mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
@@ -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> {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
13
packages/core/autopilots/stores/index.ts
Normal file
13
packages/core/autopilots/stores/index.ts
Normal 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";
|
||||
172
packages/core/autopilots/stores/view-store.ts
Normal file
172
packages/core/autopilots/stores/view-store.ts
Normal 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(),
|
||||
);
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user