feat(autopilots): list schema, parsed client, and view store in core

- 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 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-06-12 15:49:23 +08:00
parent d94ffd7a7a
commit 4be0e33f2d
7 changed files with 305 additions and 2 deletions

View File

@@ -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<ListAutopilotsResponse> {
const search = new URLSearchParams();
if (params?.status) search.set("status", params.status);
return this.fetch(`/api/autopilots?${search}`);
const raw = await this.fetch<unknown>(`/api/autopilots?${search}`);
return parseWithFallback(
raw,
ListAutopilotsResponseSchema,
EMPTY_LIST_AUTOPILOTS_RESPONSE as ListAutopilotsResponse,
{ endpoint: "GET /api/autopilots" },
);
}
async getAutopilot(id: string): Promise<GetAutopilotResponse> {

View File

@@ -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({

View File

@@ -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: "",

View File

@@ -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";

View File

@@ -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<AutopilotsViewState>()(
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<AutopilotsViewState>) };
},
},
),
);
registerForWorkspaceRehydration(() =>
useAutopilotsViewStore.persist.rehydrate(),
);

View File

@@ -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:",

View File

@@ -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 {