Files
multica/apps/mobile/data/schemas.ts
Bohan Jiang 13f74e651a feat(agents): remove custom_env from agent resources, add audited env endpoint (MUL-2600) (#3209)
* feat(agents): remove custom_env from agent resources, add audited env endpoint (MUL-2600)

The agent resource shape (list / get / create / update / archive /
restore responses + WebSocket events) no longer carries `custom_env`
values. Reads/writes of env now flow exclusively through a dedicated
`/api/agents/{id}/env` endpoint that is owner/admin-only, rejects
agent-actor sessions, applies a "****" sentinel preserve guard on
PUT, and writes a persistent audit row per reveal/update.

Why
- `multica agent list --output json` historically returned plaintext
  `custom_env` for owner/admin callers (the redaction gate gave only
  members the masked map). Any agent token running on the workspace
  inherits its owner's role and could read every other agent's
  secrets just by listing.
- Patching list/get redaction alone (PR #3175 direction) left
  symmetric leaks via mutation responses, WS events, the "reveal"
  path itself (no actor-aware auth), and a `****` overwrite footgun
  on UpdateAgent.

What changed
- Backend: drop `custom_env` from AgentResponse; add coarse
  `has_custom_env` + `custom_env_key_count`. Strip env handling from
  UpdateAgent (silently ignored if sent). Keep CreateAgent's
  custom_env acceptance.
- Backend: new GET/PUT `/api/agents/{id}/env` handlers in
  `internal/handler/agent_env.go`:
  - resolveActor → 403 for agent actors (closes the lateral-movement
    path).
  - Owner/admin role gate via existing helper.
  - PUT honours value == "****" as "preserve existing value".
  - Both write to `activity_log` with `agent_env_revealed` /
    `agent_env_updated` actions. Audit details record key names only,
    never values.
- Daemon claim path (`ClaimAgentTask`) unchanged — `TaskAgentData`
  still carries plaintext env for runtime injection.
- SQL: new `UpdateAgentCustomEnv` query; sqlc regenerated (v1.31.1).
- CLI: new `multica agent env get|set` subcommands. `--custom-env*`
  flags removed from `multica agent update`; the no-fields error
  now points to the new path.
- Frontend: drop env fields from `Agent` + `UpdateAgentRequest`; add
  `getAgentEnv` / `updateAgentEnv` client methods; rewrite env-tab
  to show "N variables configured" + explicit "Reveal & edit"
  button, fetching values only on intentional reveal.
- Locales: parity-safe additions to en + zh-Hans.
- Docs: agents-create.{mdx,zh.mdx} reflect the new threat model and
  endpoint.
- Mobile: schema drops `custom_env` / `custom_env_redacted`, adds
  metadata fields.

Tests
- Handler tests pinned the new invariants: no env in list/get
  responses, owner reveal happy-path + audit row, agent-actor 403,
  `****` sentinel preserves real values, UpdateAgent silently
  ignores `custom_env`, pure `mergeAgentEnv` cases.
- CLI tests pivot to the new flag surface: `agent update` MUST NOT
  expose the env flags; `agent env set` MUST expose
  --custom-env-stdin/--custom-env-file.
- Frontend test fixtures updated; pnpm typecheck / test / lint
  pass cleanly.

This is a breaking API change. Scripts that read `custom_env` from
`/api/agents` must migrate to `GET /api/agents/{id}/env`.

Co-authored-by: multica-agent <github@multica.ai>

* fix(agents): close actor-spoofing + audit fail-closed in env endpoints (MUL-2600)

Addresses Elon's review of #3209:

* Mint a task-scoped `mat_` token per claim, bound to (agent, task,
  workspace, owner). Daemon injects it into the agent process in place
  of its own credential. Auth middleware authoritatively rebuilds
  X-User-ID / X-Agent-ID / X-Task-ID from the token row and sets
  X-Actor-Source=task_token; that header is server-set only — incoming
  values are stripped before any auth branch runs. resolveActor honors
  the header so an agent that strips X-Agent-ID / X-Task-ID still
  resolves as actor=agent.
* GetAgentEnv / UpdateAgentEnv are now fail-closed on audit-log
  failures: GET refuses to return plaintext, PUT persists inside the
  same tx as the audit row so they commit/roll back together.
* PUT /api/agents/{id} returns 400 when the body carries custom_env
  instead of silently dropping it — directs callers to the audited env
  endpoint.
* Agent actors never see mcp_config, even when the underlying member
  is owner/admin; mutation broadcasts go through a redaction shim so
  WS subscribers don't pick it up either.
* Fix backend test that asserted dense JSON (jsonb::text renders
  whitespace) and frontend test that assumed a unique "Test User"
  match.

Co-authored-by: multica-agent <github@multica.ai>

* fix(agents): close residual MUL-2600 gaps from review (MUL-2600)

Migration 108 FK now correctly references agent_task_queue(id) instead
of the non-existent agent_task table; the previous name blocked CI
backend migrations.

Task-token-authenticated requests can no longer be re-routed at a
different workspace by passing workspace_slug / workspace_id /
?workspace_id / a URL workspace param. ResolveWorkspaceIDFromRequest
and resolveWorkspaceUUID both short-circuit on X-Actor-Source=task_token
and return only the token-bound X-Workspace-ID; buildMiddleware adds a
defence-in-depth 403 if any URL-resolved workspace disagrees with the
token binding.

mcp_config no longer leaks back to agent actors through UpdateAgent /
CreateAgent / ArchiveAgent / RestoreAgent HTTP responses — the same
redactAgentResponseForActor helper that GetAgent/ListAgents use is now
applied to mutation responses too. WS broadcasts were already redacted
via broadcastAgentResponse.

FailTask and every TaskService cancel path (CancelTask /
CancelTasksForIssue / CancelTasksForAgent / CancelTasksByTriggerComment
/ BroadcastCancelledTasks) now eagerly DeleteTaskTokensByTask so the
mat_ token's 24h window doesn't outlive a terminated task. Failure is
non-fatal — the FK cascade and expiry remain durable guards.

Doc-only: clarify that PUT /api/agents/{id} now hard-rejects bodies
that carry custom_env (was previously "silently ignores").

Tests:
- middleware: TestResolveWorkspaceIDFromRequest gains a task_token
  case asserting client-supplied slug/id/query cannot override the
  bound workspace.
- handler: TestUpdateAgent_RedactsMcpConfigForAgentActor and
  TestUpdateAgent_KeepsMcpConfigForMemberActor pin the mutation-
  response redaction contract per actor type.

Co-authored-by: multica-agent <github@multica.ai>

* fix(agents): match redacted mcp_config as JSON null, not Go nil (MUL-2600)

`AgentResponse.McpConfig` is `json.RawMessage` without `omitempty`, so
the redacted response serialises as `"mcp_config": null`. On decode,
`json.RawMessage` keeps the literal bytes `null` rather than collapsing
to Go nil, which made the assertion fire on a non-leak.

The product contract (field always present, distinguished from "no
config" via `mcp_config_redacted`) is intentional, so adjust the test
to check for "no secret-bearing content" instead of weakening the
contract via `omitempty`.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 18:42:48 +08:00

672 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(""),
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(),
}).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(""),
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 };