mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 14:00:09 +02:00
Adds a cross-workspace unread summary so the workspace switcher shows the existing brand dot when a workspace OTHER than the active one has unread inbox items. The active workspace's own unread stays on the Inbox nav count to avoid a duplicate signal, and the dot is shared with the pending- invitation indicator. Backend: new GET /api/inbox/unread-summary returns per-workspace unread counts for the user, scoped via a member join so a left workspace can't light the dot. One account-level query instead of N per-workspace inbox fetches. Frontend: schema-guarded api.getInboxUnreadSummary, a single account-level TanStack Query, and a derived "other workspace has unread" boolean in AppSidebar (shared by web + desktop). Inbox WS events (new/read/archived/ batch) and reconnect invalidate the summary, so the dot appears and clears in realtime even for events from a non-active workspace. Closes multica-ai/multica#3773 Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
1106 lines
38 KiB
TypeScript
1106 lines
38 KiB
TypeScript
import { z } from "zod";
|
|
import type {
|
|
Agent,
|
|
AgentTemplate,
|
|
AgentTemplateSummary,
|
|
Attachment,
|
|
BillingBalance,
|
|
BillingBatchesPage,
|
|
BillingCheckoutSessionStatus,
|
|
BillingPriceTier,
|
|
BillingTopupsPage,
|
|
BillingTransactionsPage,
|
|
CancelTaskResponse,
|
|
CreateAgentFromTemplateResponse,
|
|
CreateBillingCheckoutSessionResponse,
|
|
CreateBillingPortalSessionResponse,
|
|
GroupedIssuesResponse,
|
|
InboxWorkspaceUnread,
|
|
ListIssuesResponse,
|
|
ListWebhookDeliveriesResponse,
|
|
SearchIssuesResponse,
|
|
SearchProjectsResponse,
|
|
Squad,
|
|
TimelineEntry,
|
|
User,
|
|
WebhookDelivery,
|
|
} from "../types";
|
|
import type { CloudRuntimeNode } from "../runtimes/cloud-runtime";
|
|
|
|
export interface AppConfigResponse {
|
|
cdn_domain: string;
|
|
// True when the CDN domain serves private content via time-bounded signed
|
|
// URLs (CloudFront signing) — raw storage URLs on that domain are NOT
|
|
// publicly fetchable and must not be used as native media sources
|
|
// (MUL-3254). Older servers omit the field; treat that as false.
|
|
cdn_signed?: boolean;
|
|
allow_signup: boolean;
|
|
google_client_id?: string;
|
|
posthog_key?: string;
|
|
posthog_host?: string;
|
|
analytics_environment?: string;
|
|
daemon_server_url?: string;
|
|
daemon_app_url?: string;
|
|
workspace_creation_disabled?: boolean;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Schemas for the highest-risk API endpoints — those whose responses drive
|
|
// the issue detail page (timeline, comments, subscribers) and the issues
|
|
// list. These are the surfaces that white-screened in #2143 / #2147 / #2192.
|
|
//
|
|
// These schemas are intentionally LENIENT:
|
|
// - String enums are stored as `z.string()` rather than `z.enum([...])`.
|
|
// A new server-side enum value should render as a generic fallback in
|
|
// the UI, never crash a `safeParse`.
|
|
// - Optional fields are unioned with `null` and given fallbacks where
|
|
// existing UI code already coerces them.
|
|
// - Arrays default to `[]` so a missing `reactions` / `attachments` /
|
|
// `entries` field doesn't take the page down.
|
|
// - Every object schema ends with `.loose()` so unknown server-side
|
|
// fields pass through unchanged. zod 4's `.object()` defaults to STRIP,
|
|
// which would silently delete fields the schema didn't explicitly list
|
|
// — fine while the TS type doesn't claim them, but the moment a future
|
|
// PR adds a TS field without updating the schema, the cast `as T` lies
|
|
// and the field shows up as `undefined` at runtime. `.loose()` removes
|
|
// that synchronisation hazard.
|
|
//
|
|
// These schemas are deliberately not typed as `z.ZodType<TimelineEntry>` /
|
|
// `z.ZodType<Issue>` etc. — the strict TS types narrow string fields to
|
|
// literal unions, which would defeat the leniency above. `parseWithFallback`
|
|
// returns the parsed value cast to the caller-supplied `T`, so the strict
|
|
// type still flows out at the call site; the schema only guards shape.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ReactionSchema = z.object({
|
|
id: z.string(),
|
|
comment_id: z.string(),
|
|
actor_type: z.string(),
|
|
actor_id: z.string(),
|
|
emoji: z.string(),
|
|
created_at: z.string(),
|
|
});
|
|
|
|
// Nested attachments embedded in timeline/comment responses stay lenient on
|
|
// purpose: a single malformed attachment must not knock the whole timeline
|
|
// into the fallback `[]`.
|
|
const AttachmentSchema = z.object({
|
|
id: z.string(),
|
|
}).loose();
|
|
|
|
// Standalone attachment lookup (`GET /api/attachments/{id}`) is the source of
|
|
// truth for click-time download URLs. The two fields the download flow opens
|
|
// in a new tab — `download_url` and `url` — must be strings, otherwise we'd
|
|
// happily `window.open(undefined)`. `filename` gates the toast/title and is
|
|
// also enforced so a missing value falls back to the empty record below.
|
|
//
|
|
// `markdown_url` is parsed lenient: a server old enough to predate
|
|
// MUL-3192 omits the field, in which case the schema defaults it to "".
|
|
// Callers that need to persist a URL into markdown should go through the
|
|
// `useFileUpload` helper (which falls back to the legacy
|
|
// `attachmentDownloadPath` shape when `markdown_url` is empty), so the
|
|
// empty-string default does not silently break any persistence path.
|
|
export const AttachmentResponseSchema = z.object({
|
|
id: z.string(),
|
|
url: z.string(),
|
|
download_url: z.string(),
|
|
markdown_url: z.string().optional().default(""),
|
|
filename: z.string(),
|
|
chat_session_id: z.string().nullable().optional(),
|
|
chat_message_id: z.string().nullable().optional(),
|
|
}).loose();
|
|
|
|
export const EMPTY_ATTACHMENT: Attachment = {
|
|
id: "",
|
|
workspace_id: "",
|
|
issue_id: null,
|
|
comment_id: null,
|
|
chat_session_id: null,
|
|
chat_message_id: null,
|
|
uploader_type: "",
|
|
uploader_id: "",
|
|
filename: "",
|
|
url: "",
|
|
download_url: "",
|
|
markdown_url: "",
|
|
content_type: "",
|
|
size_bytes: 0,
|
|
created_at: "",
|
|
};
|
|
|
|
// All object schemas use `.loose()` so unknown server-side fields pass
|
|
// through unchanged. zod 4's `.object()` defaults to STRIP, which would
|
|
// silently drop new fields and surface as a "field neither showed up in
|
|
// the UI" mystery the next time the TS type adopted them but the schema
|
|
// wasn't updated in lock-step. `.loose()` removes that synchronisation
|
|
// hazard — the schema validates the shape it knows about and leaves the
|
|
// rest alone.
|
|
const TimelineEntrySchema = z.object({
|
|
type: z.string(),
|
|
id: z.string(),
|
|
actor_type: z.string(),
|
|
actor_id: z.string(),
|
|
created_at: z.string(),
|
|
action: z.string().optional(),
|
|
details: z.record(z.string(), z.unknown()).optional(),
|
|
content: z.string().optional(),
|
|
parent_id: z.string().nullable().optional(),
|
|
updated_at: z.string().optional(),
|
|
comment_type: z.string().optional(),
|
|
reactions: z.array(ReactionSchema).optional(),
|
|
attachments: z.array(AttachmentSchema).optional(),
|
|
source_task_id: z.string().nullable().optional(),
|
|
coalesced_count: z.number().optional(),
|
|
}).loose();
|
|
|
|
// /timeline returns a flat array of TimelineEntry, oldest first. The
|
|
// previously cursor-paginated wrapper was removed (#1929) — at observed data
|
|
// sizes (p99 ~30 entries per issue) paged delivery only created bugs.
|
|
export const TimelineEntriesSchema = z.array(TimelineEntrySchema);
|
|
|
|
export const EMPTY_TIMELINE_ENTRIES: TimelineEntry[] = [];
|
|
|
|
const OptionalStringSchema = z.preprocess(
|
|
(value) => (typeof value === "string" ? value : undefined),
|
|
z.string().optional(),
|
|
);
|
|
|
|
const BooleanWithDefaultSchema = (fallback: boolean) =>
|
|
z.preprocess(
|
|
(value) => (typeof value === "boolean" ? value : undefined),
|
|
z.boolean().default(fallback),
|
|
);
|
|
|
|
export const AppConfigSchema = z.object({
|
|
cdn_domain: z.string().default(""),
|
|
cdn_signed: BooleanWithDefaultSchema(false),
|
|
allow_signup: BooleanWithDefaultSchema(true),
|
|
google_client_id: OptionalStringSchema,
|
|
posthog_key: OptionalStringSchema,
|
|
posthog_host: OptionalStringSchema,
|
|
analytics_environment: OptionalStringSchema,
|
|
daemon_server_url: OptionalStringSchema,
|
|
daemon_app_url: OptionalStringSchema,
|
|
workspace_creation_disabled: BooleanWithDefaultSchema(false).optional(),
|
|
}).loose();
|
|
|
|
export const EMPTY_APP_CONFIG: AppConfigResponse = {
|
|
cdn_domain: "",
|
|
cdn_signed: false,
|
|
allow_signup: true,
|
|
google_client_id: "",
|
|
daemon_server_url: "",
|
|
daemon_app_url: "",
|
|
workspace_creation_disabled: false,
|
|
};
|
|
|
|
export const CommentSchema = z.object({
|
|
id: z.string(),
|
|
issue_id: z.string(),
|
|
author_type: z.string(),
|
|
author_id: z.string(),
|
|
content: z.string(),
|
|
type: z.string(),
|
|
parent_id: z.string().nullable(),
|
|
reactions: z.array(ReactionSchema).default([]),
|
|
attachments: z.array(AttachmentSchema).default([]),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
source_task_id: z.string().nullable().optional(),
|
|
}).loose();
|
|
|
|
export const CommentsListSchema = z.array(CommentSchema);
|
|
|
|
const CommentTriggerPreviewAgentSchema = z.object({
|
|
id: z.string(),
|
|
name: z.string().default(""),
|
|
avatar_url: z.string().optional(),
|
|
source: z.string().default(""),
|
|
reason: z.string().default(""),
|
|
}).loose();
|
|
|
|
export const CommentTriggerPreviewSchema = z.object({
|
|
agents: z.array(CommentTriggerPreviewAgentSchema).default([]),
|
|
}).loose();
|
|
|
|
const IssueTriggerPreviewItemSchema = z.object({
|
|
issue_id: z.string(),
|
|
agent_id: z.string().default(""),
|
|
source: z.string().default(""),
|
|
handoff_supported: z.boolean().default(false),
|
|
}).loose();
|
|
|
|
export const IssueTriggerPreviewSchema = z.object({
|
|
triggers: z.array(IssueTriggerPreviewItemSchema).default([]),
|
|
total_count: z.number().default(0),
|
|
}).loose();
|
|
|
|
// Metadata is primitive-only by API/DB contract. Stay lenient on shape:
|
|
// unknown keys land as `unknown` to a caller, but the field itself defaults
|
|
// to {} so consumers never need to nil-guard `issue.metadata`.
|
|
const IssueMetadataSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({});
|
|
|
|
export const IssueSchema = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string(),
|
|
number: z.number(),
|
|
identifier: z.string(),
|
|
title: z.string(),
|
|
description: z.string().nullable(),
|
|
status: z.string(),
|
|
priority: z.string(),
|
|
assignee_type: z.string().nullable(),
|
|
assignee_id: z.string().nullable(),
|
|
creator_type: z.string(),
|
|
creator_id: z.string(),
|
|
parent_issue_id: z.string().nullable(),
|
|
project_id: z.string().nullable(),
|
|
position: z.number(),
|
|
// Older backends predate `stage`; default to null so a missing field parses
|
|
// cleanly into the non-optional Issue.stage (number | null).
|
|
stage: z.number().nullable().default(null),
|
|
start_date: z.string().nullable(),
|
|
due_date: z.string().nullable(),
|
|
metadata: IssueMetadataSchema,
|
|
reactions: z.array(z.unknown()).optional(),
|
|
labels: z.array(z.unknown()).optional(),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
}).loose();
|
|
|
|
export const ListIssuesResponseSchema = z.object({
|
|
issues: z.array(IssueSchema).default([]),
|
|
total: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const EMPTY_LIST_ISSUES_RESPONSE: ListIssuesResponse = {
|
|
issues: [],
|
|
total: 0,
|
|
};
|
|
|
|
const SearchIssueResultSchema = IssueSchema.extend({
|
|
match_source: z.string(),
|
|
matched_snippet: z.string().optional(),
|
|
matched_description_snippet: z.string().optional(),
|
|
matched_comment_snippet: z.string().optional(),
|
|
}).loose();
|
|
|
|
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 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();
|
|
|
|
const SearchProjectResultSchema = ProjectSchema.extend({
|
|
match_source: z.string(),
|
|
matched_snippet: z.string().optional(),
|
|
}).loose();
|
|
|
|
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,
|
|
};
|
|
|
|
const IssueAssigneeGroupSchema = z.object({
|
|
id: z.string(),
|
|
assignee_type: z.string().nullable(),
|
|
assignee_id: z.string().nullable(),
|
|
issues: z.array(IssueSchema).default([]),
|
|
total: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const GroupedIssuesResponseSchema = z.object({
|
|
groups: z.array(IssueAssigneeGroupSchema).default([]),
|
|
}).loose();
|
|
|
|
export const EMPTY_GROUPED_ISSUES_RESPONSE: GroupedIssuesResponse = {
|
|
groups: [],
|
|
};
|
|
|
|
const SubscriberSchema = z.object({
|
|
issue_id: z.string(),
|
|
user_type: z.string(),
|
|
user_id: z.string(),
|
|
reason: z.string(),
|
|
created_at: z.string(),
|
|
}).loose();
|
|
|
|
export const SubscribersListSchema = z.array(SubscriberSchema);
|
|
|
|
export const ChildIssuesResponseSchema = z.object({
|
|
issues: z.array(IssueSchema).default([]),
|
|
}).loose();
|
|
|
|
export const CloudRuntimeNodeSchema = z.object({
|
|
id: z.string(),
|
|
owner_id: z.string(),
|
|
instance_id: z.string(),
|
|
region: z.string(),
|
|
instance_type: z.string(),
|
|
image_id: z.string(),
|
|
subnet_id: z.string(),
|
|
name: z.string(),
|
|
status: z.string(),
|
|
tags: z.record(z.string(), z.string()).default({}),
|
|
metadata: z.record(z.string(), z.unknown()).default({}),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
}).loose();
|
|
|
|
export const CloudRuntimeNodeListSchema = z.array(CloudRuntimeNodeSchema);
|
|
|
|
export const EMPTY_CLOUD_RUNTIME_NODE_LIST: CloudRuntimeNode[] = [];
|
|
|
|
export const EMPTY_CLOUD_RUNTIME_NODE: CloudRuntimeNode = {
|
|
id: "",
|
|
owner_id: "",
|
|
instance_id: "",
|
|
region: "",
|
|
instance_type: "",
|
|
image_id: "",
|
|
subnet_id: "",
|
|
name: "",
|
|
status: "",
|
|
tags: {},
|
|
metadata: {},
|
|
created_at: "",
|
|
updated_at: "",
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Workspace dashboard schemas
|
|
//
|
|
// The dashboard hits three independent rollup endpoints. Each returns a flat
|
|
// array, and every field is consumed by chart / KPI math — a missing number
|
|
// silently degrades to NaN downstream, so we coerce missing numbers to 0.
|
|
// String fields default to "" (no enum narrowing) to survive future model /
|
|
// agent ID drift, and so a single null from tz-aware SQL bucketing fails
|
|
// only that row instead of dropping the whole array to the `[]` fallback.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DashboardUsageDailySchema = z.object({
|
|
date: z.string().default(""),
|
|
provider: z.string().default(""),
|
|
model: z.string().default(""),
|
|
input_tokens: z.number().default(0),
|
|
output_tokens: z.number().default(0),
|
|
cache_read_tokens: z.number().default(0),
|
|
cache_write_tokens: z.number().default(0),
|
|
task_count: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const DashboardUsageDailyListSchema = z.array(DashboardUsageDailySchema);
|
|
|
|
const DashboardUsageByAgentSchema = z.object({
|
|
agent_id: z.string().default(""),
|
|
provider: z.string().default(""),
|
|
model: z.string().default(""),
|
|
input_tokens: z.number().default(0),
|
|
output_tokens: z.number().default(0),
|
|
cache_read_tokens: z.number().default(0),
|
|
cache_write_tokens: z.number().default(0),
|
|
task_count: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const DashboardUsageByAgentListSchema = z.array(DashboardUsageByAgentSchema);
|
|
|
|
const DashboardAgentRunTimeSchema = z.object({
|
|
agent_id: z.string().default(""),
|
|
total_seconds: z.number().default(0),
|
|
task_count: z.number().default(0),
|
|
failed_count: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const DashboardAgentRunTimeListSchema = z.array(DashboardAgentRunTimeSchema);
|
|
|
|
const DashboardRunTimeDailySchema = z.object({
|
|
date: z.string().default(""),
|
|
total_seconds: z.number().default(0),
|
|
task_count: z.number().default(0),
|
|
failed_count: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const DashboardRunTimeDailyListSchema = z.array(DashboardRunTimeDailySchema);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Runtime usage schemas — the runtime-detail page's four usage endpoints
|
|
// (`/api/runtimes/:id/usage*`). Same leniency rules as the dashboard
|
|
// schemas above: numbers default to 0, strings to "", `.loose()` passes
|
|
// unknown fields.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const RuntimeUsageSchema = z.object({
|
|
runtime_id: z.string().default(""),
|
|
date: z.string().default(""),
|
|
provider: z.string().default(""),
|
|
model: z.string().default(""),
|
|
input_tokens: z.number().default(0),
|
|
output_tokens: z.number().default(0),
|
|
cache_read_tokens: z.number().default(0),
|
|
cache_write_tokens: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const RuntimeUsageListSchema = z.array(RuntimeUsageSchema);
|
|
|
|
const RuntimeHourlyActivitySchema = z.object({
|
|
hour: z.number().default(0),
|
|
count: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const RuntimeHourlyActivityListSchema = z.array(RuntimeHourlyActivitySchema);
|
|
|
|
const RuntimeUsageByAgentSchema = z.object({
|
|
agent_id: z.string().default(""),
|
|
provider: z.string().default(""),
|
|
model: z.string().default(""),
|
|
input_tokens: z.number().default(0),
|
|
output_tokens: z.number().default(0),
|
|
cache_read_tokens: z.number().default(0),
|
|
cache_write_tokens: z.number().default(0),
|
|
task_count: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const RuntimeUsageByAgentListSchema = z.array(RuntimeUsageByAgentSchema);
|
|
|
|
const RuntimeUsageByHourSchema = z.object({
|
|
hour: z.number().default(0),
|
|
model: z.string().default(""),
|
|
input_tokens: z.number().default(0),
|
|
output_tokens: z.number().default(0),
|
|
cache_read_tokens: z.number().default(0),
|
|
cache_write_tokens: z.number().default(0),
|
|
task_count: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const RuntimeUsageByHourListSchema = z.array(RuntimeUsageByHourSchema);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task cancellation (`POST /api/tasks/:id/cancel`)
|
|
//
|
|
// This response is consumed directly by chat recovery. The embedded task
|
|
// object stays loose so daemon/runtime fields can drift, but the optional
|
|
// `cancelled_chat_message` payload must be well-formed before the UI deletes
|
|
// a message from cache or restores text into the input.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const AgentTaskResponseSchema = z.object({
|
|
id: z.string(),
|
|
agent_id: z.string().default(""),
|
|
runtime_id: z.string().default(""),
|
|
issue_id: z.string().default(""),
|
|
status: z.string().default("cancelled"),
|
|
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),
|
|
failure_reason: z.string().optional(),
|
|
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(),
|
|
handoff_note: z.string().optional(),
|
|
kind: z.string().optional(),
|
|
work_dir: z.string().optional(),
|
|
relative_work_dir: z.string().optional(),
|
|
}).loose();
|
|
|
|
const CancelledChatMessageSchema = z.object({
|
|
chat_session_id: z.string(),
|
|
message_id: z.string(),
|
|
content: z.string(),
|
|
restore_to_input: z.boolean().default(false),
|
|
// Attachments detached from the deleted message so a restored draft can
|
|
// re-bind them on re-send. Absent on servers that predate the field.
|
|
attachments: z.array(AttachmentSchema).optional(),
|
|
}).loose();
|
|
|
|
export const CancelTaskResponseSchema = AgentTaskResponseSchema.extend({
|
|
cancelled_chat_message: CancelledChatMessageSchema.nullish()
|
|
.transform((value) => value ?? undefined),
|
|
}).loose();
|
|
|
|
export const EMPTY_CANCEL_TASK_RESPONSE: CancelTaskResponse = {
|
|
id: "",
|
|
agent_id: "",
|
|
runtime_id: "",
|
|
issue_id: "",
|
|
status: "cancelled",
|
|
priority: 0,
|
|
dispatched_at: null,
|
|
started_at: null,
|
|
completed_at: null,
|
|
result: null,
|
|
error: null,
|
|
created_at: "",
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Agent template catalog — `/api/agent-templates*` and the
|
|
// create-from-template response. The desktop app's create-agent picker
|
|
// reaches these endpoints, and a future server change to the template shape
|
|
// would white-screen older installed builds (#2192 pattern) without these
|
|
// parsers. Lenient by the same rules as IssueSchema above: arrays default to
|
|
// `[]`, optional fields stay optional, `.loose()` lets unknown fields pass
|
|
// through unchanged.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const AgentTemplateSkillRefSchema = z.object({
|
|
source_url: z.string(),
|
|
cached_name: z.string().default(""),
|
|
cached_description: z.string().default(""),
|
|
}).loose();
|
|
|
|
const AgentTemplateSummarySchemaBase = z.object({
|
|
slug: z.string(),
|
|
name: z.string(),
|
|
description: z.string().default(""),
|
|
category: z.string().optional(),
|
|
icon: z.string().optional(),
|
|
accent: z.string().optional(),
|
|
// skills MUST default to [] — picker code reads `template.skills.length`
|
|
// and `.map(...)`, both of which crash on `undefined`. The most common
|
|
// future drift (field renamed / wrapped) lands here.
|
|
skills: z.array(AgentTemplateSkillRefSchema).default([]),
|
|
}).loose();
|
|
|
|
export const AgentTemplateSummarySchema = AgentTemplateSummarySchemaBase;
|
|
|
|
// List endpoint historically returns a bare array. Server could legitimately
|
|
// migrate to `{templates: [...]}` later — we accept either shape so an old
|
|
// desktop survives the upgrade.
|
|
export const AgentTemplateSummaryListSchema = z.union([
|
|
z.array(AgentTemplateSummarySchemaBase),
|
|
z.object({ templates: z.array(AgentTemplateSummarySchemaBase).default([]) })
|
|
.loose()
|
|
.transform((v) => v.templates),
|
|
]);
|
|
|
|
export const EMPTY_AGENT_TEMPLATE_SUMMARY_LIST: AgentTemplateSummary[] = [];
|
|
|
|
export const AgentTemplateSchema = AgentTemplateSummarySchemaBase.extend({
|
|
// Detail-only field. Default "" so a malformed detail still renders the
|
|
// header + skill list; the user just sees an empty Instructions block.
|
|
instructions: z.string().default(""),
|
|
}).loose();
|
|
|
|
// Used as the parse fallback for `GET /api/agent-templates/:slug`. Slug comes
|
|
// from the URL, so we round-trip the requested one back into the fallback
|
|
// at the call site (see `getAgentTemplate` in client.ts).
|
|
export const EMPTY_AGENT_TEMPLATE_DETAIL: AgentTemplate = {
|
|
slug: "",
|
|
name: "",
|
|
description: "",
|
|
skills: [],
|
|
instructions: "",
|
|
};
|
|
|
|
// `agent` is a full Agent record — schematising every field would duplicate
|
|
// a 50-field interface and bit-rot fast. We keep it loose and require only
|
|
// `id`, the one field the create-from-template flow consumes (used to
|
|
// navigate to the new agent's detail page). Downstream code already
|
|
// optional-chains the rest.
|
|
const MinimalAgentSchema = z.object({
|
|
id: z.string(),
|
|
}).loose();
|
|
|
|
export const CreateAgentFromTemplateResponseSchema = z.object({
|
|
agent: MinimalAgentSchema,
|
|
imported_skill_ids: z.array(z.string()).default([]),
|
|
reused_skill_ids: z.array(z.string()).default([]),
|
|
}).loose();
|
|
|
|
// Fallback when the success response fails to parse. The agent server-side
|
|
// has likely been created already, so we can't pretend nothing happened —
|
|
// the caller (`create-agent-dialog.tsx`) is responsible for noticing
|
|
// `agent.id === ""` and skipping navigation while keeping the list
|
|
// invalidation, so the user finds their new agent in the list.
|
|
export const EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE: CreateAgentFromTemplateResponse = {
|
|
agent: { id: "" } as Agent,
|
|
imported_skill_ids: [],
|
|
reused_skill_ids: [],
|
|
};
|
|
|
|
// Squad list responses carry lightweight membership previews used by hover
|
|
// cards. The preview fields are additive API fields, so older backends default
|
|
// cleanly to no preview instead of breaking newer frontends.
|
|
const SquadMemberPreviewSchema = z.object({
|
|
member_type: z.string(),
|
|
member_id: z.string(),
|
|
role: z.string().default(""),
|
|
}).loose();
|
|
|
|
export const SquadSchema = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string(),
|
|
name: z.string(),
|
|
description: z.string().default(""),
|
|
instructions: z.string().default(""),
|
|
avatar_url: z.string().nullable().optional().transform((v) => v ?? null),
|
|
leader_id: z.string(),
|
|
creator_id: z.string(),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
archived_at: z.string().nullable().optional().transform((v) => v ?? null),
|
|
archived_by: z.string().nullable().optional().transform((v) => v ?? null),
|
|
member_count: z.number().default(0),
|
|
member_preview: z.array(SquadMemberPreviewSchema).default([]),
|
|
}).loose();
|
|
|
|
export const SquadListSchema = z.array(SquadSchema);
|
|
export const EMPTY_SQUAD_LIST: Squad[] = [];
|
|
export const EMPTY_SQUAD: Squad = {
|
|
id: "",
|
|
workspace_id: "",
|
|
name: "",
|
|
description: "",
|
|
instructions: "",
|
|
avatar_url: null,
|
|
leader_id: "",
|
|
creator_id: "",
|
|
created_at: "",
|
|
updated_at: "",
|
|
archived_at: null,
|
|
archived_by: null,
|
|
member_count: 0,
|
|
member_preview: [],
|
|
};
|
|
|
|
// Squad member status — backs the Squad detail page's Members tab. status
|
|
// is `string | null` (not the narrow `SquadMemberStatusValue` union) so a
|
|
// new server-side status doesn't fail the parse; the UI defaults to a
|
|
// neutral pill for unknown values.
|
|
const SquadActiveIssueBriefSchema = z.object({
|
|
issue_id: z.string(),
|
|
identifier: z.string(),
|
|
title: z.string(),
|
|
issue_status: z.string(),
|
|
}).loose();
|
|
|
|
const SquadMemberStatusSchema = z.object({
|
|
member_type: z.string(),
|
|
member_id: z.string(),
|
|
status: z.string().nullable().optional().transform((v) => v ?? null),
|
|
active_issues: z.array(SquadActiveIssueBriefSchema).default([]),
|
|
last_active_at: z.string().nullable().optional().transform((v) => v ?? null),
|
|
}).loose();
|
|
|
|
export const SquadMemberStatusListResponseSchema = z.object({
|
|
members: z.array(SquadMemberStatusSchema).default([]),
|
|
}).loose();
|
|
|
|
export const EMPTY_SQUAD_MEMBER_STATUS_LIST = { members: [] };
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Structured error body — POST /api/workspaces/:wsId/issues 409 conflict.
|
|
//
|
|
// When the server detects an active issue with the same title in the same
|
|
// workspace, it returns `{ code: "active_duplicate_issue", error, issue }`
|
|
// instead of letting the create through. The UI uses the embedded issue ref
|
|
// to offer "view existing" rather than dropping the user into a generic
|
|
// "create failed" toast.
|
|
//
|
|
// Strict guarantees:
|
|
// - `code` is a literal so a future server rename (e.g. `duplicate_issue`)
|
|
// fails the parse and falls back to a normal error toast — drift never
|
|
// ships as a broken duplicate UI.
|
|
// - `issue` is required; without an id/identifier/title the "view existing"
|
|
// button has nothing to point at, so we'd rather fall back than guess.
|
|
// - `issue.status` is intentionally OMITTED: the duplicate toast doesn't
|
|
// render a StatusIcon (which has no fallback for unknown enum values),
|
|
// so a future server-side rename of `status` must not knock this branch
|
|
// out. `.loose()` lets the field pass through unchanged for any other
|
|
// consumer.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const DuplicateIssueErrorBodySchema = z.object({
|
|
code: z.literal("active_duplicate_issue"),
|
|
error: z.string().optional(),
|
|
issue: z.object({
|
|
id: z.string(),
|
|
identifier: z.string(),
|
|
title: z.string(),
|
|
}).loose(),
|
|
}).loose();
|
|
|
|
export interface DuplicateIssueErrorBody {
|
|
code: "active_duplicate_issue";
|
|
error?: string;
|
|
issue: {
|
|
id: string;
|
|
identifier: string;
|
|
title: string;
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Webhook delivery schemas — backing the Autopilot Deliveries section. Enums
|
|
// (`status`, `signature_status`, `provider`) are kept as `z.string()` so a
|
|
// future server-side value (e.g. a Stripe provider, a new dedupe state)
|
|
// degrades to a generic UI fallback rather than collapsing the list into
|
|
// the empty array. `.loose()` lets unknown fields pass through, matching
|
|
// the rule used by every other endpoint here.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const WebhookDeliverySchema = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string(),
|
|
autopilot_id: z.string(),
|
|
trigger_id: z.string(),
|
|
provider: z.string(),
|
|
event: z.string(),
|
|
dedupe_key: z.string().nullable(),
|
|
dedupe_source: z.string().nullable(),
|
|
signature_status: z.string(),
|
|
status: z.string(),
|
|
attempt_count: z.number().default(0),
|
|
content_type: z.string().nullable(),
|
|
response_status: z.number().nullable(),
|
|
autopilot_run_id: z.string().nullable(),
|
|
replayed_from_delivery_id: z.string().nullable(),
|
|
error: z.string().nullable(),
|
|
received_at: z.string(),
|
|
last_attempt_at: z.string(),
|
|
created_at: z.string(),
|
|
// Detail-only fields. The list endpoint omits them; the detail endpoint
|
|
// populates raw_body / selected_headers / response_body.
|
|
selected_headers: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
raw_body: z.string().nullable().optional(),
|
|
response_body: z.string().nullable().optional(),
|
|
}).loose();
|
|
|
|
export const ListWebhookDeliveriesResponseSchema = z.object({
|
|
deliveries: z.array(WebhookDeliverySchema).default([]),
|
|
total: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const WebhookDeliveryResponseSchema = WebhookDeliverySchema;
|
|
|
|
export const EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE: ListWebhookDeliveriesResponse = {
|
|
deliveries: [],
|
|
total: 0,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Autopilot list schema. Enums (`status`, `execution_mode`, `trigger_kinds`,
|
|
// `last_run_status`) stay `z.string()` so future server-side values degrade
|
|
// to a generic UI fallback. The three derived fields (trigger_kinds /
|
|
// next_run_at / last_run_status) are list-endpoint-only and absent on older
|
|
// servers — optional by contract, the list renders "—" without them.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const AutopilotListItemSchema = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string(),
|
|
title: z.string(),
|
|
description: z.string().nullable().optional(),
|
|
project_id: z.string().nullable().optional(),
|
|
// Older servers (pre-MUL-2429) omit assignee_type; "agent" is the
|
|
// documented default.
|
|
assignee_type: z.string().default("agent"),
|
|
assignee_id: z.string(),
|
|
status: z.string(),
|
|
execution_mode: z.string(),
|
|
issue_title_template: z.string().nullable().optional(),
|
|
created_by_type: z.string(),
|
|
created_by_id: z.string(),
|
|
last_run_at: z.string().nullable().optional(),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
trigger_kinds: z.array(z.string()).optional(),
|
|
next_run_at: z.string().nullable().optional(),
|
|
last_run_status: z.string().nullable().optional(),
|
|
}).loose();
|
|
|
|
export const ListAutopilotsResponseSchema = z.object({
|
|
autopilots: z.array(AutopilotListItemSchema).default([]),
|
|
total: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const EMPTY_LIST_AUTOPILOTS_RESPONSE = {
|
|
autopilots: [],
|
|
total: 0,
|
|
};
|
|
|
|
export const EMPTY_WEBHOOK_DELIVERY: WebhookDelivery = {
|
|
id: "",
|
|
workspace_id: "",
|
|
autopilot_id: "",
|
|
trigger_id: "",
|
|
provider: "",
|
|
event: "",
|
|
dedupe_key: null,
|
|
dedupe_source: null,
|
|
signature_status: "not_required",
|
|
status: "queued",
|
|
attempt_count: 0,
|
|
content_type: null,
|
|
response_status: null,
|
|
autopilot_run_id: null,
|
|
replayed_from_delivery_id: null,
|
|
error: null,
|
|
received_at: "",
|
|
last_attempt_at: "",
|
|
created_at: "",
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// User (`/api/me` GET + PATCH). The auth store and Settings → Account both
|
|
// trust this shape — a drift here would knock both surfaces out. Kept
|
|
// lenient by the same rules as IssueSchema: enums stay `z.string()`,
|
|
// nullable fields are unioned with `null`, unknown server fields pass
|
|
// through via `.loose()`. `profile_description` is the field added in
|
|
// MUL-2406; the server emits `""` when unset (NOT NULL DEFAULT ''), so
|
|
// the schema defaults to `""` too — keeps the type tight without
|
|
// breaking older backends that don't return the column yet.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const UserSchema = 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();
|
|
|
|
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: "",
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cross-workspace unread inbox summary (`/api/inbox/unread-summary` GET).
|
|
// One entry per workspace the user belongs to that has unread items; the
|
|
// sidebar derives the workspace-switcher dot from it. Lenient per the usual
|
|
// rules so a future field addition can't blank the dot — on malformed JSON
|
|
// parseWithFallback returns the empty list, which simply hides the dot.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const InboxUnreadSummarySchema = z.array(
|
|
z
|
|
.object({
|
|
workspace_id: z.string(),
|
|
count: z.number(),
|
|
})
|
|
.loose(),
|
|
);
|
|
|
|
export const EMPTY_INBOX_UNREAD_SUMMARY: InboxWorkspaceUnread[] = [];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Billing schemas (cloud-billing proxy surface)
|
|
//
|
|
// All billing JSON we receive comes from multica-cloud verbatim — we proxy
|
|
// the bytes without re-shaping. These schemas use `loose()` so a future
|
|
// non-breaking field addition on the cloud side doesn't crash us; required
|
|
// fields are still strictly enforced. EMPTY_* constants supply the
|
|
// fallback parseWithFallback uses when the upstream response is malformed
|
|
// or unparseable.
|
|
|
|
export const BillingBalanceSchema = z.object({
|
|
owner_id: z.string(),
|
|
balance_micro: z.number(),
|
|
balance_credit: z.number(),
|
|
updated_at: z.string(),
|
|
}).loose();
|
|
|
|
export const EMPTY_BILLING_BALANCE: BillingBalance = {
|
|
owner_id: "",
|
|
balance_micro: 0,
|
|
balance_credit: 0,
|
|
updated_at: "",
|
|
};
|
|
|
|
// `tx_type` and `source` are kept as plain strings here; the cloud doc
|
|
// enumerates the canonical values but the frontend display tolerates
|
|
// unknown ones gracefully. Strict enums would crash the page on a future
|
|
// addition (e.g. a new `topup` source kind).
|
|
export const BillingTransactionSchema = z.object({
|
|
id: z.string(),
|
|
owner_id: z.string(),
|
|
idempotency_key: z.string().default(""),
|
|
tx_type: z.string(),
|
|
source: z.string(),
|
|
amount_micro: z.number(),
|
|
balance_after: z.number(),
|
|
reference_id: z.string().default(""),
|
|
description: z.string().default(""),
|
|
metadata: z.record(z.string(), z.unknown()).default({}),
|
|
created_at: z.string(),
|
|
}).loose();
|
|
|
|
export const BillingTransactionsPageSchema = z.object({
|
|
items: z.array(BillingTransactionSchema).default([]),
|
|
total: z.number().default(0),
|
|
page: z.number().default(1),
|
|
page_size: z.number().default(20),
|
|
}).loose();
|
|
|
|
export const EMPTY_BILLING_TRANSACTIONS_PAGE: BillingTransactionsPage = {
|
|
items: [],
|
|
total: 0,
|
|
page: 1,
|
|
page_size: 20,
|
|
};
|
|
|
|
export const BillingBatchSchema = z.object({
|
|
id: z.string(),
|
|
owner_id: z.string(),
|
|
source_tx_id: z.string().default(""),
|
|
source_type: z.string(),
|
|
total_micro: z.number(),
|
|
remaining_micro: z.number(),
|
|
// Cloud either omits the key (never expires) or sends a string
|
|
// timestamp. Null is also tolerated since some serializers emit
|
|
// explicit nulls for absent timestamps.
|
|
expires_at: z.string().nullable().optional(),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
}).loose();
|
|
|
|
export const BillingBatchesPageSchema = z.object({
|
|
items: z.array(BillingBatchSchema).default([]),
|
|
total: z.number().default(0),
|
|
page: z.number().default(1),
|
|
page_size: z.number().default(20),
|
|
}).loose();
|
|
|
|
export const EMPTY_BILLING_BATCHES_PAGE: BillingBatchesPage = {
|
|
items: [],
|
|
total: 0,
|
|
page: 1,
|
|
page_size: 20,
|
|
};
|
|
|
|
export const BillingTopupSchema = z.object({
|
|
id: z.string(),
|
|
owner_id: z.string(),
|
|
amount_cents: z.number(),
|
|
currency: z.string().default("usd"),
|
|
credits: z.number(),
|
|
bonus_credits: z.number().default(0),
|
|
status: z.string(),
|
|
tier_id: z.string().default(""),
|
|
stripe_checkout_id: z.string().default(""),
|
|
// Only set after status reaches `credited` — leave optional rather
|
|
// than coerce to "" so a UI can branch on existence.
|
|
purchase_batch_id: z.string().optional(),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
}).loose();
|
|
|
|
export const BillingTopupsPageSchema = z.object({
|
|
items: z.array(BillingTopupSchema).default([]),
|
|
total: z.number().default(0),
|
|
page: z.number().default(1),
|
|
page_size: z.number().default(20),
|
|
}).loose();
|
|
|
|
export const EMPTY_BILLING_TOPUPS_PAGE: BillingTopupsPage = {
|
|
items: [],
|
|
total: 0,
|
|
page: 1,
|
|
page_size: 20,
|
|
};
|
|
|
|
export const BillingPriceTierSchema = z.object({
|
|
id: z.string(),
|
|
// Cloud doc says display_name falls back to id; tolerate empty too.
|
|
display_name: z.string().default(""),
|
|
amount_cents: z.number(),
|
|
credits: z.number(),
|
|
bonus_credits: z.number().optional(),
|
|
bonus_expires_in: z.string().optional(),
|
|
}).loose();
|
|
|
|
export const BillingPriceTierListSchema = z.array(BillingPriceTierSchema);
|
|
|
|
export const EMPTY_BILLING_PRICE_TIER_LIST: BillingPriceTier[] = [];
|
|
|
|
export const CreateBillingCheckoutSessionResponseSchema = z.object({
|
|
order_id: z.string(),
|
|
session_id: z.string(),
|
|
url: z.string(),
|
|
}).loose();
|
|
|
|
export const EMPTY_CREATE_BILLING_CHECKOUT_SESSION_RESPONSE: CreateBillingCheckoutSessionResponse = {
|
|
order_id: "",
|
|
session_id: "",
|
|
url: "",
|
|
};
|
|
|
|
export const BillingCheckoutSessionStatusSchema = z.object({
|
|
order_id: z.string(),
|
|
status: z.string(),
|
|
amount_cents: z.number(),
|
|
credits: z.number(),
|
|
bonus_credits: z.number().default(0),
|
|
currency: z.string().default("usd"),
|
|
tier_id: z.string().default(""),
|
|
}).loose();
|
|
|
|
export const EMPTY_BILLING_CHECKOUT_SESSION_STATUS: BillingCheckoutSessionStatus = {
|
|
order_id: "",
|
|
status: "pending",
|
|
amount_cents: 0,
|
|
credits: 0,
|
|
bonus_credits: 0,
|
|
currency: "usd",
|
|
tier_id: "",
|
|
};
|
|
|
|
export const CreateBillingPortalSessionResponseSchema = z.object({
|
|
url: z.string(),
|
|
}).loose();
|
|
|
|
export const EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE: CreateBillingPortalSessionResponse = {
|
|
url: "",
|
|
};
|