mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-07 11:14:28 +02:00
* feat(usage): add error/failure visibility to the Usage dashboard The Usage page could only answer "how much did we spend"; nothing on it showed how often agents fail, what kind of failure it was, or which agent is responsible. Operators had to open failed tasks one at a time to spot a pattern. `agent_task_queue.failure_reason` already carries the refined 21-value taxonomy from server/pkg/taskfailure, so this is a read path over data that already exists. Backend — two rollups, both scoped by workspace/project/window like the existing dashboard endpoints: GET /api/dashboard/failures/daily per-(date, failure_reason) GET /api/dashboard/failures/by-agent per-(agent, failure_reason) They return every terminal task, not just failures: the `failure_reason: ""` row carries the succeeded count. That is what makes the error rate's denominator share filters with its numerator. The run-time rollups can't serve as that denominator — they require `started_at IS NOT NULL`, so a task that expired in the queue (the signature of a runtime outage) contributes nothing to their failed_count. A failed row with an empty reason column lands in an `unclassified` bucket rather than being mistaken for a success. Frontend: - "Errors" joins the trend toggle, daily and weekly, stacked by failure class with the bucket's error rate in the tooltip. - An Errors card breaks the window down by class and by agent, with the raw failure_reason strings behind a disclosure (unlocalised — an operator pastes them into a log search). Each agent row links to its Work tab, which lists the actual failed runs. - The 21 backend reasons fold into 7 display classes in @multica/core/dashboard. Unknown reasons — including ones from a backend newer than the client — land in "other" instead of being dropped, so the class totals always reconcile with the failure count. The Tasks KPI tile is deliberately left alone: its value counts started tasks only, so quoting the failure rollup's larger count there would put two denominators in one tile. The Errors card states its rate with the denominator spelled out instead. Migration 225 adds a partial index on agent_task_queue(completed_at) for terminal statuses. The table had no completed_at index at all, so the two pre-existing run-time rollups were already scanning it; these two new queries would have doubled that. Closes #4429 (MUL-5352) Co-authored-by: multica-agent <github@multica.ai> * fix(usage): correct the Errors drill-down, window and agent exposure Review findings on PR #5991. 1. The drill-down pointed at the wrong page. `?view=work` renders ActorIssuesPanel — the issues assigned to the agent — while its runs live in the Overview pane's ActivityTab. Link to Overview. That page also could not show why a run failed: `failureReasonLabel` was a `Record<TaskFailureReason, string>` indexed with a cast to the old 6-value coarse enum, so every refined reason the backend has written since MUL-1949 resolved to `undefined`. It is now a function over the full 21-value taxonomy plus the legacy coarse values, falling back to the raw wire string for anything newer than the client. Fixes the issue execution log too, which had the same cast. 2. The Errors card covered one more calendar day than the chart above it. `parseSinceParamInTZ` returns N+1 days of headroom on purpose and the dashboard trims the surplus client-side — but only a series carrying a date can be trimmed that way. Totals / classes / reasons now derive from the date-bucketed rollup after that trim, and the per-agent rollup (which has no date to trim on) closes its window server-side via a new `parseExactSinceParamInTZ`. At days=1 the card previously reported yesterday's failures beside a chart showing none. 3. The top-offenders list leaked agents the viewer cannot see. The failure rollups are workspace-scoped and deliberately skip per-agent visibility, but the agent list they are joined against does not — members only see a private agent when they own it or are owner/admin. `name ?? row.agentId` therefore rendered a bare UUID along with that agent's failure count, rate and dominant error class. Unresolvable agents now fold into one anonymous row, and the renderer never falls back to an id. Stricter than `bucketUnknownAgentRows` while the agent list loads: a transient flash of UUIDs is the leak, not a cosmetic glitch. Also from the review: the Errors tooltip echoed the raw Recharts dataKey ("rate_limit") instead of the translated label the legend already carries. Not changed — the schema's `failure_reason` default stays `""`. Defaulting a missing field to a failure bucket guards against a deflated rate, but the realistic drift is `omitempty` on the Go struct tag, which would strip the field from exactly the SUCCESS rows and read as a 100% error rate. Added TestDashboardFailureWireContractKeepsEmptyReason to pin that the server always emits the field, which is the assumption the default rests on. Co-authored-by: multica-agent <github@multica.ai> * fix(usage): renumber migration and fix the anonymous bucket's failure class Review findings on PR #5991, round 2. 1. Migration prefix 225 collided with `225_chat_message_channel_media_pending`, which landed on main while this branch was open — backend CI failed on TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Merged main and renumbered to 231; main now carries 225 through 230, so 226 is taken too. 2. The anonymous "Other agents" bucket could announce the wrong failure class. It merged rows that had ALREADY collapsed to one dominant class per agent, then credited each agent's entire failure count to that class. An agent failing auth 6 / timeout 5 contributed 11 to auth and 0 to timeout, so a bucket whose real composition was timeout 15 / auth 6 rendered as Auth. Fixed by anonymizing the raw per-(agent, reason) rows instead: the sentinel becomes just another agent_id and `aggregateAgentFailures` computes its classes from real counts. That also deletes the parallel bucketing pass — one identity rewrite replaces it. `knownAgentIds` moves up to where both consumers can see it. Also from the review: - The wire-contract test decoded both payloads into one map. json.Unmarshal merges into a non-nil map rather than resetting it, so a residual failure_reason from the first case could have masked an omitempty regression in the second — exactly what the test is meant to catch. Now table-driven with a fresh map per case. - A test comment still described the drill-down as pointing at the Work tab. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
1763 lines
61 KiB
TypeScript
1763 lines
61 KiB
TypeScript
import { z } from "zod";
|
|
import type {
|
|
Agent,
|
|
AgentTemplate,
|
|
AgentTemplateSummary,
|
|
AgentBuilderRuntimeSwitch,
|
|
AgentBuilderSession,
|
|
Attachment,
|
|
AutopilotRun,
|
|
BillingBalance,
|
|
BillingBatchesPage,
|
|
BillingCheckoutSessionStatus,
|
|
BillingPriceTier,
|
|
BillingTopupsPage,
|
|
BillingTransactionsPage,
|
|
CancelTaskResponse,
|
|
ChatDraftRestoresResponse,
|
|
CreateAgentFromTemplateResponse,
|
|
CreateBillingCheckoutSessionResponse,
|
|
CreateBillingPortalSessionResponse,
|
|
CronPreviewResponse,
|
|
GroupedIssuesResponse,
|
|
GitHubPullRequest,
|
|
InboxItem,
|
|
InboxWorkspaceUnread,
|
|
Label,
|
|
IssueProperty,
|
|
ListPropertiesResponse,
|
|
IssuePropertiesResponse,
|
|
IssueTableGroupDescriptor,
|
|
IssueTableFacetsResponse,
|
|
IssueTableGroupsResponse,
|
|
IssueTableRowsResponse,
|
|
ListIssuesResponse,
|
|
ListLabelsResponse,
|
|
ListWebhookDeliveriesResponse,
|
|
NotificationPreferenceResponse,
|
|
ResourceLabelsResponse,
|
|
SearchIssuesResponse,
|
|
SearchProjectsResponse,
|
|
Squad,
|
|
TimelineEntry,
|
|
User,
|
|
WebhookDelivery,
|
|
} from "../types";
|
|
import type { CloudRuntimeNode } from "../runtimes/cloud-runtime";
|
|
import type { CreateFeedbackResponse } from "../feedback/types";
|
|
|
|
export const GitHubPullRequestSchema = z.object({
|
|
id: z.string(),
|
|
provider: z.string().optional().default("github"),
|
|
workspace_id: z.string(),
|
|
repo_owner: z.string(),
|
|
repo_name: z.string(),
|
|
number: z.number(),
|
|
title: z.string(),
|
|
state: z.string(),
|
|
html_url: z.string(),
|
|
branch: z.string().nullable(),
|
|
author_login: z.string().nullable(),
|
|
author_avatar_url: z.string().nullable(),
|
|
merged_at: z.string().nullable(),
|
|
closed_at: z.string().nullable(),
|
|
pr_created_at: z.string(),
|
|
pr_updated_at: z.string(),
|
|
mergeable: z.string().nullable().optional(),
|
|
merge_state_status: z.string().nullable().optional(),
|
|
snapshot_available: z.boolean().optional(),
|
|
checks_rollup: z.string().nullable().optional(),
|
|
checks_conclusion: z.string().nullable().optional(),
|
|
checks_total: z.number().optional().default(0),
|
|
checks_passed: z.number().optional().default(0),
|
|
checks_failed: z.number().optional().default(0),
|
|
checks_running: z.number().optional().default(0),
|
|
checks_pending: z.number().optional().default(0),
|
|
failed_check_names: z.array(z.string()).optional().default([]),
|
|
snapshot_stale: z.boolean().optional().default(false),
|
|
snapshot_fetched_at: z.string().nullable().optional(),
|
|
mergeable_state: z.string().nullable().optional(),
|
|
additions: z.number().optional().default(0),
|
|
deletions: z.number().optional().default(0),
|
|
changed_files: z.number().optional().default(0),
|
|
}).loose();
|
|
|
|
export const IssuePullRequestsResponseSchema = z.object({
|
|
pull_requests: z.array(GitHubPullRequestSchema).default([]),
|
|
}).loose();
|
|
|
|
export const EMPTY_ISSUE_PULL_REQUESTS_RESPONSE: { pull_requests: GitHubPullRequest[] } = {
|
|
pull_requests: [],
|
|
};
|
|
|
|
// Label responses are consumed by settings tables and resource pickers. Keep
|
|
// the resource type lenient so newer server scopes do not break older clients,
|
|
// while defaulting fields that predate scoped label catalogs.
|
|
export const LabelSchema = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string(),
|
|
resource_type: z.string().optional().default("issue"),
|
|
name: z.string(),
|
|
description: z.string().optional().default(""),
|
|
color: z.string(),
|
|
usage_count: z.number().optional().default(0),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
}).loose();
|
|
|
|
export const EMPTY_LABEL: Label = {
|
|
id: "",
|
|
workspace_id: "",
|
|
resource_type: "issue",
|
|
name: "",
|
|
description: "",
|
|
color: "#6b7280",
|
|
usage_count: 0,
|
|
created_at: "",
|
|
updated_at: "",
|
|
};
|
|
|
|
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 ResourceLabelsResponseSchema = z.object({
|
|
labels: z.array(LabelSchema).default([]),
|
|
}).loose();
|
|
|
|
export const EMPTY_RESOURCE_LABELS_RESPONSE: ResourceLabelsResponse = {
|
|
labels: [],
|
|
};
|
|
|
|
// Custom property definitions. `type` stays a lenient string so newer server
|
|
// types don't break installed clients; UI narrows with isKnownPropertyType.
|
|
export const IssuePropertySchema = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string(),
|
|
name: z.string(),
|
|
type: z.string(),
|
|
description: z.string().optional().default(""),
|
|
icon: z.string().optional().default(""),
|
|
config: z.object({
|
|
options: z.array(z.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
color: z.string().optional().default("#6b7280"),
|
|
}).loose()).optional(),
|
|
}).loose().default({}),
|
|
position: z.number().optional().default(0),
|
|
archived: z.boolean().optional().default(false),
|
|
archived_at: z.string().nullable().optional(),
|
|
usage_count: z.number().optional().default(0),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
}).loose();
|
|
|
|
export const EMPTY_ISSUE_PROPERTY: IssueProperty = {
|
|
id: "",
|
|
workspace_id: "",
|
|
name: "",
|
|
type: "text",
|
|
description: "",
|
|
icon: "",
|
|
config: {},
|
|
position: 0,
|
|
archived: false,
|
|
usage_count: 0,
|
|
created_at: "",
|
|
updated_at: "",
|
|
};
|
|
|
|
export const ListPropertiesResponseSchema = z.object({
|
|
properties: z.array(IssuePropertySchema).default([]),
|
|
total: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const EMPTY_LIST_PROPERTIES_RESPONSE: ListPropertiesResponse = {
|
|
properties: [],
|
|
total: 0,
|
|
};
|
|
|
|
// Value bag: keyed by definition UUID; values are primitives or string
|
|
// arrays (multi_select). The preprocess step drops entries with unknown
|
|
// shapes BEFORE validation — a newer server shipping an object-shaped value
|
|
// (future actor/relation types) must degrade to "that one property missing",
|
|
// never fail the whole IssueSchema and blank the list via parseWithFallback.
|
|
export const IssuePropertyValuesSchema = z.preprocess(
|
|
(raw) => {
|
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
|
|
const out: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(raw)) {
|
|
const ok =
|
|
typeof value === "string" ||
|
|
typeof value === "number" ||
|
|
typeof value === "boolean" ||
|
|
(Array.isArray(value) && value.every((item) => typeof item === "string"));
|
|
if (ok) out[key] = value;
|
|
}
|
|
return out;
|
|
},
|
|
z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).default({}),
|
|
);
|
|
|
|
export const IssuePropertiesResponseSchema = z.object({
|
|
properties: IssuePropertyValuesSchema,
|
|
}).loose();
|
|
|
|
export const EMPTY_ISSUE_PROPERTIES_RESPONSE: IssuePropertiesResponse = {
|
|
properties: {},
|
|
};
|
|
|
|
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;
|
|
/** Whether this deployment offers the self-hosted Git provider integration
|
|
* (self-host only; off on the managed cloud). Absent/false hides the whole
|
|
* Settings → Integrations "Git providers" section. */
|
|
vcs_integration_available?: boolean;
|
|
feature_flags?: Record<string, boolean>;
|
|
server_version?: string;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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),
|
|
);
|
|
|
|
const FeatureFlagsSchema = z.preprocess(
|
|
(value) =>
|
|
value && typeof value === "object" && !Array.isArray(value)
|
|
? value
|
|
: undefined,
|
|
z.record(z.string(), BooleanWithDefaultSchema(false)).default({}),
|
|
);
|
|
|
|
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(),
|
|
vcs_integration_available: BooleanWithDefaultSchema(false).optional(),
|
|
feature_flags: FeatureFlagsSchema,
|
|
server_version: OptionalStringSchema,
|
|
}).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,
|
|
vcs_integration_available: false,
|
|
feature_flags: {},
|
|
};
|
|
|
|
// Preference keys may grow over time, so keep both the key and value spaces
|
|
// forward-compatible while still rejecting non-string persisted data.
|
|
export const NotificationPreferenceResponseSchema = z.object({
|
|
workspace_id: z.string(),
|
|
preferences: z.record(z.string(), z.string()).default({}),
|
|
}).loose();
|
|
|
|
export const EMPTY_NOTIFICATION_PREFERENCE_RESPONSE: NotificationPreferenceResponse = {
|
|
workspace_id: "",
|
|
preferences: {},
|
|
};
|
|
|
|
export const CreateFeedbackResponseSchema = z.object({
|
|
id: z.string(),
|
|
created_at: z.string(),
|
|
}).loose();
|
|
|
|
export const EMPTY_CREATE_FEEDBACK_RESPONSE: CreateFeedbackResponse = {
|
|
id: "",
|
|
created_at: "",
|
|
};
|
|
|
|
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();
|
|
|
|
// Per-target outcome of an explicit @agent / @squad mention (MUL-4525 §2).
|
|
// target_id is required to correlate with the client's rendered mention; a
|
|
// malformed entry (missing id) is dropped rather than failing the whole payload.
|
|
export const CommentTriggerOutcomeSchema = z.object({
|
|
target_type: z.string().default(""),
|
|
target_id: z.string(),
|
|
status: z.string().default(""),
|
|
reason_code: z.string().default(""),
|
|
}).loose();
|
|
|
|
export const CommentTriggerPreviewSchema = z.object({
|
|
agents: z.array(CommentTriggerPreviewAgentSchema).default([]),
|
|
// Drop malformed blocked entries INDIVIDUALLY (MUL-4525): a single bad item
|
|
// must not discard the whole set of valid blocked mentions. A non-array
|
|
// degrades to []; each valid entry is kept, each malformed one dropped.
|
|
blocked: z
|
|
.array(z.unknown())
|
|
.catch([])
|
|
.default([])
|
|
.transform((items) =>
|
|
items.flatMap((item) => {
|
|
const parsed = CommentTriggerOutcomeSchema.safeParse(item);
|
|
return parsed.success ? [parsed.data] : [];
|
|
}),
|
|
),
|
|
}).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,
|
|
// Older backends predate custom properties; default {} so consumers never
|
|
// nil-guard issue.properties.
|
|
properties: IssuePropertyValuesSchema,
|
|
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();
|
|
|
|
// Response schema for POST /api/issues. Two tightenings over IssueSchema:
|
|
//
|
|
// - `id` must be non-empty. A created issue always carries a real id, so an
|
|
// empty/absent id means the create effectively failed. createIssue turns a
|
|
// schema failure into a rejection (not a fabricated success), so tightening
|
|
// id here routes an id-less body to that same failure path.
|
|
// - `labels` is the backend-compatibility signal the create modal reads to
|
|
// decide whether the backend attached labels in the create transaction
|
|
// (present) or predates that (absent → fall back to per-label attach).
|
|
// Validate it strictly as Label[] and degrade a malformed value to
|
|
// `undefined` — the same as an absent field — so a wrong shape (null,
|
|
// object, a garbage array) can never masquerade as "handled" and suppress
|
|
// the fallback. Unlike the loose IssueSchema.labels (z.array(z.unknown())),
|
|
// the elements are fully validated. See packages/views/modals/create-issue.tsx.
|
|
export const CreateIssueResponseSchema = IssueSchema.extend({
|
|
id: z.string().min(1),
|
|
labels: z.array(LabelSchema).optional().catch(undefined),
|
|
}).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(),
|
|
// .default(null) so a project from an older backend (frontend deploys before
|
|
// backend) that omits these keys parses to null instead of failing the whole
|
|
// object — which would degrade a search/list batch to the empty fallback.
|
|
start_date: z.string().nullable().default(null),
|
|
due_date: z.string().nullable().default(null),
|
|
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 IssueTableActorRefSchema = z.object({
|
|
// Server-driven enums stay open so installed desktop clients survive a
|
|
// backend that introduces another actor kind.
|
|
type: z.string(),
|
|
id: z.string(),
|
|
}).loose();
|
|
|
|
const IssueTableParentRefSchema = z.object({
|
|
id: z.string(),
|
|
number: z.number(),
|
|
identifier: z.string(),
|
|
title: z.string(),
|
|
status: z.string(),
|
|
}).loose();
|
|
|
|
const IssueTableGroupValueSchema = z.discriminatedUnion("kind", [
|
|
z.object({
|
|
kind: z.literal("status"),
|
|
status: z.string(),
|
|
}).loose(),
|
|
z.object({
|
|
kind: z.literal("assignee"),
|
|
actor: IssueTableActorRefSchema.nullable(),
|
|
}).loose(),
|
|
z.object({
|
|
kind: z.literal("project"),
|
|
project_id: z.string().nullable().optional().default(null),
|
|
}).loose(),
|
|
z.object({
|
|
kind: z.literal("parent"),
|
|
parent_id: z.string().nullable().optional().default(null),
|
|
parent: IssueTableParentRefSchema.nullable().optional().default(null),
|
|
value_state: z.enum(["value", "unavailable", "unset"]),
|
|
}).loose(),
|
|
z.object({
|
|
kind: z.literal("property"),
|
|
property_id: z.string(),
|
|
value: z.union([z.string(), z.boolean(), z.null()]).optional(),
|
|
value_state: z.enum(["value", "unavailable", "unset"]),
|
|
}).loose(),
|
|
]);
|
|
|
|
const IssueTableGroupDescriptorSchema: z.ZodType<IssueTableGroupDescriptor> = z.lazy(() => z.object({
|
|
key: z.string(),
|
|
value: IssueTableGroupValueSchema,
|
|
count: z.number(),
|
|
secondary_groups: z.array(IssueTableGroupDescriptorSchema).optional(),
|
|
}).loose());
|
|
|
|
export const IssueTableGroupsResponseSchema = z.object({
|
|
query_fingerprint: z.string(),
|
|
total: z.number(),
|
|
groups: z.array(IssueTableGroupDescriptorSchema).default([]),
|
|
next_cursor: z.string().nullable().default(null),
|
|
}).loose();
|
|
|
|
export const EMPTY_ISSUE_TABLE_GROUPS_RESPONSE: IssueTableGroupsResponse = {
|
|
query_fingerprint: "",
|
|
total: 0,
|
|
groups: [],
|
|
next_cursor: null,
|
|
};
|
|
|
|
const IssueTableRowSchema = z.object({
|
|
issue: IssueSchema,
|
|
direct_child_count: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const IssueTableRowsResponseSchema = z.object({
|
|
query_fingerprint: z.string(),
|
|
group_key: z.string().nullable().default(null),
|
|
parent_id: z.string().nullable().default(null),
|
|
total: z.number(),
|
|
rows: z.array(IssueTableRowSchema).default([]),
|
|
branch_total: z.number(),
|
|
next_cursor: z.string().nullable().default(null),
|
|
}).loose();
|
|
|
|
export const EMPTY_ISSUE_TABLE_ROWS_RESPONSE: IssueTableRowsResponse = {
|
|
query_fingerprint: "",
|
|
group_key: null,
|
|
parent_id: null,
|
|
total: 0,
|
|
rows: [],
|
|
branch_total: 0,
|
|
next_cursor: null,
|
|
};
|
|
|
|
const IssueTableFacetValueSchema = z.object({
|
|
key: z.string(),
|
|
count: z.number(),
|
|
}).loose();
|
|
|
|
const IssueTableFacetSchema = z.object({
|
|
kind: z.enum(["status", "priority", "assignee", "creator", "project", "label", "property"]),
|
|
property_id: z.string().optional(),
|
|
values: z.array(IssueTableFacetValueSchema).default([]),
|
|
}).loose();
|
|
|
|
export const IssueTableFacetsResponseSchema = z.object({
|
|
query_fingerprint: z.string(),
|
|
total: z.number(),
|
|
facets: z.array(IssueTableFacetSchema).default([]),
|
|
}).loose();
|
|
|
|
export const EMPTY_ISSUE_TABLE_FACETS_RESPONSE: IssueTableFacetsResponse = {
|
|
query_fingerprint: "",
|
|
total: 0,
|
|
facets: [],
|
|
};
|
|
|
|
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.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Cost split carried by every usage row. `cost_usd_ticks` is what the provider
|
|
// itself charged for the rows behind this aggregate (1e-10 USD); the
|
|
// `uncosted_*` counts are the tokens from rows the provider did NOT price, and
|
|
// so are the only ones the client should run through its rate table.
|
|
//
|
|
// The `uncosted_*` fields are deliberately `.optional()` rather than
|
|
// `.default(0)`: a backend that predates them sends nothing, and defaulting
|
|
// those rows to "0 tokens left to estimate" would silently zero their cost.
|
|
// `undefined` means "this backend doesn't split", and the consumer falls back
|
|
// to the full token counts — i.e. exactly the old behaviour. A real 0 from a
|
|
// current backend means "everything here is already priced", which is a
|
|
// different thing and must stay distinguishable.
|
|
const CostSplitShape = {
|
|
cost_usd_ticks: z.number().optional(),
|
|
uncosted_input_tokens: z.number().optional(),
|
|
uncosted_output_tokens: z.number().optional(),
|
|
uncosted_cache_read_tokens: z.number().optional(),
|
|
uncosted_cache_write_tokens: z.number().optional(),
|
|
};
|
|
|
|
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),
|
|
...CostSplitShape,
|
|
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),
|
|
...CostSplitShape,
|
|
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);
|
|
|
|
// Failure rollups. `failure_reason` is an open string on purpose — it carries
|
|
// the backend's canonical taxonomy, which grows as new classifier rules land
|
|
// (server/pkg/taskfailure). Pinning it to a z.enum would make an installed
|
|
// desktop client drop rows for a reason its build predates; the client folds
|
|
// unrecognised reasons into an "other" display class instead. The empty
|
|
// string is the succeeded bucket, so `.default("")` is a meaningful default
|
|
// only for a row that already lost its reason — such a row lands in the
|
|
// denominator rather than inventing a failure that never happened.
|
|
const DashboardFailureDailySchema = z.object({
|
|
date: z.string().default(""),
|
|
failure_reason: z.string().default(""),
|
|
task_count: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const DashboardFailureDailyListSchema = z.array(DashboardFailureDailySchema);
|
|
|
|
const DashboardFailureByAgentSchema = z.object({
|
|
agent_id: z.string().default(""),
|
|
failure_reason: z.string().default(""),
|
|
task_count: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const DashboardFailureByAgentListSchema = z.array(
|
|
DashboardFailureByAgentSchema,
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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),
|
|
...CostSplitShape,
|
|
}).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),
|
|
...CostSplitShape,
|
|
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),
|
|
...CostSplitShape,
|
|
task_count: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const RuntimeUsageByHourListSchema = z.array(RuntimeUsageByHourSchema);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Agent task responses. The base object stays loose so daemon/runtime fields
|
|
// can drift while task-list consumers still validate the fields they render.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Human attribution (MUL-4302 §9): who an agent run is accountable to, and how
|
|
// that human was resolved. Every field is defensive so a departed member, an
|
|
// autopilot run (no originator), or an older backend degrades to a partial
|
|
// object instead of a parse failure.
|
|
const AttributionUserSchema = z.object({
|
|
id: z.string().default(""),
|
|
name: z.string().optional(),
|
|
email: z.string().optional(),
|
|
avatar_url: z.string().optional(),
|
|
}).loose();
|
|
|
|
const TaskEvidenceSchema = z.object({
|
|
kind: z.string().default(""),
|
|
ref_id: z.string().default(""),
|
|
}).loose();
|
|
|
|
const TaskAttributionSchema = z.object({
|
|
source: z.string().default("unattributed"),
|
|
precise: z.boolean().default(false),
|
|
initiator: AttributionUserSchema.optional(),
|
|
originator: AttributionUserSchema.optional(),
|
|
evidence: TaskEvidenceSchema.optional(),
|
|
rule_version_id: z.string().optional(),
|
|
delegated_from_task_id: z.string().optional(),
|
|
retry_of_task_id: z.string().optional(),
|
|
rerun_of_task_id: z.string().optional(),
|
|
}).loose();
|
|
|
|
const OptionalStringArraySchema = z.preprocess(
|
|
(value) =>
|
|
Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
? value
|
|
: undefined,
|
|
z.array(z.string()).optional(),
|
|
);
|
|
|
|
export const AgentTaskSchema = 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(),
|
|
// Coverage is additive display metadata. A mixed-version or partially
|
|
// upgraded server must not make one malformed optional field erase the
|
|
// entire execution log, so degrade that field to "absent" independently.
|
|
coalesced_comment_ids: OptionalStringArraySchema,
|
|
delivered_comment_ids: OptionalStringArraySchema,
|
|
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(),
|
|
attribution: TaskAttributionSchema.optional(),
|
|
}).loose();
|
|
|
|
export const AgentTaskListSchema = z.array(AgentTaskSchema);
|
|
|
|
// Task cancellation (`POST /api/tasks/:id/cancel`) is consumed directly by
|
|
// chat recovery. Its optional message payload must be well-formed before the
|
|
// UI deletes a message from cache or restores text into the input.
|
|
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 = AgentTaskSchema.extend({
|
|
cancelled_chat_message: CancelledChatMessageSchema.nullish()
|
|
.transform((value) => value ?? undefined),
|
|
}).loose();
|
|
|
|
// Deferred-cancellation draft restores
|
|
// (`GET /api/chat/sessions/{id}/draft-restores`, #5219) feed the composer
|
|
// directly: `content` becomes the draft text, `attachments` re-bind on
|
|
// re-send, and `id` is the consume key. A malformed response falls back to
|
|
// an empty list — the durable row stays pending server-side, so nothing is
|
|
// lost by skipping a fetch.
|
|
const ChatDraftRestoreSchema = z.object({
|
|
id: z.string(),
|
|
chat_session_id: z.string(),
|
|
task_id: z.string().optional(),
|
|
content: z.string().default(""),
|
|
attachments: z.array(AttachmentSchema).optional(),
|
|
created_at: z.string().optional(),
|
|
}).loose();
|
|
|
|
export const ChatDraftRestoresResponseSchema = z.object({
|
|
restores: z.array(ChatDraftRestoreSchema).default([]),
|
|
}).loose();
|
|
|
|
export const EMPTY_CHAT_DRAFT_RESTORES: ChatDraftRestoresResponse = {
|
|
restores: [],
|
|
};
|
|
|
|
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 invocation permissions (MUL-3963)
|
|
//
|
|
// Full agent request/response payloads are NOT zod-validated today — the API
|
|
// client returns them typed directly (see client.ts `listAgents` /
|
|
// `getAgent` / `createAgent`), so there is no `AgentSchema` /
|
|
// `CreateAgentRequestSchema` / `UpdateAgentRequestSchema` to extend here.
|
|
// These lenient, exported fragments encode the new permission fields so any
|
|
// future agent schema — and the from-template minimal agent below — can reuse
|
|
// them. Per this file's convention the enum stays lenient (a future
|
|
// server-side value degrades to the strict default rather than failing the
|
|
// parse), and the target array defaults to `[]`.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const AgentPermissionModeSchema = z
|
|
.enum(["private", "public_to"])
|
|
.catch("private");
|
|
|
|
export const AgentInvocationTargetSchema = z
|
|
.object({
|
|
target_type: z.string(),
|
|
target_id: z.string().nullable().optional().transform((v) => v ?? null),
|
|
})
|
|
.loose();
|
|
|
|
export const AgentInvocationTargetsSchema = z
|
|
.array(AgentInvocationTargetSchema)
|
|
.default([]);
|
|
|
|
// `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. The permission fields are parsed leniently when
|
|
// present so the from-template response carries a well-formed access shape.
|
|
const MinimalAgentSchema = z.object({
|
|
id: z.string(),
|
|
permission_mode: AgentPermissionModeSchema.optional(),
|
|
invocation_targets: AgentInvocationTargetsSchema.optional(),
|
|
}).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: [],
|
|
};
|
|
|
|
export const AgentBuilderSessionSchema = z.object({
|
|
session_id: z.string(),
|
|
builder_agent_id: z.string(),
|
|
runtime_id: z.string(),
|
|
}).loose();
|
|
|
|
export const EMPTY_AGENT_BUILDER_SESSION: AgentBuilderSession = {
|
|
session_id: "",
|
|
builder_agent_id: "",
|
|
runtime_id: "",
|
|
};
|
|
|
|
export const AgentBuilderRuntimeSwitchSchema = z.object({
|
|
runtime_id: z.string(),
|
|
}).loose();
|
|
|
|
// This endpoint returns 2xx only after the carrier has been bound to the
|
|
// runtime the caller asked for; anything else is a thrown error and no commit.
|
|
// So the safe fallback for an unparseable SUCCESS body is the requested id, not
|
|
// an empty one: the rebind did happen, and reporting "unknown" would leave the
|
|
// picker showing a runtime that is no longer executing — the exact split this
|
|
// endpoint exists to close.
|
|
export const agentBuilderRuntimeSwitchFallback = (
|
|
requestedRuntimeID: string,
|
|
): AgentBuilderRuntimeSwitch => ({ runtime_id: requestedRuntimeID });
|
|
|
|
// 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),
|
|
// Older servers predate the durable dispatch queue. Defaults preserve
|
|
// compatibility while the UI rolls out alongside the new worker.
|
|
dispatch_attempts: z.number().default(0),
|
|
available_at: z.string().default(""),
|
|
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(),
|
|
// Per-caller write capability; absent on older servers (treated as unknown).
|
|
can_write: z.boolean().optional(),
|
|
// Narrower per-caller access-management capability (detail endpoint only).
|
|
can_manage_access: z.boolean().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,
|
|
};
|
|
|
|
// Autopilot run (POST /trigger, GET /runs). Consumed by the "run now" flow,
|
|
// which branches on `status` to avoid a false-success toast (MUL-4525), so the
|
|
// response must be schema-parsed. `reason_code` is an additive, stable
|
|
// classification of a non-success run the UI localizes; older servers omit it.
|
|
// Defaults are conservative: an unreadable run degrades to a non-success status
|
|
// so the UI never shows success it cannot confirm. .loose() tolerates new fields.
|
|
export const AutopilotRunSchema = z.object({
|
|
id: z.string().default(""),
|
|
autopilot_id: z.string().default(""),
|
|
trigger_id: z.string().nullable().default(null),
|
|
source: z.string().default("manual"),
|
|
status: z.string().default("failed"),
|
|
issue_id: z.string().nullable().default(null),
|
|
task_id: z.string().nullable().default(null),
|
|
triggered_at: z.string().default(""),
|
|
completed_at: z.string().nullable().default(null),
|
|
failure_reason: z.string().nullable().default(null),
|
|
reason_code: z.string().optional(),
|
|
trigger_payload: z.unknown().default(null),
|
|
result: z.unknown().default(null),
|
|
created_at: z.string().default(""),
|
|
}).loose();
|
|
|
|
export const FALLBACK_AUTOPILOT_RUN: AutopilotRun = {
|
|
id: "",
|
|
autopilot_id: "",
|
|
trigger_id: null,
|
|
source: "manual",
|
|
status: "failed",
|
|
issue_id: null,
|
|
task_id: null,
|
|
triggered_at: "",
|
|
completed_at: null,
|
|
failure_reason: null,
|
|
trigger_payload: null,
|
|
result: null,
|
|
created_at: "",
|
|
};
|
|
|
|
// Cron preview: the server is the authority on the next occurrences. No
|
|
// `.default([])` here — a missing or reshaped field must fail validation so it
|
|
// degrades to the `next_runs: null` fallback ("preview unreadable") instead of
|
|
// masquerading as a valid empty list ("this expression never fires").
|
|
export const CronPreviewResponseSchema = z.object({
|
|
next_runs: z.array(z.string()),
|
|
}).loose();
|
|
|
|
export const UNREADABLE_CRON_PREVIEW_RESPONSE: CronPreviewResponse = {
|
|
next_runs: null,
|
|
};
|
|
|
|
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,
|
|
dispatch_attempts: 0,
|
|
available_at: "",
|
|
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[] = [];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Archived inbox items (`/api/inbox/archived` GET).
|
|
// Lenient per the usual rules: `severity` / `type` / `recipient_type` stay
|
|
// `z.string()` so a notification kind this client doesn't know yet still
|
|
// parses and renders (the UI's type-label lookup already tolerates unknown
|
|
// kinds). Nullable optional fields are declared optional as well, since older
|
|
// rows can omit them entirely. On malformed JSON parseWithFallback returns the
|
|
// empty list — the archived view then reads as empty rather than white-
|
|
// screening the inbox.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const InboxItemListSchema = z.array(
|
|
z
|
|
.object({
|
|
id: z.string(),
|
|
workspace_id: z.string(),
|
|
recipient_type: z.string(),
|
|
recipient_id: z.string(),
|
|
type: z.string(),
|
|
severity: z.string(),
|
|
issue_id: z.string().nullish(),
|
|
title: z.string(),
|
|
body: z.string().nullish(),
|
|
read: z.boolean(),
|
|
archived: z.boolean(),
|
|
created_at: z.string(),
|
|
})
|
|
.loose(),
|
|
);
|
|
|
|
export const EMPTY_INBOX_ITEMS: InboxItem[] = [];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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: "",
|
|
};
|