Files
multica/apps/mobile/data/schemas.ts
Multica Eve abf99eb700 fix(attachments): server-driven markdown_url + legacy compat (MUL-3192) (#3991)
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>
2026-06-10 16:00:40 +08:00

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 };