mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
Comment / issue / chat images uploaded inside the Desktop app rendered
as the broken-image fallback. The editor was persisting a site-relative
`/api/attachments/<id>/download` URL into markdown — that path only
resolves when the document origin proxies /api to the API host (apps/web
via Next.js rewrite). On Electron's file:// origin it never resolved.
Per GPT-Boy's plan, move the durable-URL choice from the client to the
server so the persisted shape is correct regardless of which client
performed the upload.
Server:
- AttachmentResponse gains a markdown_url field, computed by
buildMarkdownURL from the deployment policy:
• storage URL is already absolute + unsigned (public CDN, S3 public
bucket, LocalStorage with MULTICA_LOCAL_UPLOAD_BASE_URL on https) →
use it verbatim;
• CloudFront-signed mode → never expose the raw S3 URL (private
bucket); return cfg.PublicURL + /api/attachments/<id>/download so
the server can re-sign on every request;
• LocalStorage relative + cfg.PublicURL set → same prefixed API
endpoint;
• cfg.PublicURL unset → fall back to site-relative path so web's
Next.js rewrite still works.
- isDurablePublicURL helper rejects URLs carrying CloudFront / S3
signature query params, so a freshly-signed download_url can never
leak into persistence — the original MUL-3130 bug stays closed.
Frontend:
- Attachment type + AttachmentResponseSchema (and apps/mobile mirror)
carry markdown_url. Schema lenient-defaults to '' so a backend old
enough to predate this field doesn't break clients.
- useFileUpload picks markdownLink with three-layer fallback:
(1) att.markdown_url (modern server),
(2) attachmentDownloadPath(att.id) — legacy site-relative shape,
retained for backends old enough to omit markdown_url,
(3) att.url — no-workspace avatar branch with no attachment-row id.
- attachment.tsx keeps the relative→absolute absolutize pass, but
reframed as the legacy-compat fallback for already-persisted
/api/attachments/<id>/download or /uploads/<key> URLs in old
bodies. New content writes absolute URLs and skips this path.
- ContentEditor still tracks freshly-uploaded records into
AttachmentDownloadProvider so Quick Create's editor can swap the URL
via the resolver during the same session even before the server-side
binding lands.
Tests:
- server/internal/handler/file_test.go: 5 new buildMarkdownURL matrix
tests (public CDN passthrough, CloudFront-signed swap, relative
prefixing, PublicURL unset fallback, trailing-slash strip) + 15
table-driven isDurablePublicURL cases.
- packages/core/hooks/use-file-upload.test.ts: new file, 4 cases
covering modern server / legacy server / no-id avatar / oversize.
- packages/views/editor/attachment.test.tsx + content-editor.test.tsx:
10 cases for the absolutize matrix and in-session attachment merge.
- 6 existing test fixtures updated to include markdown_url.
Verification: 1236 @multica/views tests pass; 514 @multica/core tests
pass (4 new); server handler package tests pass for the new matrix
plus all pre-existing TestAttachmentToResponse* and TestDownload*
cases. Typecheck green for views/core/web/desktop. Lint clean on
touched files.
Quick Create attachment_ids binding (orphaned attachment relationship
on the resulting issue) is a follow-up — it requires a new --attachment-id
CLI flag and daemon prompt-template work and is intentionally scoped
out of this PR.
Refs: MUL-3192
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
675 lines
26 KiB
TypeScript
675 lines
26 KiB
TypeScript
/**
|
|
* Mobile-local zod schemas + fallbacks for endpoints whose responses aren't
|
|
* yet schematised in @multica/core/api/schemas. Lenient by design — see the
|
|
* leniency rationale at the top of the core file (string enums tolerated,
|
|
* loose() so unknown server fields pass through, defaults so a missing
|
|
* array doesn't take the page down).
|
|
*
|
|
* If web/desktop later need these same schemas, promote them to core; until
|
|
* then they live here so mobile satisfies its "Parse, don't cast" rule
|
|
* (root CLAUDE.md "API Response Compatibility") for these endpoints.
|
|
*/
|
|
import { z } from "zod";
|
|
import type {
|
|
Agent,
|
|
AgentTask,
|
|
Attachment,
|
|
ChatMessage,
|
|
ChatPendingTask,
|
|
ChatSession,
|
|
Comment,
|
|
InboxItem,
|
|
IssueLabelsResponse,
|
|
Label,
|
|
ListLabelsResponse,
|
|
ListProjectResourcesResponse,
|
|
ListProjectsResponse,
|
|
MemberWithUser,
|
|
PinnedItem,
|
|
Project,
|
|
ProjectResource,
|
|
RuntimeDevice,
|
|
SearchIssuesResponse,
|
|
SearchProjectsResponse,
|
|
SendChatMessageResponse,
|
|
Squad,
|
|
TaskMessagePayload,
|
|
User,
|
|
Workspace,
|
|
} from "@multica/core/types";
|
|
import { IssueSchema } from "@multica/core/api/schemas";
|
|
|
|
/** Upload response. Only fields mobile actually consumes — `url` to put
|
|
* into the markdown link, `filename` for the `[📎 name](url)` form, `id`
|
|
* for future linking. `.loose()` so the server can add fields without
|
|
* breaking mobile. Web's AttachmentSchema (packages/core/api/schemas.ts:41)
|
|
* is even looser (only `id`); mobile validates more because the upload
|
|
* flow inserts `url` directly into editable text and an empty `url` would
|
|
* produce a broken link the user only notices after submit. */
|
|
export const AttachmentSchema: z.ZodType<Attachment> = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string().default(""),
|
|
issue_id: z.string().nullable().default(null),
|
|
comment_id: z.string().nullable().default(null),
|
|
chat_session_id: z.string().nullable().default(null),
|
|
chat_message_id: z.string().nullable().default(null),
|
|
uploader_type: z.string().default(""),
|
|
uploader_id: z.string().default(""),
|
|
filename: z.string(),
|
|
url: z.string(),
|
|
download_url: z.string().default(""),
|
|
markdown_url: z.string().default(""),
|
|
content_type: z.string().default(""),
|
|
size_bytes: z.number().default(0),
|
|
created_at: z.string().default(""),
|
|
}).loose();
|
|
|
|
/** GET /api/issues/:id/attachments — array of attachments for the issue.
|
|
* Empty array fallback so a 5xx or shape mismatch doesn't crash markdown
|
|
* rendering — image URIs simply fail to resolve and fall back to fetch. */
|
|
export const AttachmentListSchema = z.array(AttachmentSchema).default([]);
|
|
export const EMPTY_ATTACHMENT_LIST: Attachment[] = [];
|
|
|
|
/** Comment write endpoints all return a full Comment. Used by createComment /
|
|
* updateComment / resolveComment / unresolveComment via fetchValidatedWith.
|
|
* Empty fallback yields `id: ""` so downstream code (the mutations'
|
|
* onSuccess writers) can detect drift and fall back to invalidate. */
|
|
export const CommentSchema = z.object({
|
|
id: z.string(),
|
|
issue_id: z.string().default(""),
|
|
author_type: z.string().default("member"),
|
|
author_id: z.string().default(""),
|
|
content: z.string().default(""),
|
|
type: z.string().default("comment"),
|
|
parent_id: z.string().nullable().default(null),
|
|
reactions: z.array(z.unknown()).default([]),
|
|
attachments: z.array(z.unknown()).default([]),
|
|
created_at: z.string().default(""),
|
|
updated_at: z.string().default(""),
|
|
resolved_at: z.string().nullable().default(null),
|
|
resolved_by_type: z.string().nullable().default(null),
|
|
resolved_by_id: z.string().nullable().default(null),
|
|
}).loose() as unknown as z.ZodType<Comment>;
|
|
|
|
export const EMPTY_COMMENT: Comment = {
|
|
id: "",
|
|
issue_id: "",
|
|
author_type: "member",
|
|
author_id: "",
|
|
content: "",
|
|
type: "comment",
|
|
parent_id: null,
|
|
reactions: [],
|
|
attachments: [],
|
|
created_at: "",
|
|
updated_at: "",
|
|
resolved_at: null,
|
|
resolved_by_type: null,
|
|
resolved_by_id: null,
|
|
};
|
|
|
|
/** GET/PUT /api/notification-preferences. Preferences are partial — absent
|
|
* keys mean "default (= all)", an explicit "muted" turns the group off.
|
|
* Loose() so future group additions on the backend don't break parsing.
|
|
* Value type is z.string() (not z.enum) so a future server-side value like
|
|
* "snoozed" downgrades gracefully (read sites treat unknown as enabled)
|
|
* instead of failing schema parse and dropping the entire preferences map.
|
|
* Per CLAUDE.md "Enum drift downgrades, not crashes". */
|
|
export const NotificationPreferenceResponseSchema = z.object({
|
|
workspace_id: z.string().default(""),
|
|
preferences: z.record(z.string(), z.string()).default({}),
|
|
}).loose();
|
|
export const EMPTY_NOTIFICATION_PREFERENCES = {
|
|
workspace_id: "",
|
|
preferences: {},
|
|
} as const;
|
|
|
|
const LabelSchema = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string(),
|
|
name: z.string(),
|
|
color: z.string(),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
}).loose();
|
|
|
|
export const ListLabelsResponseSchema = z.object({
|
|
labels: z.array(LabelSchema).default([]),
|
|
total: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const EMPTY_LIST_LABELS_RESPONSE: ListLabelsResponse = {
|
|
labels: [],
|
|
total: 0,
|
|
};
|
|
|
|
export const IssueLabelsResponseSchema = z.object({
|
|
labels: z.array(LabelSchema).default([]),
|
|
}).loose();
|
|
|
|
export const EMPTY_ISSUE_LABELS_RESPONSE: IssueLabelsResponse = {
|
|
labels: [],
|
|
};
|
|
|
|
export const ProjectSchema = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string(),
|
|
title: z.string(),
|
|
description: z.string().nullable(),
|
|
icon: z.string().nullable(),
|
|
status: z.string(),
|
|
priority: z.string(),
|
|
lead_type: z.string().nullable(),
|
|
lead_id: z.string().nullable(),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
issue_count: z.number().default(0),
|
|
done_count: z.number().default(0),
|
|
resource_count: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const ListProjectsResponseSchema = z.object({
|
|
projects: z.array(ProjectSchema).default([]),
|
|
total: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const EMPTY_LIST_PROJECTS_RESPONSE: ListProjectsResponse = {
|
|
projects: [],
|
|
total: 0,
|
|
};
|
|
|
|
// Fallback for `GET /api/projects/{id}` when the response shape drifts.
|
|
// `id` defaults to empty — caller can detect "not found / drift" by checking
|
|
// `data.id === ""` and rendering an error state instead of pretending the
|
|
// data is valid. Status / priority cast to the enum literals so TS callers
|
|
// downstream still flow correctly; runtime values came from the schema
|
|
// (`z.string()`), which would have already passed.
|
|
export const EMPTY_PROJECT: Project = {
|
|
id: "",
|
|
workspace_id: "",
|
|
title: "",
|
|
description: null,
|
|
icon: null,
|
|
status: "planned",
|
|
priority: "none",
|
|
lead_type: null,
|
|
lead_id: null,
|
|
created_at: "",
|
|
updated_at: "",
|
|
issue_count: 0,
|
|
done_count: 0,
|
|
resource_count: 0,
|
|
};
|
|
|
|
// Project resources are typed pointers to external resources (today: GitHub
|
|
// repos). resource_ref shape varies per resource_type; lenient on both
|
|
// `resource_type` (so a future type doesn't crash the list) and
|
|
// `resource_ref` (passes through unchanged for the renderer to dispatch on).
|
|
const ProjectResourceSchema = z.object({
|
|
id: z.string(),
|
|
project_id: z.string(),
|
|
workspace_id: z.string(),
|
|
resource_type: z.string(),
|
|
resource_ref: z.unknown(),
|
|
label: z.string().nullable(),
|
|
position: z.number().default(0),
|
|
created_at: z.string(),
|
|
created_by: z.string().nullable(),
|
|
}).loose();
|
|
|
|
export const ListProjectResourcesResponseSchema = z.object({
|
|
resources: z.array(ProjectResourceSchema).default([]),
|
|
total: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const EMPTY_LIST_PROJECT_RESOURCES_RESPONSE: ListProjectResourcesResponse = {
|
|
resources: [],
|
|
total: 0,
|
|
};
|
|
|
|
// =====================================================
|
|
// Chat (sessions / messages / pending task)
|
|
// =====================================================
|
|
// Lenient on every field that's purely informational (status enum, timestamps,
|
|
// agent/creator ids). `.loose()` so server-added fields pass through. The two
|
|
// fields mobile keys behaviour on — `id` and `chat_session_id` — are required.
|
|
|
|
export const ChatSessionSchema: z.ZodType<ChatSession> = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string().default(""),
|
|
agent_id: z.string().default(""),
|
|
creator_id: z.string().default(""),
|
|
title: z.string().default(""),
|
|
// Enum drift defense (root CLAUDE.md "Enum drift downgrades, not crashes"):
|
|
// unknown server values fall back to "active" so the row still renders.
|
|
status: z.enum(["active", "archived"]).catch("active"),
|
|
has_unread: z.boolean().default(false),
|
|
created_at: z.string().default(""),
|
|
updated_at: z.string().default(""),
|
|
}).loose();
|
|
|
|
export const ChatSessionListSchema = z.array(ChatSessionSchema).default([]);
|
|
|
|
export const EMPTY_CHAT_SESSION_LIST: ChatSession[] = [];
|
|
|
|
// `attachments` carried for parity rendering only — v1 doesn't author them on
|
|
// mobile. AttachmentSchema is reused as-is.
|
|
export const ChatMessageSchema: z.ZodType<ChatMessage> = z.object({
|
|
id: z.string(),
|
|
chat_session_id: z.string(),
|
|
// If the server ever introduces a third role, fall back to "assistant" so
|
|
// the message renders (as a left-aligned bubble) instead of crashing the
|
|
// list. Matches Enum drift defense.
|
|
role: z.enum(["user", "assistant"]).catch("assistant"),
|
|
content: z.string().default(""),
|
|
task_id: z.string().nullable().default(null),
|
|
created_at: z.string().default(""),
|
|
attachments: z.array(AttachmentSchema).optional(),
|
|
failure_reason: z.string().nullable().optional(),
|
|
elapsed_ms: z.number().nullable().optional(),
|
|
}).loose();
|
|
|
|
export const ChatMessageListSchema = z.array(ChatMessageSchema).default([]);
|
|
|
|
export const EMPTY_CHAT_MESSAGE_LIST: ChatMessage[] = [];
|
|
|
|
// All fields optional — server returns an empty object when no in-flight task.
|
|
export const ChatPendingTaskSchema: z.ZodType<ChatPendingTask> = z.object({
|
|
task_id: z.string().optional(),
|
|
status: z.string().optional(),
|
|
created_at: z.string().optional(),
|
|
}).loose();
|
|
|
|
export const EMPTY_CHAT_PENDING_TASK: ChatPendingTask = {};
|
|
|
|
export const SendChatMessageResponseSchema: z.ZodType<SendChatMessageResponse> = z.object({
|
|
message_id: z.string(),
|
|
task_id: z.string(),
|
|
created_at: z.string().default(""),
|
|
}).loose();
|
|
|
|
// Live timeline emitted by the agent runtime while a task is running. Each
|
|
// row is one execution step (thinking / tool_use / tool_result / text /
|
|
// error). Mirrors web's TaskMessagePayload type and the WS `task:message`
|
|
// payload so the mobile cache shape stays interchangeable with web's.
|
|
export const TaskMessagePayloadSchema: z.ZodType<TaskMessagePayload> = z.object({
|
|
task_id: z.string(),
|
|
issue_id: z.string().default(""),
|
|
chat_session_id: z.string().optional(),
|
|
seq: z.number().default(0),
|
|
// Enum drift defense: unknown server-side types fall back to "text" so
|
|
// the row still renders (as a plain markdown chunk) instead of crashing
|
|
// the timeline. Matches root CLAUDE.md "Enum drift downgrades, not crashes".
|
|
type: z
|
|
.enum(["text", "thinking", "tool_use", "tool_result", "error"])
|
|
.catch("text"),
|
|
tool: z.string().optional(),
|
|
content: z.string().optional(),
|
|
input: z.record(z.string(), z.unknown()).optional(),
|
|
output: z.string().optional(),
|
|
created_at: z.string().optional(),
|
|
}).loose();
|
|
|
|
export const TaskMessageListSchema = z.array(TaskMessagePayloadSchema).default([]);
|
|
|
|
export const EMPTY_TASK_MESSAGE_LIST: TaskMessagePayload[] = [];
|
|
|
|
// =====================================================
|
|
// Search (issues + projects)
|
|
// =====================================================
|
|
// Mirrors SearchIssueResult / SearchProjectResult in packages/core/types/api.ts.
|
|
// Web does not currently route search responses through parseWithFallback, so
|
|
// the schemas live mobile-side. Promote to core when web adopts the same
|
|
// defense.
|
|
//
|
|
// match_source is the server's hint of which field matched. Enum-drift defense
|
|
// (root CLAUDE.md "Enum drift downgrades, not crashes"): unknown values fall
|
|
// back to "title" so the row still renders without a snippet line.
|
|
|
|
const SearchIssueResultSchema = IssueSchema.safeExtend({
|
|
match_source: z.enum(["title", "description", "comment"]).catch("title"),
|
|
matched_snippet: z.string().optional(),
|
|
});
|
|
|
|
export const SearchIssuesResponseSchema = z.object({
|
|
issues: z.array(SearchIssueResultSchema).default([]),
|
|
total: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const EMPTY_SEARCH_ISSUES_RESPONSE: SearchIssuesResponse = {
|
|
issues: [],
|
|
total: 0,
|
|
};
|
|
|
|
const SearchProjectResultSchema = ProjectSchema.safeExtend({
|
|
match_source: z.enum(["title", "description"]).catch("title"),
|
|
matched_snippet: z.string().optional(),
|
|
});
|
|
|
|
export const SearchProjectsResponseSchema = z.object({
|
|
projects: z.array(SearchProjectResultSchema).default([]),
|
|
total: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const EMPTY_SEARCH_PROJECTS_RESPONSE: SearchProjectsResponse = {
|
|
projects: [],
|
|
total: 0,
|
|
};
|
|
|
|
// =====================================================
|
|
// Agent tasks (per-issue runs, active + history)
|
|
// =====================================================
|
|
// Mirrors AgentTask in packages/core/types/agent.ts. Backend handlers:
|
|
// GET /api/issues/{id}/active-task → { tasks: AgentTask[] } (may be empty)
|
|
// GET /api/issues/{id}/task-runs → AgentTask[]
|
|
// Lenient on every field — status / kind / failure_reason all use `.catch()`
|
|
// so a future server-side enum value renders a generic fallback rather than
|
|
// crashing the row (root CLAUDE.md "Enum drift downgrades, not crashes").
|
|
|
|
export const AgentTaskSchema: z.ZodType<AgentTask> = z.object({
|
|
id: z.string(),
|
|
agent_id: z.string().default(""),
|
|
runtime_id: z.string().default(""),
|
|
issue_id: z.string().default(""),
|
|
status: z
|
|
.enum(["queued", "dispatched", "running", "completed", "failed", "cancelled"])
|
|
.catch("queued"),
|
|
priority: z.number().default(0),
|
|
dispatched_at: z.string().nullable().default(null),
|
|
started_at: z.string().nullable().default(null),
|
|
completed_at: z.string().nullable().default(null),
|
|
result: z.unknown().default(null),
|
|
error: z.string().nullable().default(null),
|
|
// Backend uses empty string ("") as the "not failed" sentinel (Go
|
|
// `omitempty` on a custom string-typed enum). Normalize that to `undefined`
|
|
// so downstream truthy checks (`if (task.failure_reason)`) don't have to
|
|
// special-case both null/undefined AND "".
|
|
failure_reason: z
|
|
.enum(["agent_error", "timeout", "runtime_offline", "runtime_recovery", "manual", ""])
|
|
.optional()
|
|
.catch("")
|
|
.transform((v) => (v === "" ? undefined : v)),
|
|
created_at: z.string().default(""),
|
|
chat_session_id: z.string().optional(),
|
|
autopilot_run_id: z.string().optional(),
|
|
parent_task_id: z.string().optional(),
|
|
attempt: z.number().optional(),
|
|
trigger_comment_id: z.string().optional(),
|
|
trigger_summary: z.string().optional(),
|
|
kind: z.enum(["comment", "autopilot", "chat", "quick_create", "direct"]).optional().catch("direct"),
|
|
work_dir: z.string().optional(),
|
|
}).loose();
|
|
|
|
export const AgentTaskListSchema = z.array(AgentTaskSchema).default([]);
|
|
|
|
export const ActiveTasksResponseSchema = z.object({
|
|
tasks: z.array(AgentTaskSchema).default([]),
|
|
}).loose();
|
|
|
|
export interface ActiveTasksResponse {
|
|
tasks: AgentTask[];
|
|
}
|
|
|
|
export const EMPTY_AGENT_TASK_LIST: AgentTask[] = [];
|
|
export const EMPTY_ACTIVE_TASKS_RESPONSE: ActiveTasksResponse = { tasks: [] };
|
|
|
|
// =====================================================
|
|
// User / Workspace / Inbox / Member / Agent
|
|
// =====================================================
|
|
// Mobile reads these on every cold start (auth → workspaces → inbox → members
|
|
// → agents form the boot sequence). A schema drift in any of them used to
|
|
// cascade — getMe failure flushed the user, listWorkspaces failure landed the
|
|
// app on the workspace picker with no entries. With parseWithFallback every
|
|
// drift downgrades to "stale defaults render", and the user can keep working.
|
|
//
|
|
// All five are `.loose()` so additive backend fields (`onboarded_at` style
|
|
// flags) pass through without breaking parsing. Required identity fields
|
|
// (id, slug, etc.) stay required — a response that genuinely lacks them is
|
|
// unusable and parseWithFallback should fall back to the empty sentinel.
|
|
|
|
export const UserSchema: z.ZodType<User> = z.object({
|
|
id: z.string(),
|
|
name: z.string().default(""),
|
|
email: z.string().default(""),
|
|
avatar_url: z.string().nullable().default(null),
|
|
onboarded_at: z.string().nullable().default(null),
|
|
onboarding_questionnaire: z.record(z.string(), z.unknown()).default({}),
|
|
starter_content_state: z.string().nullable().default(null),
|
|
language: z.string().nullable().default(null),
|
|
profile_description: z.string().default(""),
|
|
timezone: z.string().nullable().default(null),
|
|
created_at: z.string().default(""),
|
|
updated_at: z.string().default(""),
|
|
}).loose();
|
|
|
|
// `id: ""` is the sentinel for "drifted / unauthenticated"; downstream code
|
|
// that switches on `user.id` will treat empty-string as a logged-out state
|
|
// (the auth hook also clears the cache on 401, so this is rarely seen).
|
|
export const EMPTY_USER: User = {
|
|
id: "",
|
|
name: "",
|
|
email: "",
|
|
avatar_url: null,
|
|
onboarded_at: null,
|
|
onboarding_questionnaire: {},
|
|
starter_content_state: null,
|
|
language: null,
|
|
profile_description: "",
|
|
timezone: null,
|
|
created_at: "",
|
|
updated_at: "",
|
|
};
|
|
|
|
export const WorkspaceSchema: z.ZodType<Workspace> = z.object({
|
|
id: z.string(),
|
|
name: z.string().default(""),
|
|
slug: z.string().default(""),
|
|
description: z.string().nullable().default(null),
|
|
context: z.string().nullable().default(null),
|
|
settings: z.record(z.string(), z.unknown()).default({}),
|
|
repos: z.array(z.object({ url: z.string() }).loose()).default([]),
|
|
issue_prefix: z.string().default(""),
|
|
avatar_url: z.string().nullable().default(null),
|
|
created_at: z.string().default(""),
|
|
updated_at: z.string().default(""),
|
|
}).loose();
|
|
|
|
export const WorkspaceListSchema = z.array(WorkspaceSchema).default([]);
|
|
export const EMPTY_WORKSPACE_LIST: Workspace[] = [];
|
|
|
|
/** Pin metadata only — display fields (title / status / icon) are NOT here,
|
|
* consumers derive them from `issueDetailOptions` / `projectDetailOptions`.
|
|
* Matches the design in packages/core/types/pin.ts. */
|
|
export const PinnedItemSchema: z.ZodType<PinnedItem> = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string().default(""),
|
|
user_id: z.string().default(""),
|
|
item_type: z.enum(["issue", "project"]).catch("issue"),
|
|
item_id: z.string(),
|
|
position: z.number().default(0),
|
|
created_at: z.string().default(""),
|
|
}).loose();
|
|
|
|
export const PinListSchema = z.array(PinnedItemSchema).default([]);
|
|
export const EMPTY_PIN_LIST: PinnedItem[] = [];
|
|
|
|
const InboxItemSchema: z.ZodType<InboxItem> = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string().default(""),
|
|
// Recipient is always a real actor in the dataset, but defend against
|
|
// either field going missing — mobile's actor lookup tolerates null.
|
|
recipient_type: z.enum(["member", "agent"]).catch("member"),
|
|
recipient_id: z.string().default(""),
|
|
// `actor_type` includes "system" for platform-triggered notifications
|
|
// (packages/core/types/inbox.ts:28). ActorAvatar handles all three plus
|
|
// null. Enum drift falls back to null so the row still renders without an
|
|
// avatar instead of crashing the list.
|
|
actor_type: z
|
|
.enum(["member", "agent", "system"])
|
|
.nullable()
|
|
.catch(null),
|
|
actor_id: z.string().nullable().default(null),
|
|
// `type` discriminates the rendered detail-label. Unknown values pass
|
|
// through as raw strings — `InboxDetailLabel` has a default branch that
|
|
// shows the raw type as fallback (components/inbox/detail-label.tsx).
|
|
type: z.string() as unknown as z.ZodType<InboxItem["type"]>,
|
|
severity: z
|
|
.enum(["action_required", "attention", "info"])
|
|
.catch("info"),
|
|
issue_id: z.string().nullable().default(null),
|
|
title: z.string().default(""),
|
|
body: z.string().nullable().default(null),
|
|
issue_status: z.string().nullable().default(null) as unknown as z.ZodType<
|
|
InboxItem["issue_status"]
|
|
>,
|
|
read: z.boolean().default(false),
|
|
archived: z.boolean().default(false),
|
|
created_at: z.string().default(""),
|
|
details: z.record(z.string(), z.string()).nullable().default(null),
|
|
}).loose();
|
|
|
|
export const InboxListSchema = z.array(InboxItemSchema).default([]);
|
|
export const EMPTY_INBOX_LIST: InboxItem[] = [];
|
|
|
|
export const MemberWithUserSchema: z.ZodType<MemberWithUser> = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string().default(""),
|
|
user_id: z.string().default(""),
|
|
role: z.enum(["owner", "admin", "member"]).catch("member"),
|
|
created_at: z.string().default(""),
|
|
name: z.string().default(""),
|
|
email: z.string().default(""),
|
|
avatar_url: z.string().nullable().default(null),
|
|
}).loose();
|
|
|
|
export const MemberListSchema = z.array(MemberWithUserSchema).default([]);
|
|
export const EMPTY_MEMBER_LIST: MemberWithUser[] = [];
|
|
|
|
// Agent schema is loose on every enum / structural field — the agent table is
|
|
// where new modes/visibilities/statuses get added most often. We need only id,
|
|
// name, avatar_url, and a couple of flags for the assignee picker + chat
|
|
// header; everything else is informational and safe to default.
|
|
export const AgentSchema: z.ZodType<Agent> = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string().default(""),
|
|
runtime_id: z.string().default(""),
|
|
name: z.string().default(""),
|
|
description: z.string().default(""),
|
|
instructions: z.string().default(""),
|
|
avatar_url: z.string().nullable().default(null),
|
|
runtime_mode: z.string().catch("daemon") as unknown as z.ZodType<
|
|
Agent["runtime_mode"]
|
|
>,
|
|
runtime_config: z.record(z.string(), z.unknown()).default({}),
|
|
custom_args: z.array(z.string()).default([]),
|
|
// MUL-2600: agent resource shape no longer carries custom_env or
|
|
// custom_env_redacted. Mobile keeps only the coarse metadata that
|
|
// mirrors web's expectations. Real env values are reachable via the
|
|
// dedicated /env endpoint and we don't expose env editing on mobile.
|
|
has_custom_env: z.boolean().optional(),
|
|
custom_env_key_count: z.number().optional(),
|
|
visibility: z.string().catch("workspace") as unknown as z.ZodType<
|
|
Agent["visibility"]
|
|
>,
|
|
status: z.string().catch("active") as unknown as z.ZodType<Agent["status"]>,
|
|
max_concurrent_tasks: z.number().default(1),
|
|
model: z.string().default(""),
|
|
owner_id: z.string().nullable().default(null),
|
|
skills: z.array(z.unknown()).default([]) as unknown as z.ZodType<
|
|
Agent["skills"]
|
|
>,
|
|
created_at: z.string().default(""),
|
|
updated_at: z.string().default(""),
|
|
archived_at: z.string().nullable().default(null),
|
|
archived_by: z.string().nullable().default(null),
|
|
}).loose();
|
|
|
|
export const AgentListSchema = z.array(AgentSchema).default([]);
|
|
export const EMPTY_AGENT_LIST: Agent[] = [];
|
|
|
|
// Runtime device — the daemon (local or cloud) an agent binds to. Mobile reads
|
|
// it for the presence dot: `status` + `last_seen_at` drive the three-state
|
|
// availability derivation in @multica/core/agents/derive-presence. All other
|
|
// fields default safely so a backend that adds optional new metadata
|
|
// (timezone, visibility flags, etc.) doesn't break the parse.
|
|
export const RuntimeSchema: z.ZodType<RuntimeDevice> = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string().default(""),
|
|
daemon_id: z.string().nullable().default(null),
|
|
name: z.string().default(""),
|
|
runtime_mode: z.string().catch("local") as unknown as z.ZodType<
|
|
RuntimeDevice["runtime_mode"]
|
|
>,
|
|
provider: z.string().default(""),
|
|
launch_header: z.string().default(""),
|
|
// The two fields presence derivation actually reads. Status defaults to
|
|
// "offline" — a runtime row with an unparseable status is treated as
|
|
// unreachable, which is the safe degrade for the dot.
|
|
status: z.enum(["online", "offline"]).catch("offline"),
|
|
last_seen_at: z.string().nullable().default(null),
|
|
device_info: z.string().default(""),
|
|
metadata: z.record(z.string(), z.unknown()).default({}),
|
|
owner_id: z.string().nullable().default(null),
|
|
visibility: z.string().catch("private") as unknown as z.ZodType<
|
|
RuntimeDevice["visibility"]
|
|
>,
|
|
timezone: z.string().default(""),
|
|
created_at: z.string().default(""),
|
|
updated_at: z.string().default(""),
|
|
}).loose();
|
|
|
|
export const RuntimeListSchema = z.array(RuntimeSchema).default([]);
|
|
export const EMPTY_RUNTIME_LIST: RuntimeDevice[] = [];
|
|
|
|
// Squad schema — fields mobile actually consumes for the @mention suggestion
|
|
// bar (id, name, archived_at filter) plus identity/timestamp fields that are
|
|
// safe to default. `.loose()` so the server can add squad fields without
|
|
// breaking the parser.
|
|
export const SquadSchema: z.ZodType<Squad> = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string().default(""),
|
|
name: z.string().default(""),
|
|
description: z.string().default(""),
|
|
instructions: z.string().default(""),
|
|
avatar_url: z.string().nullable().default(null),
|
|
leader_id: z.string().default(""),
|
|
creator_id: z.string().default(""),
|
|
created_at: z.string().default(""),
|
|
updated_at: z.string().default(""),
|
|
archived_at: z.string().nullable().default(null),
|
|
archived_by: z.string().nullable().default(null),
|
|
}).loose();
|
|
|
|
export const SquadListSchema = z.array(SquadSchema).default([]);
|
|
export const EMPTY_SQUAD_LIST: Squad[] = [];
|
|
|
|
// Single-issue fallback used by getIssue. Mobile reuses IssueSchema from core
|
|
// for parsing; this sentinel lets parseWithFallback yield a structurally-
|
|
// valid Issue when the response drifts. `id: ""` flags drift downstream — the
|
|
// detail screen treats it as "issue not found" and shows the empty state.
|
|
export const EMPTY_ISSUE_FALLBACK: import("@multica/core/types").Issue = {
|
|
id: "",
|
|
workspace_id: "",
|
|
number: 0,
|
|
identifier: "",
|
|
title: "",
|
|
description: null,
|
|
status: "backlog",
|
|
priority: "none",
|
|
assignee_type: null,
|
|
assignee_id: null,
|
|
creator_type: "member",
|
|
creator_id: "",
|
|
parent_issue_id: null,
|
|
project_id: null,
|
|
position: 0,
|
|
start_date: null,
|
|
due_date: null,
|
|
metadata: {},
|
|
created_at: "",
|
|
updated_at: "",
|
|
};
|
|
|
|
// Helpers re-exported for ergonomic single-import at the call site.
|
|
export type { Label, Project, ProjectResource };
|