From 4be0e33f2d1ee814afa312d0fcaab77f4d5fd094 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:49:23 +0800 Subject: [PATCH] feat(autopilots): list schema, parsed client, and view store in core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - listAutopilots now runs through parseWithFallback with a zod schema (this endpoint was a bare fetch — overdue per the API compatibility rules); malformed bodies degrade to an empty list, old-server rows without assignee_type or the new derived fields parse cleanly, and enum drift passes through as plain strings - Autopilot type gains the three optional list-only derived fields - New autopilots view store (scope/sort/columns/filters, persisted per workspace): status is the promoted scope dimension so it does NOT appear in filters — one dimension lives in exactly one place Co-Authored-By: Claude Fable 5 --- packages/core/api/client.ts | 10 +- packages/core/api/schema.test.ts | 61 +++++++ packages/core/api/schemas.ts | 41 +++++ packages/core/autopilots/stores/index.ts | 13 ++ packages/core/autopilots/stores/view-store.ts | 172 ++++++++++++++++++ packages/core/package.json | 3 +- packages/core/types/autopilot.ts | 7 + 7 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 packages/core/autopilots/stores/index.ts create mode 100644 packages/core/autopilots/stores/view-store.ts diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index d4f5b686e..026c9382f 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -161,6 +161,8 @@ import { AppConfigSchema, type AppConfigResponse, GroupedIssuesResponseSchema, + ListAutopilotsResponseSchema, + EMPTY_LIST_AUTOPILOTS_RESPONSE, ListIssuesResponseSchema, ListWebhookDeliveriesResponseSchema, RuntimeHourlyActivityListSchema, @@ -1945,7 +1947,13 @@ export class ApiClient { async listAutopilots(params?: { status?: string }): Promise { const search = new URLSearchParams(); if (params?.status) search.set("status", params.status); - return this.fetch(`/api/autopilots?${search}`); + const raw = await this.fetch(`/api/autopilots?${search}`); + return parseWithFallback( + raw, + ListAutopilotsResponseSchema, + EMPTY_LIST_AUTOPILOTS_RESPONSE as ListAutopilotsResponse, + { endpoint: "GET /api/autopilots" }, + ); } async getAutopilot(id: string): Promise { diff --git a/packages/core/api/schema.test.ts b/packages/core/api/schema.test.ts index af0e1848b..77b370e9d 100644 --- a/packages/core/api/schema.test.ts +++ b/packages/core/api/schema.test.ts @@ -91,6 +91,67 @@ describe("ApiClient schema fallback", () => { }); }); + describe("listAutopilots", () => { + const baseAutopilot = { + id: "ap-1", + workspace_id: "ws-1", + title: "Daily triage", + description: null, + assignee_id: "agent-1", + status: "active", + execution_mode: "run_only", + issue_title_template: null, + created_by_type: "member", + created_by_id: "user-1", + last_run_at: null, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + }; + + it("falls back to an empty list when the response is malformed", async () => { + stubFetchJson({ autopilots: "not-an-array", total: 1 }); + const client = new ApiClient("https://api.example.test"); + const res = await client.listAutopilots(); + expect(res).toEqual({ autopilots: [], total: 0 }); + }); + + it("accepts an old-server row without assignee_type or derived fields", async () => { + // Pre-MUL-2429 servers omit assignee_type; servers older than the + // list-derived-fields change omit trigger_kinds/next_run_at/ + // last_run_status. Both must parse, not fall back. + stubFetchJson({ autopilots: [baseAutopilot], total: 1 }); + const client = new ApiClient("https://api.example.test"); + const res = await client.listAutopilots(); + expect(res.autopilots).toHaveLength(1); + expect(res.autopilots[0].assignee_type).toBe("agent"); + expect(res.autopilots[0].trigger_kinds).toBeUndefined(); + expect(res.autopilots[0].last_run_status).toBeUndefined(); + }); + + it("passes derived fields through and tolerates enum drift", async () => { + stubFetchJson({ + autopilots: [ + { + ...baseAutopilot, + assignee_type: "squad", + trigger_kinds: ["schedule", "some_future_kind"], + next_run_at: "2026-06-13T09:00:00Z", + last_run_status: "some_future_status", + }, + ], + total: 1, + }); + const client = new ApiClient("https://api.example.test"); + const res = await client.listAutopilots(); + expect(res.autopilots[0].trigger_kinds).toEqual([ + "schedule", + "some_future_kind", + ]); + expect(res.autopilots[0].next_run_at).toBe("2026-06-13T09:00:00Z"); + expect(res.autopilots[0].last_run_status).toBe("some_future_status"); + }); + }); + describe("getConfig", () => { it("drops malformed daemon setup URLs instead of throwing", async () => { stubFetchJson({ diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index 225bd6cb6..57dbb8e8f 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -666,6 +666,47 @@ export const EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE: ListWebhookDeliveriesRespon 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: "", diff --git a/packages/core/autopilots/stores/index.ts b/packages/core/autopilots/stores/index.ts new file mode 100644 index 000000000..c28c482aa --- /dev/null +++ b/packages/core/autopilots/stores/index.ts @@ -0,0 +1,13 @@ +export { + useAutopilotsViewStore, + AUTOPILOT_SCOPES, + AUTOPILOT_SORT_DEFAULT_DIRECTION, + AUTOPILOT_DEFAULT_HIDDEN_COLUMNS, + EMPTY_AUTOPILOT_FILTERS, + type AutopilotScope, + type AutopilotSortField, + type AutopilotSortDirection, + type AutopilotColumnKey, + type AutopilotListFilters, + type AutopilotsViewState, +} from "./view-store"; diff --git a/packages/core/autopilots/stores/view-store.ts b/packages/core/autopilots/stores/view-store.ts new file mode 100644 index 000000000..1f084fc65 --- /dev/null +++ b/packages/core/autopilots/stores/view-store.ts @@ -0,0 +1,172 @@ +"use client"; + +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { + createWorkspaceAwareStorage, + registerForWorkspaceRehydration, +} from "../../platform/workspace-storage"; +import { defaultStorage } from "../../platform/storage"; + +// View preferences for the autopilots list page: scope, sort, column +// visibility, and filters. Persisted per workspace (workspace-aware storage), +// per user/device (localStorage). Search text and row selection are +// deliberately NOT stored — they are session-scoped (same rationale as the +// skills view store). + +// Status is the promoted SCOPE dimension (lifecycle stage, mutually +// exclusive, must give archived a visible home outside the default view) — +// it therefore does NOT appear in `filters`; one dimension lives in exactly +// one place. "all" = active + paused, archived excluded (Linear archive +// semantics). +export type AutopilotScope = "all" | "active" | "paused" | "archived"; + +export const AUTOPILOT_SCOPES: AutopilotScope[] = [ + "all", + "active", + "paused", + "archived", +]; + +export type AutopilotSortField = "name" | "lastRun" | "nextRun" | "created"; + +export type AutopilotSortDirection = "asc" | "desc"; + +/** Per-field direction applied when the user switches TO that field. */ +export const AUTOPILOT_SORT_DEFAULT_DIRECTION: Record< + AutopilotSortField, + AutopilotSortDirection +> = { + name: "asc", + lastRun: "desc", + nextRun: "asc", + created: "desc", +}; + +/** Multi-select filter state. Empty array per dimension = inactive. */ +export interface AutopilotListFilters { + assignees: string[]; + modes: string[]; + triggerKinds: string[]; + creators: string[]; +} + +export const EMPTY_AUTOPILOT_FILTERS: AutopilotListFilters = { + assignees: [], + modes: [], + triggerKinds: [], + creators: [], +}; + +// User-hideable columns. Name and the structural columns (checkbox, kebab) +// are always visible. +export type AutopilotColumnKey = + | "assignee" + | "trigger" + | "lastRun" + | "nextRun" + | "mode" + | "creator" + | "created"; + +/** Mode, creator and created are opt-in: hidden until the user enables them. */ +export const AUTOPILOT_DEFAULT_HIDDEN_COLUMNS: AutopilotColumnKey[] = [ + "mode", + "creator", + "created", +]; + +export interface AutopilotsViewState { + scope: AutopilotScope; + sortField: AutopilotSortField; + sortDirection: AutopilotSortDirection; + hiddenColumns: AutopilotColumnKey[]; + filters: AutopilotListFilters; + setScope: (scope: AutopilotScope) => void; + /** Header click: toggles direction on the active field, otherwise switches + * to the field with its default direction. */ + toggleSort: (field: AutopilotSortField) => void; + /** Display panel select: switches field (default direction), no toggle. */ + setSortField: (field: AutopilotSortField) => void; + setSortDirection: (direction: AutopilotSortDirection) => void; + toggleColumn: (key: AutopilotColumnKey) => void; + toggleFilter: (key: keyof AutopilotListFilters, value: string) => void; + clearFilters: () => void; +} + +const DEFAULTS = { + scope: "all" as AutopilotScope, + sortField: "lastRun" as AutopilotSortField, + sortDirection: AUTOPILOT_SORT_DEFAULT_DIRECTION.lastRun, + hiddenColumns: AUTOPILOT_DEFAULT_HIDDEN_COLUMNS, + filters: EMPTY_AUTOPILOT_FILTERS, +}; + +export const useAutopilotsViewStore = create()( + persist( + (set) => ({ + ...DEFAULTS, + setScope: (scope) => set({ scope }), + toggleSort: (field) => + set((state) => + state.sortField === field + ? { + sortDirection: state.sortDirection === "asc" ? "desc" : "asc", + } + : { + sortField: field, + sortDirection: AUTOPILOT_SORT_DEFAULT_DIRECTION[field], + }, + ), + setSortField: (field) => + set((state) => + state.sortField === field + ? {} + : { + sortField: field, + sortDirection: AUTOPILOT_SORT_DEFAULT_DIRECTION[field], + }, + ), + setSortDirection: (direction) => set({ sortDirection: direction }), + toggleColumn: (key) => + set((state) => ({ + hiddenColumns: state.hiddenColumns.includes(key) + ? state.hiddenColumns.filter((k) => k !== key) + : [...state.hiddenColumns, key], + })), + toggleFilter: (key, value) => + set((state) => { + const list = state.filters[key] as string[]; + const next = list.includes(value) + ? list.filter((v) => v !== value) + : [...list, value]; + return { filters: { ...state.filters, [key]: next } }; + }), + clearFilters: () => set({ filters: EMPTY_AUTOPILOT_FILTERS }), + }), + { + name: "multica_autopilots_view", + storage: createJSONStorage(() => + createWorkspaceAwareStorage(defaultStorage), + ), + partialize: (state) => ({ + scope: state.scope, + sortField: state.sortField, + sortDirection: state.sortDirection, + hiddenColumns: state.hiddenColumns, + filters: state.filters, + }), + // On rehydrate, if the new workspace has no persisted value, reset to + // the defaults instead of leaving the previous workspace's in-memory + // view state in place (same rationale as the skills view store). + merge: (persisted, current) => { + if (!persisted) return { ...current, ...DEFAULTS }; + return { ...current, ...(persisted as Partial) }; + }, + }, + ), +); + +registerForWorkspaceRehydration(() => + useAutopilotsViewStore.persist.rehydrate(), +); diff --git a/packages/core/package.json b/packages/core/package.json index 5f3d8afeb..1f8ff61a3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -104,7 +104,8 @@ "./i18n/browser": "./i18n/browser.ts", "./skills": "./skills/index.ts", "./skills/frontmatter": "./skills/frontmatter.ts", - "./skills/stores": "./skills/stores/index.ts" + "./skills/stores": "./skills/stores/index.ts", + "./autopilots/stores": "./autopilots/stores/index.ts" }, "dependencies": { "@formatjs/intl-localematcher": "catalog:", diff --git a/packages/core/types/autopilot.ts b/packages/core/types/autopilot.ts index 23a8949ed..658ab110a 100644 --- a/packages/core/types/autopilot.ts +++ b/packages/core/types/autopilot.ts @@ -39,6 +39,13 @@ export interface Autopilot { last_run_at: string | null; created_at: string; updated_at: string; + // List-endpoint-only derived fields; absent on detail/create/update + // responses and on older servers. Enabled triggers only. `trigger_kinds` + // and `last_run_status` are server-driven strings — render unknown values + // through a generic fallback, never an exhaustive switch. + trigger_kinds?: string[]; + next_run_at?: string | null; + last_run_status?: string | null; } export interface WebhookEventFilter {