diff --git a/.codex/skills/openspec-apply-change/SKILL.md b/.codex/skills/openspec-apply-change/SKILL.md index d474dc13555..716375ac7d8 100644 --- a/.codex/skills/openspec-apply-change/SKILL.md +++ b/.codex/skills/openspec-apply-change/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.2.0" + generatedBy: "1.3.0" --- Implement tasks from an OpenSpec change. diff --git a/.codex/skills/openspec-archive-change/SKILL.md b/.codex/skills/openspec-archive-change/SKILL.md index 9b1f851aaad..74047c6b43c 100644 --- a/.codex/skills/openspec-archive-change/SKILL.md +++ b/.codex/skills/openspec-archive-change/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.2.0" + generatedBy: "1.3.0" --- Archive a completed change in the experimental workflow. diff --git a/.codex/skills/openspec-explore/SKILL.md b/.codex/skills/openspec-explore/SKILL.md index ffa10cad72c..7ca31924aa3 100644 --- a/.codex/skills/openspec-explore/SKILL.md +++ b/.codex/skills/openspec-explore/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.2.0" + generatedBy: "1.3.0" --- Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. @@ -56,10 +56,10 @@ Depending on what the user brings, you might: │ Use ASCII diagrams liberally │ ├─────────────────────────────────────────┤ │ │ -│ ┌────────┐ ┌────────┐ │ -│ │ State │────────▶│ State │ │ -│ │ A │ │ B │ │ -│ └────────┘ └────────┘ │ +│ ┌────────┐ ┌────────┐ │ +│ │ State │────────▶│ State │ │ +│ │ A │ │ B │ │ +│ └────────┘ └────────┘ │ │ │ │ System diagrams, state machines, │ │ data flows, architecture sketches, │ @@ -114,14 +114,14 @@ If the user mentions a change or you detect one is relevant: 3. **Offer to capture when decisions are made** - | Insight Type | Where to Capture | - |--------------|------------------| - | New requirement discovered | `specs//spec.md` | - | Requirement changed | `specs//spec.md` | - | Design decision made | `design.md` | - | Scope changed | `proposal.md` | - | New work identified | `tasks.md` | - | Assumption invalidated | Relevant artifact | + | Insight Type | Where to Capture | + |----------------------------|--------------------------------| + | New requirement discovered | `specs//spec.md` | + | Requirement changed | `specs//spec.md` | + | Design decision made | `design.md` | + | Scope changed | `proposal.md` | + | New work identified | `tasks.md` | + | Assumption invalidated | Relevant artifact | Example offers: - "That's a design decision. Capture it in design.md?" @@ -227,7 +227,7 @@ User: A CLI tool that tracks local dev environments You: That changes everything. ┌─────────────────────────────────────────────────┐ - │ CLI TOOL DATA STORAGE │ + │ CLI TOOL DATA STORAGE │ └─────────────────────────────────────────────────┘ Key constraints: diff --git a/.codex/skills/openspec-propose/SKILL.md b/.codex/skills/openspec-propose/SKILL.md index d27bc5312ca..4b57621a056 100644 --- a/.codex/skills/openspec-propose/SKILL.md +++ b/.codex/skills/openspec-propose/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.2.0" + generatedBy: "1.3.0" --- Propose a new change - create the change and generate all artifacts in one step. diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 00000000000..9f5390ae0d8 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "fetch": { + "command": "uvx", + "args": [ + "mcp-server-fetch" + ] + } + } +} diff --git a/.env.example b/.env.example index 6860330dfd0..095a51beaf3 100644 --- a/.env.example +++ b/.env.example @@ -85,8 +85,17 @@ MULTICA_LLM_BASE_URL= # - Default model used when a request omits `model`. When unset, falls # back to a small built-in default (gpt-5.6-luna). MULTICA_LLM_DEFAULT_MODEL= +# Graph rollout controls. Disabling new runs also pauses trigger dispatch; +# disabling advancement stops new node dispatch while retaining receipts and +# cancellation recovery for running work. Neither flag changes ordinary tasks. +MULTICA_GRAPH_DISABLE_NEW_RUNS=false +MULTICA_GRAPH_DISABLE_ADVANCEMENT=false MULTICA_DAEMON_CONFIG= MULTICA_WORKSPACE_ID= +# Optional daemon workspace allowlist (comma-separated UUIDs). Unset preserves +# automatic discovery of every accessible workspace. An explicitly provided +# `daemon start --workspace-id ` scopes that daemon to one workspace. +MULTICA_DAEMON_WORKSPACE_IDS= MULTICA_DAEMON_ID= MULTICA_DAEMON_DEVICE_NAME= MULTICA_DAEMON_POLL_INTERVAL=3s diff --git a/.gitattributes b/.gitattributes index 5528041dbc4..042018aa303 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,8 @@ docker/entrypoint.sh text eol=lf # Default behavior * text=auto + +# Bundled release signatures cover the exact archive payload bytes. +server/internal/pluginbundled/releases/** -text +# Embedded skill documents must have the same frontmatter on every build host. +server/internal/service/builtin_skills/** text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b12dee3912..9d28a2db8f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -303,6 +303,13 @@ jobs: - name: Setup Helm uses: azure/setup-helm@v4 + # Graph's native OpenClaw/OMP credential projection uses node:sqlite. + # Pin a supported runtime so these fixtures execute in the backend gate. + - name: Setup Node.js for native runtime fixtures + uses: actions/setup-node@v6 + with: + node-version: 22 + - name: Test Helm chart run: bash scripts/helm-config.test.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9dd50056dfe..949a424c871 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,6 +86,11 @@ jobs: - name: Record Go toolchain run: go version + - name: Setup Node.js for native runtime fixtures + uses: actions/setup-node@v6 + with: + node-version: 22 + - name: Verify Go test wrapper run: bash scripts/test-go.test.sh diff --git a/apps/mobile/app/(app)/[workspace]/_layout.tsx b/apps/mobile/app/(app)/[workspace]/_layout.tsx index 420c53d40b9..787eadb78ec 100644 --- a/apps/mobile/app/(app)/[workspace]/_layout.tsx +++ b/apps/mobile/app/(app)/[workspace]/_layout.tsx @@ -6,6 +6,7 @@ import { workspaceListOptions } from "@/data/queries/workspaces"; import { useWorkspaceStore } from "@/data/workspace-store"; import { RealtimeProvider } from "@/data/realtime/realtime-provider"; import { useInboxRealtime } from "@/data/realtime/use-inbox-realtime"; +import { useGraphsRealtime } from "@/data/realtime/use-graphs-realtime"; import { useIssuesRealtime } from "@/data/realtime/use-issues-realtime"; import { useMyIssuesRealtime } from "@/data/realtime/use-my-issues-realtime"; import { useChatSessionsRealtime } from "@/data/realtime/use-chat-sessions-realtime"; @@ -75,6 +76,7 @@ export const unstable_settings = { anchor: "(tabs)" } as const; function RealtimeSubscriptions() { useInboxRealtime(); useIssuesRealtime(); + useGraphsRealtime(); useMyIssuesRealtime(); useChatSessionsRealtime(); useProjectsRealtime(); @@ -132,6 +134,40 @@ export default function WorkspaceLayout() { + + , + }} + /> + + + + + + + + - + - + + - + (); + return ; +} diff --git a/apps/mobile/app/(app)/[workspace]/graph/[id]/start.tsx b/apps/mobile/app/(app)/[workspace]/graph/[id]/start.tsx new file mode 100644 index 00000000000..e5c473ba140 --- /dev/null +++ b/apps/mobile/app/(app)/[workspace]/graph/[id]/start.tsx @@ -0,0 +1,16 @@ +import { useLocalSearchParams } from "expo-router"; +import { GraphStartScreen } from "@/components/graphs/start-screen"; +export default function StartGraph() { + const { id, previous_run, issue } = useLocalSearchParams<{ + id: string; + previous_run?: string; + issue?: string; + }>(); + return ( + + ); +} diff --git a/apps/mobile/app/(app)/[workspace]/graph/[id]/triggers.tsx b/apps/mobile/app/(app)/[workspace]/graph/[id]/triggers.tsx new file mode 100644 index 00000000000..8f3bf01c683 --- /dev/null +++ b/apps/mobile/app/(app)/[workspace]/graph/[id]/triggers.tsx @@ -0,0 +1,6 @@ +import { useLocalSearchParams } from "expo-router"; +import { GraphTriggersScreen } from "@/components/graphs/triggers-screen"; +export default function GraphTriggers() { + const { id } = useLocalSearchParams<{ id: string }>(); + return ; +} diff --git a/apps/mobile/app/(app)/[workspace]/graph/index.tsx b/apps/mobile/app/(app)/[workspace]/graph/index.tsx new file mode 100644 index 00000000000..d5c6dc49939 --- /dev/null +++ b/apps/mobile/app/(app)/[workspace]/graph/index.tsx @@ -0,0 +1 @@ +export { GraphListScreen as default } from "@/components/graphs/list-screen"; diff --git a/apps/mobile/app/(app)/[workspace]/graph/new.tsx b/apps/mobile/app/(app)/[workspace]/graph/new.tsx new file mode 100644 index 00000000000..f99e0945908 --- /dev/null +++ b/apps/mobile/app/(app)/[workspace]/graph/new.tsx @@ -0,0 +1,4 @@ +import { GraphEditorScreen } from "@/components/graphs/editor-screen"; +export default function NewGraph() { + return ; +} diff --git a/apps/mobile/app/(app)/[workspace]/graph/node.tsx b/apps/mobile/app/(app)/[workspace]/graph/node.tsx new file mode 100644 index 00000000000..b4dcc129780 --- /dev/null +++ b/apps/mobile/app/(app)/[workspace]/graph/node.tsx @@ -0,0 +1 @@ +export { GraphNodeScreen as default } from "@/components/graphs/node-screen"; diff --git a/apps/mobile/app/(app)/[workspace]/graph/picker.tsx b/apps/mobile/app/(app)/[workspace]/graph/picker.tsx new file mode 100644 index 00000000000..8e182b4d4c4 --- /dev/null +++ b/apps/mobile/app/(app)/[workspace]/graph/picker.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import { FlatList, Pressable, View } from "react-native"; +import { router } from "expo-router"; +import { Text } from "@/components/ui/text"; +import { TextField } from "@/components/ui/text-field"; +import { Button } from "@/components/ui/button"; +import { useGraphDraftStore } from "@/data/stores/graph-draft-store"; +export default function GraphPicker() { + const picker = useGraphDraftStore((s) => s.picker), + [query, setQuery] = useState(""); + if (!picker) + return ( + + This selection has expired. + + + ); + const items = picker.options.filter((o) => + (o.label + " " + (o.description ?? "")) + .toLowerCase() + .includes(query.toLowerCase()), + ); + return ( + + + {picker.title} + + + o.value} + contentContainerStyle={{ paddingBottom: 40 }} + renderItem={({ item }) => ( + { + useGraphDraftStore + .getState() + .field( + picker.key, + picker.path, + Object.prototype.hasOwnProperty.call(item, "data") + ? item.data + : item.value, + ); + useGraphDraftStore.setState({ picker: undefined }); + router.back(); + }} + > + + {item.label} + {item.value === picker.value ? " ✓" : ""} + + {item.description && ( + + {item.description} + + )} + + )} + ListEmptyComponent={ + + No matching options. + + } + /> + + ); +} diff --git a/apps/mobile/app/(app)/[workspace]/graph/run/[id].tsx b/apps/mobile/app/(app)/[workspace]/graph/run/[id].tsx new file mode 100644 index 00000000000..d74f5b49f27 --- /dev/null +++ b/apps/mobile/app/(app)/[workspace]/graph/run/[id].tsx @@ -0,0 +1,6 @@ +import { useLocalSearchParams } from "expo-router"; +import { GraphRunScreen } from "@/components/graphs/run-screen"; +export default function GraphRun() { + const { id } = useLocalSearchParams<{ id: string }>(); + return ; +} diff --git a/apps/mobile/app/(app)/[workspace]/graph/runs.tsx b/apps/mobile/app/(app)/[workspace]/graph/runs.tsx new file mode 100644 index 00000000000..07314e0a12b --- /dev/null +++ b/apps/mobile/app/(app)/[workspace]/graph/runs.tsx @@ -0,0 +1,6 @@ +import { useLocalSearchParams } from "expo-router"; +import { GraphListScreen } from "@/components/graphs/list-screen"; +export default function GraphRuns() { + const { graph } = useLocalSearchParams<{ graph?: string }>(); + return ; +} diff --git a/apps/mobile/components/graphs/editor-screen.tsx b/apps/mobile/components/graphs/editor-screen.tsx new file mode 100644 index 00000000000..8ea4a4de3da --- /dev/null +++ b/apps/mobile/components/graphs/editor-screen.tsx @@ -0,0 +1,633 @@ +import { useEffect, useState } from "react"; +import { Alert, View } from "react-native"; +import { router } from "expo-router"; +import { useQuery } from "@tanstack/react-query"; +import type { + GraphDefinition, + GraphNodeDefinition, +} from "@multica/core/graphs/types"; +import { graphDefinitionChanges } from "@multica/core/graphs/definition"; +import { Text } from "@/components/ui/text"; +import { Button } from "@/components/ui/button"; +import { agentListOptions } from "@/data/queries/agents"; +import { + graphDetailOptions, + graphGenerationOptions, +} from "@/data/queries/graphs"; +import { + useGenerateGraph, + useSaveGraph, + useValidateGraph, + usePublishGraph, + useArchiveGraph, +} from "@/data/mutations/graphs"; +import { + graphDraftIdentity, + useGraphDraftStore, +} from "@/data/stores/graph-draft-store"; +import { + GraphScreen, + GraphSection, + GraphError, + GraphPick, + GraphText, + GraphWorkflowTree, + GraphDisclosure, + useGraphContext, + useGraphCommandKey, + NODE_LABELS, +} from "./shared"; +import { GraphSchemaFields, GraphBindingsFields } from "./fields"; + +const object = { type: "object", additionalProperties: true }; +export function emptyGraph(name = ""): GraphDefinition { + return { + schemaVersion: 2, + name, + inputSchema: object, + nodes: [ + { nodeKey: "start", label: "Start", type: "start" }, + { nodeKey: "end", label: "Finish", type: "end" }, + ], + edges: [{ from: "start", to: "end" }], + maxConcurrency: 4, + }; +} +export function createGraphNode( + type: string, + key: string, +): GraphNodeDefinition { + const base = { nodeKey: key, label: NODE_LABELS[type] ?? type, type }; + switch (type) { + case "agent": + return { + ...base, + agentId: "", + instructions: "", + inputs: { context: { source: "run_input", path: "" } }, + outputSchema: object, + executionMode: "none", + maxAttempts: 2, + timeoutSeconds: 3600, + }; + case "approval": + return { ...base, approval: { mode: "all", approverIds: [] } }; + case "branch": + return { + ...base, + condition: { op: "exists", left: { source: "run_input", path: "" } }, + }; + case "delay": + return { ...base, delay: { seconds: 60 } }; + case "script": + return { + ...base, + agentId: "", + inputs: {}, + outputSchema: object, + script: { language: "javascript", code: "", timeoutSeconds: 60 }, + }; + case "http": + return { + ...base, + outputSchema: object, + http: { + method: "GET", + url: "", + timeoutSeconds: 30, + maxResponseBytes: 65536, + }, + }; + case "loop": + return { + ...base, + inputs: {}, + outputSchema: object, + loop: { + body: emptyGraph("Loop body"), + maxIterations: 3, + condition: { + op: "eq", + left: { source: "constant", value: false }, + right: { source: "constant", value: true }, + }, + next: {}, + }, + }; + case "subgraph": + return { + ...base, + inputs: {}, + outputSchema: object, + subgraph: { definition: emptyGraph("Subflow") }, + }; + default: + return base; + } +} +export function GraphEditorScreen({ id }: { id?: string }) { + const { wsId, slug, userId, members, actor, admin } = useGraphContext(); + const graph = useQuery(graphDetailOptions(wsId, id ?? "")), + agents = useQuery(agentListOptions(wsId)), + generation = useQuery(graphGenerationOptions(wsId)); + const save = useSaveGraph(wsId), + generate = useGenerateGraph(wsId), + validate = useValidateGraph(), + publish = usePublishGraph(wsId), + archive = useArchiveGraph(wsId); + const commandKey = useGraphCommandKey(), + key = graphDraftIdentity(wsId, userId, id), + doc = useGraphDraftStore((s) => s.documents[key]); + const [ready, setReady] = useState(false), + [prompt, setPrompt] = useState(""), + [error, setError] = useState(null); + useEffect(() => { + if (!wsId || !userId || (id && !graph.data)) return; + useGraphDraftStore + .getState() + .open( + key, + graph.data?.definition ?? emptyGraph(), + graph.data?.revision ?? 0, + ); + setReady(false); + void useGraphDraftStore + .getState() + .recover(key) + .finally(() => setReady(true)); + }, [key, wsId, userId, id, graph.data]); + if (id && !graph.isLoading && !graph.data && !graph.error) + return ( + + This workflow is unavailable. + + + ); + const canEdit = + !!userId && (!id || admin || graph.data?.createdBy === userId), + pending = + save.isPending || + generate.isPending || + publish.isPending || + archive.isPending; + if (!doc || !ready) + return ( + + void graph.refetch()} /> + + ); + const disabled = + !canEdit || + pending || + !!graph.data?.archivedAt || + ![1, 2].includes(doc.definition.schemaVersion); + const agentId = String( + doc.values.generationAgent ?? + (agents.data?.length === 1 ? agents.data[0]!.id : ""), + ), + approverId = String(doc.values.generationApprover ?? actor.memberId ?? ""); + const newType = String(doc.values.nodeType ?? "agent"); + const run = async (action: () => Promise) => { + setError(null); + try { + await action(); + } catch (e) { + setError(e); + } + }; + const changes = doc.proposal + ? graphDefinitionChanges(doc.definition, doc.proposal.definition) + : undefined; + return ( + + + {graph.data?.archivedAt && Archived workflow} + {doc.conflict && ( + + + Your edits are preserved. Reload the latest version before saving to + avoid overwriting other collaborators’ changes. + + + + )} + + useGraphDraftStore.getState().edit(key, { ...doc.definition, name }) + } + disabled={disabled} + /> + + + !a.archived_at) + .map((a) => ({ value: a.id, label: a.name }))} + disabled={disabled} + /> + ({ + value: m.id, + label: m.name || m.email, + }))} + disabled={disabled} + /> + + {generation.data?.enabled === false && ( + + AI generation is unavailable on this server. Manual editing is + available below. + + )} + void generation.refetch()} + /> + + {doc.proposal && ( + + {doc.proposal.summary ?? doc.proposal.definition.name} + + {changes?.added.length} added · {changes?.changed.length} changed ·{" "} + {changes?.removed.length} removed + + + {JSON.stringify(doc.definition) !== doc.proposal.base && ( + + Your draft changed after this proposal. Generate again to preserve + your edits. + + )} + + + + )} + + + + + + + router.push({ + pathname: `/${slug}/graph/node`, + params: { + document: key, + path: JSON.stringify(["definition", ...path]), + readonly: disabled ? "1" : "0", + }, + }) + } + /> + !["start", "end"].includes(value)) + .map(([value, label]) => ({ value, label }))} + disabled={disabled} + /> + + + + + + + + + + + useGraphDraftStore + .getState() + .edit(key, { ...doc.definition, maxConcurrency: Number(v) }) + } + disabled={disabled} + /> + + {doc.dirty + ? "Draft is stored securely on this device." + : "All changes saved."} + + + {id && ( + <> + + {validate.data && ( + + {validate.data.diagnostics.map((d, i) => ( + + {d.message} + + ))} + + )} + + + + + + {admin && !graph.data?.archivedAt && ( + + )} + + )} + + ); +} diff --git a/apps/mobile/components/graphs/fields.tsx b/apps/mobile/components/graphs/fields.tsx new file mode 100644 index 00000000000..e4bad6636b1 --- /dev/null +++ b/apps/mobile/components/graphs/fields.tsx @@ -0,0 +1,659 @@ +import { useState } from "react"; +import { Switch, View } from "react-native"; +import type { + GraphDefinition, + GraphInputBinding, + GraphCondition, + JsonObject, +} from "@multica/core/graphs/types"; +import { asSchema, schemaDefaults } from "@multica/core/graphs/input-schema"; +import { Text } from "@/components/ui/text"; +import { Button } from "@/components/ui/button"; +import { TextField } from "@/components/ui/text-field"; +import { useGraphDraftStore } from "@/data/stores/graph-draft-store"; +import { + GraphField, + GraphPick, + GraphText, + GraphDisclosure, + GraphError, +} from "./shared"; + +type Path = (string | number)[]; +export const graphPathValue = (value: unknown, path: Path): unknown => + path.reduce( + (item, key) => + item && typeof item === "object" + ? (item as Record)[String(key)] + : undefined, + value, + ); +const set = (key: string, path: Path, value: unknown) => + useGraphDraftStore.getState().field(key, path, value); +export function GraphJSONField({ + label, + value, + onChange, + disabled, +}: { + label: string; + value: unknown; + onChange: (value: unknown) => void; + disabled?: boolean; +}) { + const [text, setText] = useState(null), + [error, setError] = useState(null); + return ( + + + + + + ); +} +export function GraphInputFields({ + documentKey, + path, + schema, + value, + disabled, + depth = 0, +}: { + documentKey: string; + path: Path; + schema: JsonObject; + value: unknown; + disabled?: boolean; + depth?: number; +}) { + const [extraName, setExtraName] = useState(""); + if (depth > 8) + return ( + set(documentKey, path, v)} + /> + ); + if (Array.isArray(schema.enum)) + return ( + ({ + value: typeof v === "string" ? v : JSON.stringify(v), + label: String(v), + data: v, + }))} + disabled={disabled} + /> + ); + if (schema.type === "boolean") + return ( + set(documentKey, path, v)} + /> + ); + if (schema.type === "object" || schema.properties) { + const properties = asSchema(schema.properties), + current = asSchema(value), + required = Array.isArray(schema.required) ? schema.required : []; + const keys = [ + ...new Set([...Object.keys(properties), ...Object.keys(current)]), + ]; + return ( + + {keys.map((key) => { + const child = asSchema(properties[key]); + return ( + + + {!required.includes(key) && current[key] !== undefined && ( + + )} + + ); + })} + {schema.additionalProperties !== false && ( + + + + + )} + + ); + } + if (schema.type === "array") { + const values = Array.isArray(value) ? value : [], + items = asSchema(schema.items); + return ( + + {values.map((v, i) => ( + + + + + ))} + + + ); + } + return ( + + set( + documentKey, + path, + ["number", "integer"].includes(String(schema.type)) + ? v.trim() + ? Number(v) + : undefined + : v, + ) + } + /> + ); +} +function inferSchema(value: unknown): JsonObject { + return { + type: Array.isArray(value) + ? "array" + : value === null + ? "string" + : typeof value === "object" + ? "object" + : typeof value === "number" + ? "number" + : typeof value === "boolean" + ? "boolean" + : "string", + }; +} +function initialValue(schema: JsonObject): unknown { + return ( + schema.default ?? + (schema.type === "object" + ? schemaDefaults(schema) + : schema.type === "array" + ? [] + : schema.type === "boolean" + ? false + : ["number", "integer"].includes(String(schema.type)) + ? 0 + : "") + ); +} +export function GraphSchemaFields({ + documentKey, + path, + schema, + disabled, + depth = 0, +}: { + documentKey: string; + path: Path; + schema: JsonObject; + disabled?: boolean; + depth?: number; +}) { + const properties = asSchema(schema.properties), + required = Array.isArray(schema.required) + ? (schema.required as string[]) + : []; + const [name, setName] = useState(""); + return ( + + {Object.entries(properties).map(([key, raw]) => { + const field = asSchema(raw), + p = [...path, "properties", key]; + return ( + + {key} + set(documentKey, [...p, "description"], v)} + disabled={disabled} + /> + ({ value: v, label: v }))} + disabled={disabled} + /> + + Required + + set( + documentKey, + [...path, "required"], + v ? [...required, key] : required.filter((k) => k !== key), + ) + } + /> + + {depth < 4 && field.type === "object" && ( + + )} + + + ); + })} + + + set(documentKey, path, asSchema(v))} + /> + + ); +} +export function GraphBindingFields({ + documentKey, + path, + binding, + definition, + disabled, + depth = 0, +}: { + documentKey: string; + path: Path; + binding: GraphInputBinding; + definition: GraphDefinition; + disabled?: boolean; + depth?: number; +}) { + const source = binding.source || "run_input"; + const literalKey = "literal." + path.join("."), + literalType = useGraphDraftStore( + (s) => s.documents[documentKey]?.values[literalKey], + ); + return ( + + + {source === "node_output" && ( + n.outputSchema) + .map((n) => ({ value: n.nodeKey, label: n.label || n.nodeKey }))} + disabled={disabled} + /> + )}{" "} + {(source === "run_input" || source === "node_output") && ( + set(documentKey, [...path, "path"], v)} + disabled={disabled} + /> + )}{" "} + {source === "constant" && ( + <> + ({ value, label: value }), + )} + disabled={disabled} + /> + + + )} + + + Allow a missing value + set(documentKey, [...path, "optional"], v)} + /> + + set(documentKey, [...path, "default"], v)} + /> + + + ); +} +export function GraphBindingsFields({ + documentKey, + path, + bindings, + definition, + disabled, +}: { + documentKey: string; + path: Path; + bindings: Record; + definition: GraphDefinition; + disabled?: boolean; +}) { + const [name, setName] = useState(""); + return ( + + {Object.entries(bindings).map(([key, b]) => ( + + {key} + + + + ))} + + + + ); +} +export function GraphConditionFields({ + documentKey, + path, + condition, + definition, + disabled, + depth = 0, +}: { + documentKey: string; + path: Path; + condition: GraphCondition; + definition: GraphDefinition; + disabled?: boolean; + depth?: number; +}) { + const compound = ["all", "any", "not"].includes(condition.op); + return ( + + ({ value, label }))} + disabled={disabled} + /> + {compound && depth < 6 ? ( + <> + {(condition.conditions ?? []).map((c, i) => ( + + ))} + {(condition.op !== "not" || !condition.conditions?.length) && ( + + )} + + ) : ( + <> + + {condition.op !== "exists" && ( + + )} + + )} + + ); +} diff --git a/apps/mobile/components/graphs/list-screen.tsx b/apps/mobile/components/graphs/list-screen.tsx new file mode 100644 index 00000000000..828ed907278 --- /dev/null +++ b/apps/mobile/components/graphs/list-screen.tsx @@ -0,0 +1,192 @@ +import { useState, useDeferredValue } from "react"; +import { FlatList, Pressable, ScrollView, View } from "react-native"; +import { router } from "expo-router"; +import { useQuery } from "@tanstack/react-query"; +import { Text } from "@/components/ui/text"; +import { Button } from "@/components/ui/button"; +import { TextField } from "@/components/ui/text-field"; +import { + graphSearchOptions, + graphRunSearchOptions, +} from "@/data/queries/graphs"; +import { + GraphError, + GraphStatus, + STATE_LABELS, + useGraphContext, +} from "./shared"; +export function GraphListScreen({ + runs = false, + graphId, +}: { + runs?: boolean; + graphId?: string; +}) { + const { wsId, slug } = useGraphContext(); + const [q, setQ] = useState(""), + [state, setState] = useState(""), + [cursors, setCursors] = useState([]); + const params = { + q: useDeferredValue(q), + state: state || undefined, + graphId, + cursor: cursors[cursors.length - 1], + limit: 20, + }; + const graphs = useQuery({ + ...graphSearchOptions(wsId, { + q: params.q, + cursor: params.cursor, + limit: 20, + }), + enabled: !!wsId && !runs, + }), + history = useQuery({ + ...graphRunSearchOptions(wsId, params), + enabled: !!wsId && runs, + }); + const query = runs ? history : graphs, + items = + (runs + ? history.data?.items.map((run) => ({ + id: run.id, + title: run.graphName || "Workflow run", + subtitle: new Date(run.createdAt).toLocaleString(), + state: run.state, + })) + : graphs.data?.items.map((graph) => ({ + id: graph.id, + title: graph.name, + subtitle: graph.versions.length + ? `${graph.versions.length} published version(s)` + : "Draft", + state: graph.archivedAt ? "Archived" : "", + }))) ?? []; + return ( + + + + + {!runs && ( + + )} + + { + setQ(v); + setCursors([]); + }} + /> + {runs && ( + + {[ + "", + "active", + "waiting_approval", + "blocked", + "succeeded", + "failed", + "rejected", + "cancelled", + ].map((value) => ( + + ))} + + )} + void query.refetch()} /> + {history.data?.stats && ( + + + {history.data.stats.active} running ·{" "} + {history.data.stats.waitingApproval} awaiting approval ·{" "} + {history.data.stats.blocked} blocked + + + {history.data.stats.totalTokens.toLocaleString()} tokens · $ + {history.data.stats.totalCostUsd.toFixed(2)} recorded + {history.data.stats.unpricedTasks + ? ` · ${history.data.stats.unpricedTasks} tasks have no price data` + : ""} + + + )} + + item.id} + refreshing={query.isRefetching} + onRefresh={() => void query.refetch()} + contentContainerStyle={{ padding: 16, gap: 12, paddingBottom: 40 }} + renderItem={({ item }) => ( + + router.push(`/${slug}/graph/${runs ? "run/" : ""}${item.id}`) + } + > + {item.title} + + {item.subtitle} + + {item.state && } + + )} + ListEmptyComponent={ + + {query.isLoading + ? "Loading…" + : q + ? "No matching workflows." + : runs + ? "No runs yet." + : "Create a workflow from an AI proposal or build its steps manually."} + + } + ListFooterComponent={ + + + {query.data?.total ?? 0} total + + + } + /> + + ); +} diff --git a/apps/mobile/components/graphs/node-screen.tsx b/apps/mobile/components/graphs/node-screen.tsx new file mode 100644 index 00000000000..f12205b5054 --- /dev/null +++ b/apps/mobile/components/graphs/node-screen.tsx @@ -0,0 +1,576 @@ +import { useState } from "react"; +import { Alert, View } from "react-native"; +import { router, useLocalSearchParams } from "expo-router"; +import { useQuery } from "@tanstack/react-query"; +import type { + GraphDefinition, + GraphNodeDefinition, +} from "@multica/core/graphs/types"; +import { + connectGraphNodes, + renameGraphNode, +} from "@multica/core/graphs/definition"; +import { Text } from "@/components/ui/text"; +import { Button } from "@/components/ui/button"; +import { agentListOptions } from "@/data/queries/agents"; +import { useGraphDraftStore } from "@/data/stores/graph-draft-store"; +import { + GraphScreen, + GraphSection, + GraphText, + GraphPick, + GraphError, + GraphDisclosure, + GraphWorkflowTree, + NODE_LABELS, + useGraphContext, +} from "./shared"; +import { + graphPathValue, + GraphSchemaFields, + GraphBindingsFields, + GraphConditionFields, + GraphBindingFields, +} from "./fields"; +import { createGraphNode } from "./editor-screen"; +export function GraphNodeScreen() { + const { + document: key, + path: rawPath, + readonly, + } = useLocalSearchParams<{ + document: string; + path: string; + readonly?: string; + }>(); + const context = useGraphContext(), + agents = useQuery(agentListOptions(context.wsId)), + doc = useGraphDraftStore((s) => s.documents[key]); + const [error, setError] = useState(null), + [identifier, setIdentifier] = useState(""); + let path: (string | number)[] = []; + try { + const parsed: unknown = JSON.parse(rawPath); + if ( + Array.isArray(parsed) && + parsed.every((p) => typeof p === "string" || typeof p === "number") + ) + path = parsed; + } catch {} + const node = graphPathValue(doc, path) as GraphNodeDefinition | undefined, + scopePath = path.slice(0, -2), + definition = graphPathValue(doc, scopePath) as GraphDefinition | undefined; + if (!doc || !node?.nodeKey || !definition?.nodes) + return ( + + This step is no longer available. Open the workflow again. + + ); + const disabled = readonly === "1", + field = (suffix: (string | number)[], value: unknown) => + useGraphDraftStore.getState().field(key, [...path, ...suffix], value); + const prefix = path.join("."), + approvers = + node.approval?.approverIds ?? (node.approverId ? [node.approverId] : []), + approval = node.approval ?? { mode: "all", approverIds: approvers }; + const pick = (name: string) => String(doc.values[prefix + "." + name] ?? ""), + vp = (name: string) => ["values", prefix + "." + name]; + const number = ( + label: string, + value: number, + suffix: (string | number)[], + ) => ( + field(suffix, Number(v))} + disabled={disabled} + /> + ); + return ( + + + {node.label || NODE_LABELS[node.type] || node.nodeKey} + + + field(["label"], v)} + disabled={disabled} + /> + {["agent", "script"].includes(node.type) && ( + !a.archived_at || a.id === node.agentId) + .map((a) => ({ value: a.id, label: a.name }))} + disabled={disabled} + /> + )} + {node.type === "agent" && ( + <> + field(["instructions"], v)} + disabled={disabled} + /> + + {number("Maximum attempts (1–3)", node.maxAttempts ?? 2, [ + "maxAttempts", + ])} + {number("Timeout in seconds", node.timeoutSeconds ?? 3600, [ + "timeoutSeconds", + ])} + + )} + {node.type === "approval" && ( + + + {approval.mode === "quorum" && + number("Required approvals", approval.quorum ?? 1, [ + "approval", + "quorum", + ])} + {approvers.map((id) => ( + + + {context.members.data?.find((m) => m.id === id)?.name ?? id} + + + + ))} + !approvers.includes(m.id)) + .map((m) => ({ value: m.id, label: m.name || m.email }))} + disabled={disabled} + /> + + + )} + {node.type === "branch" && ( + + )} + {node.delay && + number("Wait in seconds (1–86400)", node.delay.seconds, [ + "delay", + "seconds", + ])} + {node.script && ( + <> + + + JavaScript: read input and return a JSON value. Python: read input + and assign output. Write logs to stderr. + + field(["script", "code"], v)} + disabled={disabled} + /> + {number("Timeout in seconds (1–3600)", node.script.timeoutSeconds, [ + "script", + "timeoutSeconds", + ])} + + )} + {node.http && ( + <> + ({ value: v, label: v }), + )} + disabled={disabled} + /> + field(["http", "url"], v)} + disabled={disabled} + /> + {number("Timeout in seconds (1–300)", node.http.timeoutSeconds, [ + "http", + "timeoutSeconds", + ])} + {number("Maximum response bytes", node.http.maxResponseBytes, [ + "http", + "maxResponseBytes", + ])} + + + + {!["GET", "HEAD"].includes(node.http.method) && ( + + )} + + )} + {node.loop && ( + <> + {number("Maximum iterations (1–100)", node.loop.maxIterations, [ + "loop", + "maxIterations", + ])} + + + + + + + + )} + {(node.loop || node.subgraph) && ( + + + router.push({ + pathname: `/${context.slug}/graph/node`, + params: { + document: key, + path: JSON.stringify([ + ...path, + node.loop ? "loop" : "subgraph", + node.loop ? "body" : "definition", + ...child, + ]), + readonly, + }, + }) + } + /> + !["start", "end"].includes(v)) + .map(([value, label]) => ({ value, label }))} + disabled={disabled} + /> + + + + + )} + {["agent", "script", "loop", "subgraph"].includes(node.type) && ( + + + + )} + {["agent", "script", "loop", "subgraph", "http"].includes(node.type) && ( + + + + )} + {node.type !== "end" && ( + + {definition.edges + .filter((e) => e.from === node.nodeKey) + .map((e) => ( + + + {e.port + ? e.port === "true" + ? "If true → " + : "If false → " + : "→ "} + {definition.nodes.find((n) => n.nodeKey === e.to)?.label || + e.to} + + + + ))} + {node.type === "branch" && ( + + )} + n.nodeKey !== node.nodeKey && n.type !== "start") + .map((n) => ({ value: n.nodeKey, label: n.label || n.nodeKey }))} + disabled={disabled} + /> + + + )} + + + + + {!["start", "end"].includes(node.type) && ( + + )} + + + ); +} diff --git a/apps/mobile/components/graphs/run-screen.tsx b/apps/mobile/components/graphs/run-screen.tsx new file mode 100644 index 00000000000..cc4cf7f45ce --- /dev/null +++ b/apps/mobile/components/graphs/run-screen.tsx @@ -0,0 +1,595 @@ +import { useEffect, useState } from "react"; +import { Alert, Linking, View } from "react-native"; +import { router } from "expo-router"; +import { useQuery } from "@tanstack/react-query"; +import type { + GraphRun, + GraphNodeRun, + GraphRunAction, + GraphApproval, + GraphRepositoryOption, + JsonObject, +} from "@multica/core/graphs/types"; +import { asSchema } from "@multica/core/graphs/input-schema"; +import { + canApproveGraphRun, + canManageGraphRun, + canReplaceGraphApprover, + graphPendingApprovals, + graphRunActions, + nodeCanRetry, + GRAPH_TERMINAL_STATES, +} from "@multica/core/graphs/controls"; +import { graphArtifactLink } from "@multica/core/graphs/artifacts"; +import { Text } from "@/components/ui/text"; +import { Button } from "@/components/ui/button"; +import { api } from "@/data/api"; +import { + graphRunOptions, + graphStartOptions, + graphNodeTasksOptions, +} from "@/data/queries/graphs"; +import { + useControlGraphRun, + useApproveGraphRun, + useReplaceGraphApprover, +} from "@/data/mutations/graphs"; +import { + graphDraftIdentity, + useGraphDraftStore, +} from "@/data/stores/graph-draft-store"; +import { + GraphScreen, + GraphSection, + GraphDisclosure, + GraphError, + GraphValue, + GraphText, + GraphPick, + GraphStatus, + useGraphContext, + useGraphCommandKey, +} from "./shared"; +import { emptyGraph } from "./editor-screen"; +export function GraphRunScreen({ id }: { id: string }) { + const { wsId } = useGraphContext(), + query = useQuery(graphRunOptions(wsId, id)); + return ( + + void query.refetch()} /> + + {query.data && } + + ); +} +function Artifact({ + artifact, + run, + repositories, +}: { + artifact: JsonObject; + run: GraphRun; + repositories: GraphRepositoryOption[]; +}) { + const { wsId } = useGraphContext(); + const issueId = + run.nodes.find((n) => n.attempts.some((a) => a.taskId === artifact.task_id)) + ?.issueId ?? run.rootIssueId; + const attachments = useQuery({ + queryKey: ["graphs", wsId, "attachments", issueId], + queryFn: ({ signal }) => api.listAttachments(issueId, { signal }), + enabled: artifact.type === "attachment_ref", + }); + const attachment = attachments.data?.find( + (a) => a.id === artifact.attachment_id || a.id === artifact.id, + ); + const href = + graphArtifactLink(artifact, repositories) ?? + (attachment + ? graphArtifactLink({ url: attachment.download_url }, []) + : undefined); + return ( + + + {String( + artifact.name ?? + (artifact.type === "git_commit" + ? String(artifact.commit_sha ?? "").slice(0, 12) + : "Attachment"), + )} + + {href && ( + + )} + void attachments.refetch()} + /> + + + + + ); +} +function NodeDetail({ + node, + run, + canManage, + pending, + onAction, +}: { + node: GraphNodeRun; + run: GraphRun; + canManage: boolean; + pending: boolean; + onAction: (action: GraphRunAction, node: GraphNodeRun) => void; +}) { + const { wsId, slug } = useGraphContext(), + tasks = useQuery(graphNodeTasksOptions(wsId, node.issueId ?? "")); + let definition = run.definition; + const scope = run.scopes?.find((s) => s.id === node.scopeId); + const path = + typeof scope?.definition_path === "string" + ? scope.definition_path.split("/") + : []; + for (let i = 0; definition && i < path.length; i += 2) { + const parent = definition.nodes.find((n) => n.nodeKey === path[i]); + definition = parent?.loop?.body ?? parent?.subgraph?.definition; + } + const step = definition?.nodes.find((n) => n.nodeKey === node.nodeKey); + return ( + + + {node.iteration !== undefined && ( + + Iteration {node.iteration} + + )} + {node.selectedPort && ( + + {node.selectedPort === "true" + ? "Condition matched" + : "Condition did not match"} + + )} + {node.blockReason && ( + + {node.blockReason === "execution_unknown" + ? "The previous execution outcome is unknown. Verify it before recovery." + : node.blockReason === "effect_outcome_unknown" + ? "The remote request may already have completed. Check the remote service before retrying." + : node.blockReason.replaceAll("_", " ")} + + )} + {node.issueId && ( + + )} + {node.attempts.length > 0 && ( + <> + + {node.attempts.length} / {node.maxAttempts} attempts + + {node.issueId && ( + + )} + {tasks.data?.map((task) => ( + + ))} + void tasks.refetch()} /> + + )} + {node.output && ( + + )} + + + + {node.submissions.map((submission, index) => ( + + + + ))} + {canManage && node.state === "blocked" && ( + <> + {nodeCanRetry(node) && ( + + )} + {node.nodeType !== "http" && node.canResolveBlock && ( + + )} + + )} + + ); +} +function RunDetail({ run }: { run: GraphRun }) { + const { wsId, slug, userId, actor, members } = useGraphContext(), + control = useControlGraphRun(wsId), + approval = useApproveGraphRun(wsId), + replace = useReplaceGraphApprover(wsId), + repositories = useQuery(graphStartOptions(wsId, run.rootIssueId)); + const [reason, setReason] = useState(""), + [error, setError] = useState(null), + commandKey = useGraphCommandKey(); + const key = graphDraftIdentity(wsId, userId, `control-${run.id}`), + doc = useGraphDraftStore((s) => s.documents[key]); + useEffect(() => { + useGraphDraftStore.getState().open(key, emptyGraph(), 0); + }, [key]); + const pending = control.isPending || approval.isPending || replace.isPending, + actions = graphRunActions(run, actor), + canManage = + canManageGraphRun(run, actor) && + run.controlState === "run" && + run.state === "blocked" && + !run.archivedAt; + const execute = async (fn: () => Promise) => { + setError(null); + try { + await fn(); + setReason(""); + } catch (e) { + setError(e); + } + }; + const act = (action: GraphRunAction, node?: GraphNodeRun) => { + const request = { + id: run.id, + action, + expectedRevision: run.revision, + nodeRunId: node?.id, + reason: reason.trim() || undefined, + }; + const submit = () => + void execute(() => + control.mutateAsync({ + ...request, + idempotencyKey: commandKey(request), + }), + ); + if (node?.nodeType === "http") { + if (!reason.trim()) { + setError( + new Error( + "Record what you checked in the remote service before retrying this request.", + ), + ); + return; + } + Alert.alert( + "Retry this HTTP request?", + "The remote side effect may already have occurred. Confirm its state first. Retrying reuses the same idempotency key; a service that ignores that key may repeat the operation.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Retry request", onPress: submit }, + ], + ); + } else if (action === "cancel") + Alert.alert( + "Cancel this run?", + "Cancellation waits for running work to acknowledge; completed artifacts remain available.", + [ + { text: "Keep running", style: "cancel" }, + { text: "Cancel run", style: "destructive", onPress: submit }, + ], + ); + else submit(); + }; + const decide = (gate: GraphApproval, decision: "approved" | "rejected") => { + const request = { + id: run.id, + expectedRevision: run.revision, + approvalId: gate.id, + candidateDigest: gate.candidateDigest, + decision, + reason: reason.trim() || undefined, + }; + void execute(() => + approval.mutateAsync({ ...request, idempotencyKey: commandKey(request) }), + ); + }; + const gates = run.approvals?.length + ? run.approvals + : run.approval + ? [run.approval] + : [], + mine = new Set(graphPendingApprovals(run, actor.memberId).map((g) => g.id)); + const actionLabels: Record = { + pause: "Pause", + resume: "Resume", + cancel: "Cancel run", + archive: "Archive run", + retry: "Retry", + "resolve-block": "Resolve block", + }; + return ( + <> + + {run.graphName ?? "Workflow run"} + + + + + + Revision {run.revision} + + + + + {run.previousRunId && ( + + )} + {actions.map((action) => ( + + ))} + {canManageGraphRun(run, actor) && + GRAPH_TERMINAL_STATES.has(run.state) && + run.graphId && ( + + )} + {(canManageGraphRun(run, actor) || canApproveGraphRun(run, actor)) && ( + + )} + + + + {run.nodes.map((node) => ( + + ))} + {run.output && ( + + + + )} + {run.artifacts.length > 0 && ( + + {run.artifacts.map((artifact, i) => ( + + ))} + + )} + {gates.map((gate) => ( + n.id === gate.nodeRunId)?.label || + "Review candidate" + } + > + + + {gate.mode === "all" + ? "All reviewers must approve." + : gate.mode === "quorum" + ? `${gate.quorum} approvals required.` + : "Any reviewer may approve."}{" "} + This decision applies to the frozen candidate below. + + {gate.candidate && ( + [ + n.nodeKey, + { title: n.label || n.nodeKey }, + ]), + ), + }} + /> + )} + {Array.isArray(gate.candidate?.artifacts) && + gate.candidate.artifacts.map((artifact, i) => ( + + ))} + {(gate.approverIds?.length + ? gate.approverIds + : [gate.approverId] + ).map((memberId) => { + const vote = gate.decisions?.find((v) => v.memberId === memberId), + field = `${gate.id}.${memberId}`, + next = String(doc?.values[field] ?? ""); + return ( + + + {members.data?.find((m) => m.id === memberId)?.name ?? + memberId} + + {vote && ( + <> + + {vote.reason && {vote.reason}} + + )} + {doc && + gate.state === "pending" && + canReplaceGraphApprover(run, actor) && ( + <> + + !(gate.approverIds ?? [gate.approverId]).includes( + m.id, + ), + ) + .map((m) => ({ + value: m.id, + label: m.name || m.email, + }))} + disabled={pending} + /> + + + )} + + ); + })} + {mine.has(gate.id) && canApproveGraphRun(run, actor) && ( + <> + + + + )} + + + + + ))} + + + + + ); +} diff --git a/apps/mobile/components/graphs/shared.tsx b/apps/mobile/components/graphs/shared.tsx new file mode 100644 index 00000000000..22fb2daed4b --- /dev/null +++ b/apps/mobile/components/graphs/shared.tsx @@ -0,0 +1,388 @@ +import { useState, useRef, type ReactNode } from "react"; +import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; +import { router } from "expo-router"; +import { useQuery } from "@tanstack/react-query"; +import type { GraphDefinition, JsonObject } from "@multica/core/graphs/types"; +import { asSchema } from "@multica/core/graphs/input-schema"; +import { createRequestId } from "@/lib/request-id"; +import { Text } from "@/components/ui/text"; +import { Button } from "@/components/ui/button"; +import { TextField } from "@/components/ui/text-field"; +import { AutosizeTextArea } from "@/components/ui/autosize-textarea"; +import { Markdown } from "@/lib/markdown"; +import { useWorkspaceStore } from "@/data/workspace-store"; +import { useAuthStore } from "@/data/auth-store"; +import { memberListOptions } from "@/data/queries/members"; +import { useGraphDraftStore } from "@/data/stores/graph-draft-store"; + +export const NODE_LABELS: Record = { + start: "Start", + end: "Finish", + agent: "Agent task", + approval: "Approval", + branch: "Condition", + parallel: "Parallel paths", + join: "Join paths", + loop: "Repeat", + subgraph: "Subflow", + delay: "Wait", + http: "HTTP request", + script: "Script", +}; +export const STATE_LABELS: Record = { + active: "Running", + pending: "Pending", + ready: "Ready", + running: "Running", + succeeded: "Succeeded", + failed: "Failed", + rejected: "Rejected", + cancelled: "Cancelled", + waiting_approval: "Awaiting approval", + blocked: "Needs attention", + skipped: "Skipped", + pause: "Paused", + paused: "Paused", + run: "Scheduling", + cancel_requested: "Cancelling", + approved: "Approved", + dispatched: "Dispatched", + completed: "Completed", + accepted: "Accepted", + timeout: "Timed out", +}; +export function useGraphContext() { + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId) ?? ""; + const slug = useWorkspaceStore((s) => s.currentWorkspaceSlug) ?? ""; + const userId = useAuthStore((s) => s.user?.id) ?? ""; + const members = useQuery(memberListOptions(wsId)); + const member = members.data?.find((m) => m.user_id === userId); + return { + wsId, + slug, + userId, + members, + actor: { userId, memberId: member?.id, role: member?.role }, + admin: member?.role === "admin" || member?.role === "owner", + }; +} +export function GraphScreen({ + children, + loading, +}: { + children: ReactNode; + loading?: boolean; +}) { + return ( + + {loading && } + {children} + + ); +} +export function GraphSection({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( + + {title} + {children} + + ); +} +export function GraphError({ + error, + retry, +}: { + error: unknown; + retry?: () => void; +}) { + return error ? ( + + + {error instanceof Error ? error.message : String(error)} + + {retry && ( + + )} + + ) : null; +} +export function GraphField({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( + + {label} + {children} + + ); +} +export function GraphText({ + label, + value, + onChange, + multiline, + disabled, +}: { + label: string; + value: string; + onChange: (value: string) => void; + multiline?: boolean; + disabled?: boolean; +}) { + return ( + + {multiline ? ( + + ) : ( + + )} + + ); +} +export function GraphStatus({ state }: { state: string }) { + return ( + + {STATE_LABELS[state] ?? state.replaceAll("_", " ")} + + ); +} +export function GraphDisclosure({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + return ( + + setOpen(!open)} + className="py-2" + > + + {open ? "−" : "+"} {title} + + + {open && children} + + ); +} +export function GraphPick({ + documentKey, + path, + label, + value, + options, + disabled, +}: { + documentKey: string; + path: (string | number)[]; + label: string; + value: string; + options: { + value: string; + label: string; + description?: string; + data?: unknown; + }[]; + disabled?: boolean; +}) { + const slug = useWorkspaceStore((s) => s.currentWorkspaceSlug); + return ( + + + + ); +} +const resultLabels: Record = { + summary: "Summary", + findings: "Findings", + recommendations: "Recommendations", + results: "Results", + evidence: "Evidence", + output: "Output", + input: "Input", +}; +export function GraphValue({ + value, + schema = {}, + depth = 0, +}: { + value: unknown; + schema?: JsonObject; + depth?: number; +}) { + if (value === undefined || value === null) + return —; + if (typeof value === "string") return ; + if (typeof value !== "object") return {String(value)}; + if (depth > 8) return {JSON.stringify(value)}; + if (Array.isArray(value)) + return ( + + {value.map((v, i) => ( + + ))} + + ); + return ( + + {Object.entries(value).map(([key, v]) => { + const field = asSchema(asSchema(schema.properties)[key]); + return ( + + + {String( + field.title || + field.description || + resultLabels[key] || + key.replaceAll("_", " "), + )} + + + + ); + })} + + ); +} +export function GraphWorkflowTree({ + definition, + onNode, + path = [], + depth = 0, +}: { + definition: GraphDefinition; + onNode?: (path: (string | number)[]) => void; + path?: (string | number)[]; + depth?: number; +}) { + return ( + + {definition.nodes.map((node, index) => { + const nodePath = [...path, "nodes", index]; + return ( + + onNode?.(nodePath)} + > + + {node.label || NODE_LABELS[node.type] || node.nodeKey} + + + {NODE_LABELS[node.type] ?? node.type} + + + {node.instructions && ( + + {node.instructions} + + )} + {definition.edges + .filter((e) => e.from === node.nodeKey) + .map((e) => ( + + {e.port + ? e.port === "true" + ? "If true → " + : "If false → " + : "→ "} + {definition.nodes.find((n) => n.nodeKey === e.to)?.label || + e.to} + + ))} + {depth < 4 && (node.loop || node.subgraph) && ( + + + + )} + + ); + })} + + ); +} + +/** Reuse the operation key after timeout until its concrete request changes. */ +export function useGraphCommandKey() { + const prior = useRef<{ signature: string; key: string } | null>(null); + return (value: unknown) => { + const signature = JSON.stringify(value); + if (prior.current?.signature !== signature) + prior.current = { signature, key: createRequestId() }; + return prior.current.key; + }; +} diff --git a/apps/mobile/components/graphs/start-screen.tsx b/apps/mobile/components/graphs/start-screen.tsx new file mode 100644 index 00000000000..34c306bb372 --- /dev/null +++ b/apps/mobile/components/graphs/start-screen.tsx @@ -0,0 +1,425 @@ +import { useEffect, useRef, useState, useDeferredValue } from "react"; +import { router } from "expo-router"; +import { useQuery } from "@tanstack/react-query"; +import type { + GraphRecord, + GraphStartRequest, +} from "@multica/core/graphs/types"; +import { executionNodes } from "@multica/core/graphs/definition"; +import { + asSchema, + schemaDefaults, + validateGraphInput, +} from "@multica/core/graphs/input-schema"; +import { isGraphRuntimeSupported } from "@multica/core/graphs/controls"; +import { Text } from "@/components/ui/text"; +import { Button } from "@/components/ui/button"; +import { + graphDetailOptions, + graphIssueSearchOptions, + graphStartOptions, + graphCommitsOptions, + graphRunOptions, +} from "@/data/queries/graphs"; +import { issueDetailOptions } from "@/data/queries/issues"; +import { runtimeListOptions } from "@/data/queries/runtimes"; +import { agentListOptions } from "@/data/queries/agents"; +import { useStartGraphRun } from "@/data/mutations/graphs"; +import { + graphDraftIdentity, + useGraphDraftStore, +} from "@/data/stores/graph-draft-store"; +import { + GraphScreen, + GraphSection, + GraphPick, + GraphText, + GraphError, + useGraphContext, + useGraphCommandKey, +} from "./shared"; +import { GraphInputFields } from "./fields"; +export function GraphStartScreen({ + id, + previousRunId, + rootIssueId, +}: { + id: string; + previousRunId?: string; + rootIssueId?: string; +}) { + const { wsId } = useGraphContext(), + graph = useQuery(graphDetailOptions(wsId, id)); + return ( + + void graph.refetch()} /> + {graph.data && ( + + )} + + ); +} +export function GraphStartFields({ + graph, + previousRunId, + rootIssueId, + configuration, + onConfigure, + submitLabel = "Start run", + pending = false, + formId = "", +}: { + graph: GraphRecord; + previousRunId?: string; + rootIssueId?: string; + configuration?: Partial; + onConfigure?: ( + request: Omit, + ) => Promise; + submitLabel?: string; + pending?: boolean; + formId?: string; +}) { + const { wsId, userId, slug } = useGraphContext(), + key = graphDraftIdentity( + wsId, + userId, + `start-${graph.id}-${previousRunId ?? ""}-${configuration?.versionId ?? ""}-${formId}`, + ), + doc = useGraphDraftStore((s) => s.documents[key]); + const [error, setError] = useState(null), + [issueQuery, setIssueQuery] = useState(""), + [ref, setRef] = useState(""), + [cursors, setCursors] = useState([]); + const prior = useQuery(graphRunOptions(wsId, previousRunId ?? "")), + agents = useQuery(agentListOptions(wsId)), + runtimes = useQuery(runtimeListOptions(wsId)), + start = useStartGraphRun(wsId), + commandKey = useGraphCommandKey(); + const versions = [...graph.versions].sort((a, b) => b.version - a.version), + version = + versions.find((v) => v.id === doc?.values.versionId) ?? versions[0]; + useEffect(() => { + if (!version || !userId) return; + const store = useGraphDraftStore.getState(); + store.open(key, version.definition, 0); + if ( + !store.documents[key]?.values.initialized && + !useGraphDraftStore.getState().documents[key]?.values.initialized + ) + store.field(key, ["values"], { + initialized: true, + versionId: configuration?.versionId ?? version.id, + issueId: rootIssueId ?? "", + input: + configuration?.input ?? + schemaDefaults(version.definition.inputSchema), + bindings: configuration?.runtimeBindings ?? {}, + repositoryId: configuration?.repository?.repositoryId ?? "", + baseSha: configuration?.repository?.baseSha ?? "", + }); + }, [key, userId, version, configuration, rootIssueId]); + const restored = useRef(false); + useEffect(() => { + if ( + prior.data && + doc && + !restored.current && + !doc.values.inputTouched && + !doc.values.issueTouched + ) { + restored.current = true; + const store = useGraphDraftStore.getState(); + store.field(key, ["values"], { + ...doc.values, + input: prior.data.input, + issueId: prior.data.rootIssueId, + versionId: prior.data.versionId, + }); + } + }, [prior.data, doc, key]); + const issueId = String(doc?.values.issueId ?? ""), + repositoryId = String(doc?.values.repositoryId ?? ""), + baseSha = String(doc?.values.baseSha ?? ""); + const oldIssue = useRef(issueId), + oldRepository = useRef(repositoryId); + useEffect(() => { + if (oldIssue.current !== issueId) { + oldIssue.current = issueId; + useGraphDraftStore.getState().field(key, ["values", "repositoryId"], ""); + useGraphDraftStore.getState().field(key, ["values", "baseSha"], ""); + setCursors([]); + } + }, [issueId, key]); + useEffect(() => { + if (oldRepository.current !== repositoryId) { + oldRepository.current = repositoryId; + useGraphDraftStore.getState().field(key, ["values", "baseSha"], ""); + setCursors([]); + } + }, [repositoryId, key]); + const steps = version ? executionNodes(version.definition) : [], + needsRepository = steps.some( + ({ node }) => node.executionMode && node.executionMode !== "none", + ); + const issues = useQuery( + graphIssueSearchOptions(wsId, useDeferredValue(issueQuery)), + ), + selectedIssue = useQuery(issueDetailOptions(wsId, issueId)); + const repositories = useQuery({ + ...graphStartOptions(wsId, issueId), + enabled: needsRepository && !!(issueId || onConfigure), + }), + repo = repositories.data?.repositories.find((r) => r.id === repositoryId); + const commits = useQuery({ + ...graphCommitsOptions(wsId, repositoryId, { + q: useDeferredValue(ref), + ref: repo?.defaultBranch, + cursor: cursors[cursors.length - 1], + }), + enabled: + !!repositoryId && (repo?.commitsSupported ?? repo?.provider === "github"), + }); + if (!version) + return Publish a version before starting this workflow.; + if (!doc) return null; + const set = (name: string, value: unknown) => + useGraphDraftStore.getState().field(key, ["values", name], value), + input = asSchema(doc.values.input), + bindings = asSchema(doc.values.bindings); + const supported = (runtimes.data ?? []).filter( + (r) => r.status === "online" && isGraphRuntimeSupported(r), + ); + const runtimeFor = (path: string, agentId?: string, script = false) => { + const value = String( + bindings[path] ?? + agents.data?.find((a) => a.id === agentId)?.runtime_id ?? + "", + ); + return supported.some( + (r) => + r.id === value && isGraphRuntimeSupported(r, script || !!r.profile_id), + ) + ? value + : ""; + }; + const issueOptions = [ + ...(selectedIssue.data ? [selectedIssue.data] : []), + ...(issues.data?.issues ?? []), + ] + .filter( + (item, index, all) => + !item.graph_node_run_id && + all.findIndex((i) => i.id === item.id) === index, + ) + .map((i) => ({ value: i.id, label: `${i.identifier} · ${i.title}` })); + const submit = async () => { + setError(null); + try { + const errors = validateGraphInput(version.definition.inputSchema, input); + if (errors.length) + throw new Error( + "Check input: " + errors.map((e) => e.path || "/").join(", "), + ); + if (!issueId && !onConfigure) throw new Error("Choose a parent task."); + if ( + steps.some( + ({ node, path }) => + !runtimeFor(path, node.agentId, node.type === "script"), + ) + ) + throw new Error( + "Every agent or script step requires a compatible online runtime.", + ); + if ( + needsRepository && + (!repositoryId || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(baseSha)) + ) + throw new Error("Choose a repository and exact commit."); + const request = { + versionId: version.id, + rootIssueId: issueId, + input, + runtimeBindings: Object.fromEntries( + steps.map(({ node, path }) => [ + path, + runtimeFor(path, node.agentId, node.type === "script"), + ]), + ), + repository: needsRepository + ? { repositoryId, baseSha, commitSha: baseSha } + : undefined, + previousRunId, + }; + if (onConfigure) await onConfigure(request); + else { + const run = await start.mutateAsync({ + ...request, + idempotencyKey: commandKey(request), + }); + router.push(`/${slug}/graph/run/${run.id}`); + } + } catch (e) { + setError(e); + } + }; + const disabled = pending || start.isPending || !!graph.archivedAt; + return ( + <> + {graph.name} + + The published version, inputs and runtime bindings are frozen when the + run starts. + + + ({ + value: v.id, + label: `Version ${v.version}`, + }))} + disabled={disabled} + /> + {!onConfigure && ( + <> + + + + )} + + + + {steps.map(({ node, path }) => ( + + isGraphRuntimeSupported( + r, + node.type === "script" || !!r.profile_id, + ), + ) + .map((r) => ({ + value: r.id, + label: r.name || r.id, + description: r.provider, + }))} + disabled={disabled} + /> + ))} + {needsRepository && ( + + ({ + value: r.id, + label: r.name, + description: r.url, + }))} + disabled={disabled} + /> + {(repo?.commitsSupported ?? repo?.provider === "github") ? ( + <> + { + setRef(v); + setCursors([]); + }} + disabled={disabled} + /> + ({ + value: c.sha, + label: `${c.sha.slice(0, 8)} · ${c.message}`, + description: c.author, + }))} + disabled={disabled} + /> + + + + ) : ( + <> + set("baseSha", v.trim())} + disabled={disabled} + /> + + Connect this repository’s provider to browse commits, or enter + its full 40 or 64 character SHA. + + + )} + + )} + + + ); +} diff --git a/apps/mobile/components/graphs/triggers-screen.tsx b/apps/mobile/components/graphs/triggers-screen.tsx new file mode 100644 index 00000000000..07cd8a5cf6d --- /dev/null +++ b/apps/mobile/components/graphs/triggers-screen.tsx @@ -0,0 +1,531 @@ +import { useEffect, useState } from "react"; +import { Alert, Switch, View } from "react-native"; +import { useQuery } from "@tanstack/react-query"; +import * as Clipboard from "expo-clipboard"; +import type { + GraphRecord, + GraphTrigger, + GraphTriggerWrite, + GraphTriggerConfig, +} from "@multica/core/graphs/types"; +import { Text } from "@/components/ui/text"; +import { Button } from "@/components/ui/button"; +import { + graphDetailOptions, + graphTriggersOptions, + graphTriggerEventsOptions, +} from "@/data/queries/graphs"; +import { projectListOptions } from "@/data/queries/projects"; +import { + useSaveGraphTrigger, + useDeleteGraphTrigger, + useRotateGraphTriggerSecret, +} from "@/data/mutations/graphs"; +import { + useGraphDraftStore, + graphDraftIdentity, +} from "@/data/stores/graph-draft-store"; +import { + GraphScreen, + GraphSection, + GraphText, + GraphPick, + GraphDisclosure, + GraphError, + GraphStatus, + useGraphContext, + useGraphCommandKey, +} from "./shared"; +import { GraphStartFields } from "./start-screen"; +import { emptyGraph } from "./editor-screen"; +function TriggerEvents({ id }: { id: string }) { + const { wsId, slug } = useGraphContext(), + query = useQuery(graphTriggerEventsOptions(wsId, id)); + return ( + + void query.refetch()} /> + {query.data?.items.map((event) => ( + + + {new Date(event.createdAt).toLocaleString()} + + + {event.error && ( + {event.error} + )} + {event.runId && ( + + )} + + ))} + + ); +} +function TriggerEditor({ + graph, + trigger, + onDone, + onSecret, +}: { + graph: GraphRecord; + trigger?: GraphTrigger; + onDone: () => void; + onSecret: (secret: string, url: string) => void; +}) { + const { wsId, userId } = useGraphContext(), + save = useSaveGraphTrigger(wsId, graph.id), + projects = useQuery(projectListOptions(wsId)), + commandKey = useGraphCommandKey(), + key = graphDraftIdentity( + wsId, + userId, + `trigger-${graph.id}-${trigger?.id ?? "new"}-${trigger?.revision ?? 0}`, + ), + doc = useGraphDraftStore((s) => s.documents[key]); + useEffect(() => { + const s = useGraphDraftStore.getState(); + s.open(key, emptyGraph(), 0); + if (!useGraphDraftStore.getState().documents[key]?.values.initialized) + s.field(key, ["values"], { + initialized: true, + name: trigger?.name ?? "", + kind: trigger?.kind ?? "schedule", + enabled: trigger?.enabled ?? true, + config: trigger?.config ?? { + cron: "0 9 * * 1-5", + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + rootTitle: graph.name, + events: ["status_changed"], + statuses: ["todo"], + }, + }); + }, [key, trigger, graph.name]); + if (!doc) return null; + const kind = String(doc.values.kind) as GraphTriggerWrite["kind"], + config = doc.values.config as GraphTriggerConfig, + set = (name: string, value: unknown) => + useGraphDraftStore.getState().field(key, ["values", name], value), + patch = (name: string, value: unknown) => + useGraphDraftStore + .getState() + .field(key, ["values", "config", name], value); + return ( + + + set("name", v)} + disabled={save.isPending} + /> + + + Enabled + set("enabled", v)} + /> + + {kind === "schedule" && ( + <> + patch("cron", v)} + disabled={save.isPending} + /> + patch("timezone", v)} + disabled={save.isPending} + /> + + )} + {kind === "task_event" ? ( + <> + ({ + value: p.id, + label: p.title, + }))} + disabled={save.isPending} + /> + {[ + ["created", "Task created"], + ["status_changed", "Task status changed"], + ].map(([value, label]) => ( + + {label} + + patch( + "events", + v + ? [...(config.events ?? []), value] + : config.events?.filter((e) => e !== value), + ) + } + /> + + ))} + Run only for these task states + {[ + "backlog", + "todo", + "in_progress", + "in_review", + "done", + "cancelled", + ].map((value) => ( + + {value.replaceAll("_", " ")} + + patch( + "statuses", + v + ? [...(config.statuses ?? []), value] + : config.statuses?.filter((e) => e !== value), + ) + } + /> + + ))} + + ) : ( + <> + patch("rootTitle", v)} + disabled={save.isPending} + /> + patch("rootDescription", v)} + disabled={save.isPending} + multiline + /> + + )} + + This trigger is pinned to the selected published version. Publishing + another version will not upgrade it automatically. + + { + if ( + !String(doc.values.name ?? "").trim() || + (kind !== "task_event" && !config.rootTitle?.trim()) || + (kind === "task_event" && + (!config.projectId || + !config.events?.length || + !config.statuses?.length)) + ) + throw new Error( + "Enter a name, parent task title, or task event project and filters.", + ); + const selected = + kind === "task_event" + ? { + projectId: config.projectId, + events: config.events, + statuses: config.statuses, + } + : kind === "schedule" + ? { + cron: config.cron, + timezone: config.timezone, + rootTitle: config.rootTitle, + rootDescription: config.rootDescription, + } + : { + rootTitle: config.rootTitle, + rootDescription: config.rootDescription, + }; + const request: GraphTriggerWrite = { + name: String(doc.values.name).trim(), + kind, + enabled: doc.values.enabled === true, + versionId: execution.versionId, + input: execution.input, + runtimeBindings: execution.runtimeBindings, + repository: execution.repository, + config: selected, + expectedRevision: trigger?.revision, + }; + const result = await save.mutateAsync({ + triggerId: trigger?.id, + request: { ...request, idempotencyKey: commandKey(request) }, + }); + if (result.secret) onSecret(result.secret, result.webhookUrl ?? ""); + save.reset(); + useGraphDraftStore.getState().field(key, ["values"], {}); + onDone(); + }} + /> + + + ); +} +export function GraphTriggersScreen({ id }: { id: string }) { + const { wsId, admin } = useGraphContext(), + graph = useQuery(graphDetailOptions(wsId, id)), + triggers = useQuery(graphTriggersOptions(wsId, id)), + save = useSaveGraphTrigger(wsId, id), + remove = useDeleteGraphTrigger(wsId, id), + rotate = useRotateGraphTriggerSecret(), + commandKey = useGraphCommandKey(); + const [editing, setEditing] = useState(null), + [secret, setSecret] = useState<{ secret: string; url: string } | null>( + null, + ), + [error, setError] = useState(null); + const pending = save.isPending || remove.isPending || rotate.isPending; + const showSecret = (value: string, url: string) => + setSecret({ + secret: value, + url: new URL(url, process.env.EXPO_PUBLIC_API_URL).toString(), + }); + const invoke = async (fn: () => Promise) => { + setError(null); + try { + await fn(); + } catch (e) { + setError(e); + } + }; + return ( + + void triggers.refetch()} + /> + {secret && ( + + + This secret is shown only once. Rotation immediately revokes the + previous secret. + + {secret.url} + {secret.secret} + + + POST JSON {`{"input":{...}}`} to this URL with Authorization: Bearer + <secret> and an Idempotency-Key event identifier (1–128 ASCII + characters). Repeated event keys cannot change the payload. + + + + )} + {graph.data && editing !== null ? ( + t.id === editing)} + onDone={() => setEditing(null)} + onSecret={showSecret} + /> + ) : ( + <> + {admin && ( + + )} + {triggers.data?.items.map((trigger) => ( + + + {trigger.kind.replaceAll("_", " ")} ·{" "} + {trigger.enabled ? "Enabled" : "Disabled"} + + {trigger.nextFireAt && ( + + Next: {new Date(trigger.nextFireAt).toLocaleString()} + + )} + {trigger.lastError && ( + {trigger.lastError} + )} + {admin && ( + <> + + + {trigger.kind === "webhook" && ( + + )} + + + )} + + + + + ))} + + )} + + ); +} diff --git a/apps/mobile/components/nav/more-tab-dropdown.tsx b/apps/mobile/components/nav/more-tab-dropdown.tsx index 9aac93b8e1a..1e6060440c3 100644 --- a/apps/mobile/components/nav/more-tab-dropdown.tsx +++ b/apps/mobile/components/nav/more-tab-dropdown.tsx @@ -77,6 +77,11 @@ const NAV_ITEMS: NavItem[] = [ { label: "Pinned", icon: "pin", path: "/more/pins" }, { label: "Issues", icon: "list.bullet", path: "/more/issues" }, { label: "Projects", icon: "square.stack", path: "/more/projects" }, + { + label: "Graph", + icon: "point.3.connected.trianglepath.dotted", + path: "/graph", + }, ]; export function MoreTabDropdownAnchor({ @@ -140,9 +145,7 @@ export function MoreTabDropdownAnchor({ - slug && router.push(`/${slug}/switch-workspace`) - } + onPress={() => slug && router.push(`/${slug}/switch-workspace`)} chevronTint={t.mutedForeground} /> @@ -153,10 +156,7 @@ export function MoreTabDropdownAnchor({ key={item.path} onPress={() => slug && router.push(`/${slug}${item.path}`)} accessibilityLabel={item.label} - className={cn( - "h-9 gap-3", - isActive(item.path) && "bg-secondary", - )} + className={cn("h-9 gap-3", isActive(item.path) && "bg-secondary")} > )} - + {user?.name ?? "—"} {user?.email ? ( - + {user.email} ) : null} @@ -266,7 +260,7 @@ function WorkspaceCard({ disabled={!canSwitch} className="h-12 gap-3" accessibilityLabel={ - canSwitch ? "Switch workspace" : currentWorkspaceName ?? "Workspace" + canSwitch ? "Switch workspace" : (currentWorkspaceName ?? "Workspace") } > - + {currentWorkspaceName ?? "Workspace"} diff --git a/apps/mobile/data/api.ts b/apps/mobile/data/api.ts index 50e1843dcc3..e980532cf01 100644 --- a/apps/mobile/data/api.ts +++ b/apps/mobile/data/api.ts @@ -1,3 +1,6 @@ +import { GraphCommitsSchema, GraphGenerationOptionsSchema, GraphGenerationResultSchema, GraphListSchema, GraphRecordSchema, GraphRunListSchema, GraphRunSchema, GraphRunSearchSchema, GraphSearchSchema, GraphStartOptionsSchema, GraphTriggerEventsSchema, GraphTriggerListSchema, GraphTriggerSchema, GraphTriggerSecretSchema, GraphValidationSchema, GraphVersionSchema } from "@multica/core/graphs/schemas"; +import type { GraphApprovalRequest, GraphCommand, GraphCommitOption, GraphDefinition, GraphGenerationOptions, GraphGenerationRequest, GraphGenerationResult, GraphRecord, GraphRepositoryOption, GraphRun, GraphRunAction, GraphSearchParams, GraphSearchResult, GraphStartRequest, GraphTrigger, GraphTriggerWrite, GraphValidation, GraphVersion } from "@multica/core/graphs/types"; +import { definitionToWire, importGraphDefinition } from "@multica/core/graphs/definition"; /** * Mobile-owned fetch wrapper. Mirrors the surface area of * packages/core/api/client.ts that mobile actually uses, but lives in @@ -191,7 +194,7 @@ class ApiClient { private async fetch( path: string, - init: RequestInit & { signal?: AbortSignal } = {}, + init: RequestInit & { signal?: AbortSignal; timeoutMs?: number } = {}, ): Promise { const rid = createRequestId(); const start = Date.now(); @@ -226,10 +229,11 @@ class ApiClient { // facebook/react-native#35384). Without this, a refetch // triggered by WS reconnect can leave the FlatList pull-to-refresh // spinner stuck on the screen indefinitely. + const timeoutMs = init.timeoutMs ?? FETCH_TIMEOUT_MS; const controller = new AbortController(); const timeoutId = setTimeout(() => { - controller.abort(new Error(`request timed out after ${FETCH_TIMEOUT_MS}ms`)); - }, FETCH_TIMEOUT_MS); + controller.abort(new Error(`request timed out after ${timeoutMs}ms`)); + }, timeoutMs); const callerSignal = init.signal; const onCallerAbort = () => controller.abort(callerSignal?.reason); if (callerSignal) { @@ -261,7 +265,7 @@ class ApiClient { duration: `${duration}ms`, }); throw new ApiError( - `Request timed out after ${FETCH_TIMEOUT_MS}ms`, + `Request timed out after ${timeoutMs}ms`, 0, undefined, ); @@ -361,6 +365,156 @@ class ApiClient { }); } + async listGraphTriggerEvents(triggerId: string, opts?: { signal?: AbortSignal }): Promise<{ items: { id: string; state: string; runId?: string; error?: string; createdAt: string; updatedAt: string }[] }> { + return this.fetchValidated(`/api/graph-triggers/${encodeURIComponent(triggerId)}/events`, GraphTriggerEventsSchema, { items: [] }, { ...opts, endpoint: "GET /api/graph-triggers/:id/events" }); + } + async listGraphTriggers(graphId: string, opts?: { signal?: AbortSignal }): Promise<{ items: GraphTrigger[] }> { + return this.fetchValidated(`/api/graphs/${encodeURIComponent(graphId)}/triggers`, GraphTriggerListSchema, { items: [] }, { ...opts, endpoint: "GET /api/graphs/:id/triggers" }); + } + async saveGraphTrigger(graphId: string, request: GraphTriggerWrite, triggerId?: string): Promise { + const body = { name: request.name, kind: request.kind, version_id: request.versionId, enabled: request.enabled, expected_revision: request.expectedRevision, idempotency_key: request.idempotencyKey, + config: { cron: request.config.cron, timezone: request.config.timezone, project_id: request.config.projectId, statuses: request.config.statuses, events: request.config.events, root_title: request.config.rootTitle, root_description: request.config.rootDescription }, + input: request.input, runtime_bindings: request.runtimeBindings, repository: request.repository ? { repository_id: request.repository.repositoryId, base_sha: request.repository.baseSha, commit_sha: request.repository.commitSha, artifact_id: request.repository.artifactId } : undefined }; + const raw = await this.fetch(triggerId ? `/api/graph-triggers/${encodeURIComponent(triggerId)}` : `/api/graphs/${encodeURIComponent(graphId)}/triggers`, { method: triggerId ? "PUT" : "POST", body: JSON.stringify(body) }); + const result = parseWithFallback(raw, GraphTriggerSchema, null, { endpoint: "SAVE /api/graph-triggers" }); + if (!result) throw new ApiError("Invalid trigger response. Refresh before trying again.", 502, "Invalid response"); + return result; + } + async deleteGraphTrigger(triggerId: string, command: GraphCommand): Promise { await this.fetch(`/api/graph-triggers/${encodeURIComponent(triggerId)}`, { method: "DELETE", body: JSON.stringify({ expected_revision: command.expectedRevision, idempotency_key: command.idempotencyKey }) }); } + async rotateGraphTriggerSecret(triggerId: string, command: GraphCommand): Promise<{ secret: string; webhookUrl: string }> { + const result = await this.fetchValidatedWith<{ secret: string; webhookUrl: string } | null>(`/api/graph-triggers/${encodeURIComponent(triggerId)}/rotate-secret`, GraphTriggerSecretSchema, null, { method: "POST", body: JSON.stringify({ expected_revision: command.expectedRevision, idempotency_key: command.idempotencyKey }) }); + if (!result) throw new ApiError("Invalid webhook secret response.", 502, "Invalid response"); + return result; + } + async searchGraphs(params: GraphSearchParams = {}, opts?: { signal?: AbortSignal }): Promise> { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) if (value !== undefined && value !== "") query.set(key === "graphId" ? "graph_id" : key, String(value)); + return this.fetchValidated(`/api/graphs/search?${query}`, GraphSearchSchema, { items: [], total: 0 }, { ...opts, endpoint: "GET /api/graphs/search" }); + } + async searchGraphRuns(params: GraphSearchParams = {}, opts?: { signal?: AbortSignal }): Promise> { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) if (value !== undefined && value !== "") query.set(key === "graphId" ? "graph_id" : key, String(value)); + return this.fetchValidated(`/api/graph-runs/search?${query}`, GraphRunSearchSchema, { items: [], total: 0 }, { ...opts, endpoint: "GET /api/graph-runs/search" }); + } + async graphStartOptions(rootIssueId?: string, opts?: { signal?: AbortSignal }): Promise<{ repositories: GraphRepositoryOption[] }> { + return this.fetchValidated(`/api/graphs/start-options${rootIssueId ? `?root_issue_id=${encodeURIComponent(rootIssueId)}` : ""}`, GraphStartOptionsSchema, { repositories: [] }, { ...opts, endpoint: "GET /api/graphs/start-options" }); + } + async graphRepositoryCommits(repositoryId: string, params: { q?: string; ref?: string; cursor?: string } = {}, opts?: { signal?: AbortSignal }): Promise<{ commits: GraphCommitOption[]; nextCursor?: string }> { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) if (value) query.set(key, value); + return this.fetchValidated(`/api/graphs/repositories/${encodeURIComponent(repositoryId)}/commits?${query}`, GraphCommitsSchema, { commits: [] }, { ...opts, endpoint: "GET /api/graphs/repositories/:id/commits" }); + } + async getGraphGenerationOptions(opts?: { signal?: AbortSignal }): Promise { + return parseWithFallback( + await this.fetch("/api/graphs/generation-options", { signal: opts?.signal }), GraphGenerationOptionsSchema, + { enabled: false }, { endpoint: "GET /api/graphs/generation-options" }, + ); + } + + async generateGraph(request: GraphGenerationRequest): Promise { + const raw = await this.fetch("/api/graphs/generate", { + timeoutMs: 180_000, method: "POST", body: JSON.stringify({ prompt: request.prompt, agent_id: request.agentId, approver_id: request.approverId, ...(request.currentDefinition ? { current_definition: definitionToWire(request.currentDefinition) } : {}), ...(request.messages ? { messages: request.messages } : {}) }), + }); + const result = parseWithFallback(raw, GraphGenerationResultSchema, null, { endpoint: "POST /api/graphs/generate" }); + if (!result || !raw || typeof raw !== "object" || !("definition" in raw)) throw new ApiError("Invalid Graph generation response. Your draft has not changed.", 502, "Invalid response"); + // Generated drafts must belong to the editor's supported schema versions. + // A newer backend must not silently replace a draft with an uneditable graph. + try { + return { definition: importGraphDefinition(JSON.stringify(raw.definition)), ...(result.summary ? { summary: result.summary } : {}) }; + } catch { + throw new ApiError("Invalid Graph generation response. Your draft has not changed.", 502, "Invalid response"); + } + } + + async listGraphs(opts?: { signal?: AbortSignal }): Promise { + return this.fetchValidated("/api/graphs", GraphListSchema, [] as GraphRecord[], { ...opts, endpoint: "GET /api/graphs" }); + } + + async getGraph(id: string, opts?: { signal?: AbortSignal }): Promise { + return this.fetchValidated(`/api/graphs/${encodeURIComponent(id)}`, GraphRecordSchema, null, { ...opts, endpoint: "GET /api/graphs/:id" }); + } + + async saveGraph(definition: GraphDefinition, command?: { id: string; expectedRevision: number }, idempotencyKey?: string): Promise { + const raw = await this.fetch(command ? `/api/graphs/${encodeURIComponent(command.id)}` : "/api/graphs", { + method: command ? "PUT" : "POST", + body: JSON.stringify({ definition: definitionToWire(definition), expected_revision: command?.expectedRevision, idempotency_key: idempotencyKey }), + }); + const result = parseWithFallback(raw, GraphRecordSchema, null, { endpoint: "SAVE /api/graphs" }); + if (!result) throw new ApiError("Invalid Graph response. Refresh before trying again.", 502, "Invalid response"); + return result; + } + + async validateGraph(id: string): Promise { + const raw = await this.fetch(`/api/graphs/${encodeURIComponent(id)}/validate`, { method: "POST", body: "{}" }); + return parseWithFallback(raw, GraphValidationSchema, { valid: false, diagnostics: [{ code: "invalid_response", message: "Unable to read validation response." }] }, { endpoint: "POST /api/graphs/:id/validate" }); + } + + async publishGraph(id: string, command: GraphCommand): Promise { + const raw = await this.fetch(`/api/graphs/${encodeURIComponent(id)}/publish`, { + method: "POST", body: JSON.stringify({ expected_revision: command.expectedRevision, idempotency_key: command.idempotencyKey }), + }); + const result = parseWithFallback(raw, GraphVersionSchema, null, { endpoint: "POST /api/graphs/:id/publish" }); + if (!result) throw new ApiError("Invalid publication response. Refresh before trying again.", 502, "Invalid response"); + return result; + } + + async listGraphRuns(opts?: { signal?: AbortSignal }): Promise { + return this.fetchValidated("/api/graph-runs", GraphRunListSchema, [] as GraphRun[], { ...opts, endpoint: "GET /api/graph-runs" }); + } + + async getGraphRun(id: string, opts?: { signal?: AbortSignal }): Promise { + return this.fetchValidated(`/api/graph-runs/${encodeURIComponent(id)}`, GraphRunSchema, null, { ...opts, endpoint: "GET /api/graph-runs/:id" }); + } + + async startGraphRun(request: GraphStartRequest): Promise { + const raw = await this.fetch("/api/graph-runs", { method: "POST", body: JSON.stringify({ + version_id: request.versionId, root_issue_id: request.rootIssueId, input: request.input, + runtime_bindings: request.runtimeBindings, deadline: request.deadline, idempotency_key: request.idempotencyKey, + previous_run_id: request.previousRunId, + repository: request.repository ? { repository_id: request.repository.repositoryId, base_sha: request.repository.baseSha, commit_sha: request.repository.commitSha, artifact_id: request.repository.artifactId } : undefined, + }) }); + const result = parseWithFallback(raw, GraphRunSchema, null, { endpoint: "POST /api/graph-runs" }); + if (!result) throw new ApiError("Invalid run response. Refresh before trying again.", 502, "Invalid response"); + return result; + } + + async controlGraphRun(id: string, action: GraphRunAction, command: GraphCommand): Promise { + const raw = await this.fetch(`/api/graph-runs/${encodeURIComponent(id)}/${action}`, { method: "POST", body: JSON.stringify({ + expected_revision: command.expectedRevision, idempotency_key: command.idempotencyKey, node_run_id: command.nodeRunId, reason: command.reason, + }) }); + const result = parseWithFallback(raw, GraphRunSchema, null, { endpoint: `POST /api/graph-runs/:id/${action}` }); + if (!result) throw new ApiError("Invalid run response. Refresh before trying again.", 502, "Invalid response"); + return result; + } + + async approveGraphRun(id: string, request: GraphApprovalRequest): Promise { + const raw = await this.fetch(`/api/graph-runs/${encodeURIComponent(id)}/approval`, { method: "POST", body: JSON.stringify({ + expected_revision: request.expectedRevision, idempotency_key: request.idempotencyKey, + approval_id: request.approvalId, candidate_digest: request.candidateDigest, decision: request.decision, reason: request.reason, + }) }); + const result = parseWithFallback(raw, GraphRunSchema, null, { endpoint: "POST /api/graph-runs/:id/approval" }); + if (!result) throw new ApiError("Invalid approval response. Refresh before trying again.", 502, "Invalid response"); + return result; + } + + async archiveGraph(id: string, command: GraphCommand): Promise { + const raw = await this.fetch(`/api/graphs/${encodeURIComponent(id)}/archive`, { method: "POST", body: JSON.stringify({ expected_revision: command.expectedRevision, idempotency_key: command.idempotencyKey }) }); + const result = parseWithFallback(raw, GraphRecordSchema, null, { endpoint: "POST /api/graphs/:id/archive" }); + if (!result) throw new ApiError("Invalid Graph response. Refresh before trying again.", 502, "Invalid response"); + return result; + } + + async replaceGraphApprover(id: string, command: GraphCommand & { approverId: string; previousApproverId?: string }): Promise { + const raw = await this.fetch(`/api/graph-runs/${encodeURIComponent(id)}/approver`, { method: "POST", body: JSON.stringify({ expected_revision: command.expectedRevision, idempotency_key: command.idempotencyKey, approver_id: command.approverId, node_run_id: command.nodeRunId, previous_approver_id: command.previousApproverId, reason: command.reason }) }); + const result = parseWithFallback(raw, GraphRunSchema, null, { endpoint: "POST /api/graph-runs/:id/approver" }); + if (!result) throw new ApiError("Invalid run response. Refresh before trying again.", 502, "Invalid response"); + return result; + } + + async detachGraphIssue(issueId: string, idempotencyKey: string): Promise { + await this.fetch(`/api/issues/${encodeURIComponent(issueId)}/graph-detach`, { method: "POST", body: JSON.stringify({ idempotency_key: idempotencyKey }) }); + } + // --- Auth --- async sendCode(email: string): Promise { await this.fetch("/auth/send-code", { diff --git a/apps/mobile/data/mutations/graphs.ts b/apps/mobile/data/mutations/graphs.ts new file mode 100644 index 00000000000..9445fea9b8d --- /dev/null +++ b/apps/mobile/data/mutations/graphs.ts @@ -0,0 +1,104 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/data/api"; +import { graphKeys } from "@/data/queries/graphs"; +import { issueKeys } from "@/data/queries/issue-keys"; +import type { GraphApprovalRequest, GraphCommand, GraphDefinition, GraphGenerationRequest, GraphRunAction, GraphStartRequest, GraphTriggerWrite } from "@multica/core/graphs/types"; + +export function useSaveGraphTrigger(wsId: string, graphId: string) { + const qc = useQueryClient(); + return useMutation({ mutationFn: ({ request, triggerId }: { request: GraphTriggerWrite; triggerId?: string }) => api.saveGraphTrigger(graphId, request, triggerId), onSettled: () => qc.invalidateQueries({ queryKey: graphKeys.triggers(wsId, graphId) }) }); +} +export function useDeleteGraphTrigger(wsId: string, graphId: string) { + const qc = useQueryClient(); + return useMutation({ mutationFn: ({ triggerId, ...command }: GraphCommand & { triggerId: string }) => api.deleteGraphTrigger(triggerId, command), onSettled: () => qc.invalidateQueries({ queryKey: graphKeys.triggers(wsId, graphId) }) }); +} +export function useRotateGraphTriggerSecret() { return useMutation({ mutationFn: ({ triggerId, ...command }: GraphCommand & { triggerId: string }) => api.rotateGraphTriggerSecret(triggerId, command) }); } + +export function useGenerateGraph(wsId: string) { + return useMutation({ + mutationKey: [...graphKeys.all(wsId), "generate"], + mutationFn: (request: GraphGenerationRequest) => api.generateGraph(request), + retry: false, + }); +} + +export function useSaveGraph(wsId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (request: { definition: GraphDefinition; id?: string; expectedRevision?: number; idempotencyKey: string }) => + api.saveGraph(request.definition, request.id ? { id: request.id, expectedRevision: request.expectedRevision ?? 0 } : undefined, request.idempotencyKey), + onSuccess: (graph) => qc.setQueryData(graphKeys.detail(wsId, graph.id), graph), + onSettled: () => qc.invalidateQueries({ queryKey: graphKeys.all(wsId) }), + }); +} + +export function useValidateGraph() { + return useMutation({ mutationFn: (id: string) => api.validateGraph(id) }); +} + +export function usePublishGraph(wsId: string) { + const qc = useQueryClient(); + return useMutation({ mutationFn: ({ id, ...command }: GraphCommand & { id: string }) => api.publishGraph(id, command), onSettled: () => qc.invalidateQueries({ queryKey: graphKeys.all(wsId) }) }); +} + +export function useStartGraphRun(wsId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (request: GraphStartRequest) => api.startGraphRun(request), + onSuccess: (run) => { + qc.setQueryData(graphKeys.run(wsId, run.id), run); + qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, run.rootIssueId) }); + }, + onSettled: () => qc.invalidateQueries({ queryKey: graphKeys.all(wsId) }), + }); +} + +export function useControlGraphRun(wsId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, action, ...command }: GraphCommand & { id: string; action: GraphRunAction }) => api.controlGraphRun(id, action, command), + onSuccess: (run) => qc.setQueryData(graphKeys.run(wsId, run.id), run), + onSettled: () => qc.invalidateQueries({ queryKey: graphKeys.all(wsId) }), + }); +} + +export function useApproveGraphRun(wsId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...request }: GraphApprovalRequest & { id: string }) => api.approveGraphRun(id, request), + onSuccess: (run) => { + qc.setQueryData(graphKeys.run(wsId, run.id), run); + qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, run.rootIssueId) }); + }, + onSettled: () => qc.invalidateQueries({ queryKey: graphKeys.all(wsId) }), + }); +} + +export function useArchiveGraph(wsId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...command }: GraphCommand & { id: string }) => api.archiveGraph(id, command), + onSuccess: (graph) => qc.setQueryData(graphKeys.detail(wsId, graph.id), graph), + onSettled: () => qc.invalidateQueries({ queryKey: graphKeys.all(wsId) }), + }); +} + +export function useReplaceGraphApprover(wsId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...command }: GraphCommand & { id: string; approverId: string; previousApproverId?: string }) => api.replaceGraphApprover(id, command), + onSuccess: (run) => qc.setQueryData(graphKeys.run(wsId, run.id), run), + onSettled: () => qc.invalidateQueries({ queryKey: graphKeys.all(wsId) }), + }); +} + +export function useDetachGraphIssue(wsId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ issueId, idempotencyKey }: { issueId: string; idempotencyKey: string }) => api.detachGraphIssue(issueId, idempotencyKey), + onSettled: (_data, _error, { issueId }) => Promise.all([ + qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, issueId) }), + qc.invalidateQueries({ queryKey: graphKeys.all(wsId) }), + ]), + }); +} diff --git a/apps/mobile/data/queries/graphs.test.ts b/apps/mobile/data/queries/graphs.test.ts new file mode 100644 index 00000000000..b21f2ecd8e5 --- /dev/null +++ b/apps/mobile/data/queries/graphs.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; +import { graphDetailOptions, graphRunOptions, graphSearchOptions, graphCommitsOptions, graphIssueSearchOptions, graphKeys } from "./graphs"; +import { api } from "@/data/api"; +vi.mock("@/data/api", () => ({ api: { getGraph: vi.fn().mockResolvedValue(null), getGraphRun: vi.fn().mockResolvedValue(null), searchGraphs: vi.fn().mockResolvedValue({ items: [], total: 0 }), graphRepositoryCommits: vi.fn().mockResolvedValue({ commits: [] }), listIssues: vi.fn().mockResolvedValue({ issues: [] }) } })); +describe("mobile Graph queries", () => { + it("isolates workspaces and never seeds detail caches with listing metadata", () => { + const qc = new QueryClient(); qc.setQueryData(graphKeys.search("a", {}), { items: [{ id: "g" }], total: 1 }); + expect(qc.getQueryData(graphKeys.detail("a", "g"))).toBeUndefined(); + expect(graphKeys.run("a", "r")).not.toEqual(graphKeys.run("b", "r")); + }); + it("forwards cancellation to Graph reads including commit and task lookup", async () => { + const qc = new QueryClient(); + await qc.fetchQuery(graphDetailOptions("w", "g")); await qc.fetchQuery(graphRunOptions("w", "r")); await qc.fetchQuery(graphSearchOptions("w")); await qc.fetchQuery(graphCommitsOptions("w", "repo", { q: "main" })); await qc.fetchQuery(graphIssueSearchOptions("w", "release")); + expect(api.getGraph).toHaveBeenCalledWith("g", { signal: expect.any(AbortSignal) }); + expect(api.getGraphRun).toHaveBeenCalledWith("r", { signal: expect.any(AbortSignal) }); + expect(api.searchGraphs).toHaveBeenCalledWith({}, { signal: expect.any(AbortSignal) }); + expect(api.graphRepositoryCommits).toHaveBeenCalledWith("repo", { q: "main" }, { signal: expect.any(AbortSignal) }); + expect(api.listIssues).toHaveBeenCalledWith({ q: "release", limit: 30 }, { signal: expect.any(AbortSignal) }); + }); +}); diff --git a/apps/mobile/data/queries/graphs.ts b/apps/mobile/data/queries/graphs.ts new file mode 100644 index 00000000000..058b792f9b1 --- /dev/null +++ b/apps/mobile/data/queries/graphs.ts @@ -0,0 +1,55 @@ +import { queryOptions } from "@tanstack/react-query"; +import { api } from "@/data/api"; +import { GRAPH_TERMINAL_STATES } from "@multica/core/graphs/controls"; +import type { GraphSearchParams } from "@multica/core/graphs/types"; + +export const graphKeys = { + all: (wsId: string) => ["graphs", wsId] as const, + list: (wsId: string) => [...graphKeys.all(wsId), "list"] as const, + generation: (wsId: string) => [...graphKeys.all(wsId), "generation-options"] as const, + detail: (wsId: string, id: string) => [...graphKeys.all(wsId), "detail", id] as const, + runs: (wsId: string) => [...graphKeys.all(wsId), "runs"] as const, + run: (wsId: string, id: string) => [...graphKeys.runs(wsId), id] as const, + tasks: (wsId: string, issueId: string) => [...graphKeys.all(wsId), "tasks", issueId] as const, + search: (wsId: string, params: GraphSearchParams) => [...graphKeys.all(wsId), "search", params] as const, + runSearch: (wsId: string, params: GraphSearchParams) => [...graphKeys.all(wsId), "run-search", params] as const, + start: (wsId: string) => [...graphKeys.all(wsId), "start-options"] as const, + commits: (wsId: string, repositoryId: string, params: { q?: string; ref?: string; cursor?: string }) => [...graphKeys.all(wsId), "commits", repositoryId, params] as const, + issueSearch: (wsId: string, q: string) => [...graphKeys.all(wsId), "issue-search", q] as const, + triggers: (wsId: string, graphId: string) => [...graphKeys.all(wsId), "triggers", graphId] as const, +}; + +export function graphSearchOptions(wsId: string, params: GraphSearchParams = {}) { return queryOptions({ queryKey: graphKeys.search(wsId, params), queryFn: ({ signal }) => api.searchGraphs(params, { signal }), enabled: Boolean(wsId) }); } +export function graphRunSearchOptions(wsId: string, params: GraphSearchParams = {}) { return queryOptions({ queryKey: graphKeys.runSearch(wsId, params), queryFn: ({ signal }) => api.searchGraphRuns(params, { signal }), enabled: Boolean(wsId), refetchInterval: 30_000 }); } +export function graphStartOptions(wsId: string, rootIssueId?: string) { return queryOptions({ queryKey: [...graphKeys.start(wsId), rootIssueId ?? ""], queryFn: ({ signal }) => api.graphStartOptions(rootIssueId, { signal }), enabled: Boolean(wsId) }); } +export function graphCommitsOptions(wsId: string, repositoryId: string, params: { q?: string; ref?: string; cursor?: string } = {}) { return queryOptions({ queryKey: graphKeys.commits(wsId, repositoryId, params), queryFn: ({ signal }) => api.graphRepositoryCommits(repositoryId, params, { signal }), enabled: Boolean(wsId && repositoryId) }); } +export function graphIssueSearchOptions(wsId: string, q: string) { return queryOptions({ queryKey: graphKeys.issueSearch(wsId, q), queryFn: ({ signal }) => api.listIssues({ q, limit: 30 }, { signal }), enabled: Boolean(wsId), staleTime: 30_000 }); } +export function graphTriggersOptions(wsId: string, graphId: string) { return queryOptions({ queryKey: graphKeys.triggers(wsId, graphId), queryFn: ({ signal }) => api.listGraphTriggers(graphId, { signal }), enabled: Boolean(wsId && graphId) }); } +export function graphTriggerEventsOptions(wsId: string, triggerId: string) { return queryOptions({ queryKey: [...graphKeys.all(wsId), "trigger-events", triggerId], queryFn: ({ signal }) => api.listGraphTriggerEvents(triggerId, { signal }), enabled: Boolean(wsId && triggerId), refetchInterval: 30_000 }); } + +export function graphGenerationOptions(wsId: string) { + return queryOptions({ queryKey: graphKeys.generation(wsId), queryFn: ({ signal }) => api.getGraphGenerationOptions({ signal }), enabled: Boolean(wsId), retry: false }); +} + +export function graphListOptions(wsId: string) { + return queryOptions({ queryKey: graphKeys.list(wsId), queryFn: ({ signal }) => api.listGraphs({ signal }), enabled: Boolean(wsId) }); +} + +export function graphDetailOptions(wsId: string, id: string) { + return queryOptions({ queryKey: graphKeys.detail(wsId, id), queryFn: ({ signal }) => api.getGraph(id, { signal }), enabled: Boolean(wsId && id), retry: false }); +} + +export function graphRunsOptions(wsId: string) { + return queryOptions({ queryKey: graphKeys.runs(wsId), queryFn: ({ signal }) => api.listGraphRuns({ signal }), enabled: Boolean(wsId), refetchInterval: 30_000 }); +} + +export function graphRunOptions(wsId: string, id: string) { + return queryOptions({ + queryKey: graphKeys.run(wsId, id), queryFn: ({ signal }) => api.getGraphRun(id, { signal }), enabled: Boolean(wsId && id), retry: false, + refetchInterval: (query) => query.state.data && GRAPH_TERMINAL_STATES.has(query.state.data.state) ? false : 15_000, + }); +} + +export function graphNodeTasksOptions(wsId: string, issueId: string) { + return queryOptions({ queryKey: graphKeys.tasks(wsId, issueId), queryFn: ({ signal }) => api.listTasksByIssue(issueId, { signal }), enabled: Boolean(wsId && issueId), refetchInterval: 15_000 }); +} diff --git a/apps/mobile/data/realtime/use-graphs-realtime.ts b/apps/mobile/data/realtime/use-graphs-realtime.ts new file mode 100644 index 00000000000..56c1bafefb9 --- /dev/null +++ b/apps/mobile/data/realtime/use-graphs-realtime.ts @@ -0,0 +1,23 @@ +import { useEffect } from "react"; +import { useAuthStore } from "@/data/auth-store"; +import { useWorkspaceStore } from "@/data/workspace-store"; +import { useGraphDraftStore } from "@/data/stores/graph-draft-store"; +import { useQueryClient } from "@tanstack/react-query"; +import { isGraphManagedIssue } from "@multica/core/graphs/controls"; +import { graphKeys } from "@/data/queries/graphs"; +import { useWSSubscriptions } from "@/lib/use-ws-subscriptions"; + +/** Graph events carry identifiers, not a complete multi-table run snapshot. */ +export function useGraphsRealtime() { + const qc = useQueryClient(); + const userId = useAuthStore((s) => s.user?.id), workspaceId = useWorkspaceStore((s) => s.currentWorkspaceId); + useEffect(() => () => useGraphDraftStore.getState().clear(), [userId, workspaceId]); + useWSSubscriptions((ws, wsId) => { + const refresh = () => { void qc.invalidateQueries({ queryKey: graphKeys.all(wsId) }); }; + return [ + ws.on("issue:updated", ({ issue }) => { if (isGraphManagedIssue(issue)) refresh(); }), + ws.onAny((message) => { if (message.type.startsWith("graph:")) refresh(); }), + ws.onReconnect(refresh), + ]; + }, [qc]); +} diff --git a/apps/mobile/data/stores/graph-draft-store.test.ts b/apps/mobile/data/stores/graph-draft-store.test.ts new file mode 100644 index 00000000000..a7d467abc21 --- /dev/null +++ b/apps/mobile/data/stores/graph-draft-store.test.ts @@ -0,0 +1,24 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("expo-secure-store", () => ({ getItemAsync: vi.fn().mockResolvedValue(null), setItemAsync: vi.fn().mockResolvedValue(undefined), deleteItemAsync: vi.fn().mockResolvedValue(undefined) })); +import { useGraphDraftStore, graphDraftIdentity } from "./graph-draft-store"; +const def = { schemaVersion: 2, name: "Research", inputSchema: { type: "object" }, nodes: [{ nodeKey: "start", type: "start" }], edges: [] }; +beforeEach(() => useGraphDraftStore.setState({ documents: {} })); +describe("native Graph drafts", () => { + it("changes condition and binding modes without retaining incompatible fields", () => { + const s=useGraphDraftStore.getState();s.open("k",{...def,nodes:[{nodeKey:"check",type:"branch",condition:{op:"eq",left:{source:"constant",value:true},right:{source:"constant",value:false}}}]},1); + s.field("k",["definition","nodes",0,"condition","op"],"all"); + expect(useGraphDraftStore.getState().documents.k.definition.nodes[0].condition).toEqual({op:"all",conditions:[{op:"exists",left:{source:"run_input",path:""}}]}); + s.field("k",["definition","nodes",0,"condition","conditions",0,"left","source"],"constant"); + expect(useGraphDraftStore.getState().documents.k.definition.nodes[0].condition?.conditions?.[0].left).toEqual({source:"constant",value:""}); + }); + it("isolates identity and preserves newer edits after an asynchronous save", () => { + const key = graphDraftIdentity("ws", "user", "g"); const s=useGraphDraftStore.getState();s.open(key,def,1);s.edit(key,{...def,name:"Submitted"});const submitted=useGraphDraftStore.getState().documents[key].definition;s.edit(key,{...def,name:"New edit"});s.saved(key,submitted,2); + expect(useGraphDraftStore.getState().documents[key]).toMatchObject({definition:{name:"New edit"},revision:2,dirty:true}); expect(graphDraftIdentity("ws","other","g")).not.toBe(key); + }); + it("requires proposal application and refuses to overwrite concurrent edits", () => { + const s=useGraphDraftStore.getState();s.open("k",def,1);s.propose("k",{...def,name:"AI"},def);expect(useGraphDraftStore.getState().documents.k.definition.name).toBe("Research");s.edit("k",{...def,name:"Manual"});expect(()=>s.apply("k")).toThrow();expect(useGraphDraftStore.getState().documents.k.definition.name).toBe("Manual"); + }); + it("undoes a complete edit and retains a conflict when the server revision changes", () => { + const s=useGraphDraftStore.getState();s.open("k",def,1);s.edit("k",{...def,name:"Local"});s.open("k",{...def,name:"Remote"},2);expect(useGraphDraftStore.getState().documents.k.conflict).toBe(true);s.undo("k");expect(useGraphDraftStore.getState().documents.k.definition.name).toBe("Research"); + }); +}); diff --git a/apps/mobile/data/stores/graph-draft-store.ts b/apps/mobile/data/stores/graph-draft-store.ts new file mode 100644 index 00000000000..1e1a6b89df4 --- /dev/null +++ b/apps/mobile/data/stores/graph-draft-store.ts @@ -0,0 +1,117 @@ +import { create } from "zustand"; +import * as SecureStore from "expo-secure-store"; +import type { GraphDefinition, GraphGenerationRequest } from "@multica/core/graphs/types"; +import { definitionToWire } from "@multica/core/graphs/definition"; +import { GraphDefinitionSchema } from "@multica/core/graphs/schemas"; + +export const graphDraftIdentity = (ws: string, user: string, id = "new") => + [ws, user, id].map((value) => Array.from(value).map((c) => c.codePointAt(0)!.toString(16)).join("_")).join("."); +export interface MobileGraphDraft { + definition: GraphDefinition; revision: number; dirty: boolean; conflict: boolean; + undo: GraphDefinition[]; redo: GraphDefinition[]; values: Record; + proposal?: { definition: GraphDefinition; base: string; summary?: string }; + messages: NonNullable; storageError?: string; +} +type Picker = { key: string; path: (string | number)[]; title: string; value: string; options: { value: string; label: string; description?: string; data?: unknown }[] }; +interface State { + documents: Record; picker?: Picker; + open(key: string, definition: GraphDefinition, revision: number): void; + recover(key: string): Promise; + edit(key: string, definition: GraphDefinition): void; + saved(key: string, submitted: GraphDefinition, revision: number): void; + reset(key: string, definition: GraphDefinition, revision: number): void; + field(key: string, path: (string | number)[], value: unknown): void; + undo(key: string): void; redo(key: string): void; + propose(key: string, definition: GraphDefinition, base: GraphDefinition, summary?: string): void; + apply(key: string): void; discardProposal(key: string): void; + clear(): void; +} +function setAt(value: unknown, path: (string | number)[], next: unknown): unknown { + if (!path.length) return next; + const [key, ...tail] = path; + if (Array.isArray(value)) return value.map((item, index) => index === key ? setAt(item, tail, next) : item); + const object = value && typeof value === "object" ? value as Record : {}; + return { ...object, [key!]: setAt(object[String(key)], tail, next) }; +} +// Chunk encrypted drafts so every SecureStore value stays below iOS's small-value +// limit. Switch the manifest last: interruption can never expose a partial draft. +const queues = new Map>(); +const timers = new Map>(); +function schedule(key: string, draft: MobileGraphDraft, onError: (error: unknown) => void) { + clearTimeout(timers.get(key)); + if (!draft.dirty) { timers.delete(key); void persist(key).catch(onError); return; } + timers.set(key, setTimeout(() => { timers.delete(key); void persist(key, draft).catch(onError); }, 350)); +} +async function persist(key: string, draft?: MobileGraphDraft) { + const prior = queues.get(key) ?? Promise.resolve(); + const work = prior.catch(() => {}).then(async () => { + const head = `graph.${key}`, oldText = await SecureStore.getItemAsync(head); + const old = oldText ? JSON.parse(oldText) as { generation: string; count: number } : undefined; + if (draft?.dirty) { + const text = JSON.stringify({ definition: definitionToWire(draft.definition), revision: draft.revision }); + const generation = `${Date.now()}_${Math.random().toString(36).slice(2)}`, chunks = text.match(/[\s\S]{1,400}/g) ?? []; + for (let i = 0; i < chunks.length; i++) await SecureStore.setItemAsync(`${head}.${generation}.${i}`, chunks[i]!); + await SecureStore.setItemAsync(head, JSON.stringify({ generation, count: chunks.length })); + } else await SecureStore.deleteItemAsync(head); + if (old && Number.isInteger(old.count) && old.count <= 3000 && /^[a-z0-9_]+$/.test(old.generation)) + for (let i = 0; i < old.count; i++) await SecureStore.deleteItemAsync(`${head}.${old.generation}.${i}`); + }); + queues.set(key, work); + try { await work; } finally { if (queues.get(key) === work) queues.delete(key); } +} +export const useGraphDraftStore = create((set, get) => { + const update = (key: string, transform: (draft: MobileGraphDraft) => MobileGraphDraft, save = true) => { + const current = get().documents[key]; if (!current) return; + const next = transform(current); set((s) => ({ documents: { ...s.documents, [key]: next } })); + if (save) schedule(key, next, (error) => set((s) => ({ documents: s.documents[key] ? { ...s.documents, [key]: { ...s.documents[key]!, storageError: String(error) } } : s.documents }))); + }; + return { + documents: {}, + open: (key, definition, revision) => { + const existing = get().documents[key]; + if (existing?.dirty) { if (existing.revision !== revision) update(key, (d) => ({ ...d, conflict: true }), false); return; } + if (existing?.revision === revision) return; + set((s) => ({ documents: { ...s.documents, [key]: { definition, revision, dirty: false, conflict: false, undo: [], redo: [], values: {}, messages: [] } } })); + }, + recover: async (key) => { + try { + const text = await SecureStore.getItemAsync(`graph.${key}`); if (!text) return; + const meta = JSON.parse(text) as { generation: string; count: number }; + if (!Number.isInteger(meta.count) || meta.count < 1 || meta.count > 3000 || !/^[a-z0-9_]+$/.test(meta.generation)) throw new Error("Invalid saved draft"); + const chunks: string[] = []; + for (let i = 0; i < meta.count; i++) { const part = await SecureStore.getItemAsync(`graph.${key}.${meta.generation}.${i}`); if (part === null) throw new Error("Incomplete saved draft"); chunks.push(part); } + const raw = JSON.parse(chunks.join("")), definition = GraphDefinitionSchema.parse(raw.definition); + if (![1,2].includes(definition.schemaVersion) || !Number.isInteger(raw.revision)) throw new Error("Unsupported saved draft"); + update(key, (d) => d.dirty ? d : { ...d, definition, revision: raw.revision, dirty: true, conflict: raw.revision !== d.revision }, false); + } catch (error) { update(key, (d) => ({ ...d, storageError: String(error) }), false); } + }, + edit: (key, definition) => update(key, (d) => ({ ...d, definition, dirty: true, undo: [...d.undo, d.definition].slice(-100), redo: [], storageError: undefined })), + saved: (key, submitted, revision) => update(key, (d) => ({ ...d, revision, conflict: false, dirty: JSON.stringify(d.definition) !== JSON.stringify(submitted) })), + reset: (key, definition, revision) => update(key, (d) => ({ ...d, definition, revision, dirty: false, conflict: false, undo: [], redo: [] })), + field: (key, path, value) => update(key, (d) => { + let target = path, replacement = value; + if (path[0] === "definition") { + const parentPath = path.slice(0, -1), parent = parentPath.reduce((v, k) => v && typeof v === "object" ? (v as Record)[String(k)] : undefined, d) as Record | undefined; + if (path[path.length - 1] === "op" && typeof value === "string") { + target = parentPath; + replacement = ["all", "any", "not"].includes(value) ? { op: value, conditions: (Array.isArray(parent?.conditions) ? parent.conditions : [{ op: "exists", left: { source: "run_input", path: "" } }]).slice(0, value === "not" ? 1 : undefined) } : { op: value, left: parent?.left ?? { source: "run_input", path: "" }, ...(value !== "exists" ? { right: parent?.right ?? { source: "constant", value: true } } : {}) }; + } else if (path[path.length - 1] === "source" && typeof value === "string") { + target = parentPath; + replacement = { source: value, ...(value === "constant" ? { value: "" } : value === "object" ? { fields: {} } : value !== "iteration_index" ? { path: "" } : {}), ...(parent?.optional !== undefined ? { optional: parent.optional } : {}), ...(parent?.default !== undefined ? { default: parent.default } : {}) }; + } else if (path[path.length - 1] === "type" && typeof value === "string" && (path.includes("inputSchema") || path.includes("outputSchema"))) { + target = parentPath; replacement = { type: value, ...(parent?.description ? { description: parent.description } : {}), ...(value === "array" ? { items: { type: "string" } } : {}) }; + } + } + const next = setAt(d, target, replacement) as MobileGraphDraft; + if (path[0] === "values" && path[1] === "input") next.values = { ...next.values, inputTouched: true }; + if (path[0] === "values" && path[1] === "issueId") next.values = { ...next.values, issueTouched: true }; + return path[0] === "definition" ? { ...next, dirty: true, undo: [...d.undo, d.definition].slice(-100), redo: [] } : next; + }, path[0] === "definition"), + undo: (key) => update(key, (d) => d.undo.length ? { ...d, definition: d.undo[d.undo.length - 1]!, undo: d.undo.slice(0, -1), redo: [d.definition, ...d.redo], dirty: true } : d), + redo: (key) => update(key, (d) => d.redo.length ? { ...d, definition: d.redo[0]!, redo: d.redo.slice(1), undo: [...d.undo, d.definition], dirty: true } : d), + propose: (key, definition, base, summary) => update(key, (d) => ({ ...d, proposal: { definition, base: JSON.stringify(base), summary } }), false), + apply: (key) => { const d = get().documents[key]; if (!d?.proposal) return; if (JSON.stringify(d.definition) !== d.proposal.base) throw new Error("The draft changed after this proposal. Generate a new proposal to preserve your changes."); get().edit(key, d.proposal.definition); get().discardProposal(key); }, + discardProposal: (key) => update(key, (d) => ({ ...d, proposal: undefined }), false), + clear: () => { const keys = Object.keys(get().documents); set({ documents: {}, picker: undefined }); for (const key of keys) { clearTimeout(timers.get(key)); timers.delete(key); void persist(key).catch(() => {}); } }, + }; +}); diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index 1d9f8e010a2..35085be79ee 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -96,6 +96,11 @@ services: APP_ENV: ${APP_ENV:-production} MULTICA_DEV_VERIFICATION_CODE: ${MULTICA_DEV_VERIFICATION_CODE:-} MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000} + MULTICA_LLM_API_KEY: ${MULTICA_LLM_API_KEY:-} + MULTICA_LLM_BASE_URL: ${MULTICA_LLM_BASE_URL:-} + MULTICA_LLM_DEFAULT_MODEL: ${MULTICA_LLM_DEFAULT_MODEL:-} + MULTICA_GRAPH_DISABLE_NEW_RUNS: ${MULTICA_GRAPH_DISABLE_NEW_RUNS:-false} + MULTICA_GRAPH_DISABLE_ADVANCEMENT: ${MULTICA_GRAPH_DISABLE_ADVANCEMENT:-false} MULTICA_SHUTDOWN_HOLD_DURATION: ${MULTICA_SHUTDOWN_HOLD_DURATION:-} ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true} ALLOWED_EMAILS: ${ALLOWED_EMAILS:-} diff --git a/docs/design/graph-module-v1.md b/docs/design/graph-module-v1.md index 85e3e54760e..a0cabf2817c 100644 --- a/docs/design/graph-module-v1.md +++ b/docs/design/graph-module-v1.md @@ -1,5 +1,7 @@ # Multica Graph 模块设计方案 +> 本文保留 V1 设计基线。V2 的分支、并行、循环与扩展节点契约见 [Graph V2 wire contract](graph-v2-contract.md),当前交付状态见 [V2 使用与发布说明](../graph-v2-delivery.md)。 + 状态:V1 设计基线。日期:2026-10-13。代码基线:`5b47a78f6`。本次实现范围、执行环境限制和验证结果见 [实现与验证说明](../graph-v1-delivery.md)。 Graph 是 Multica 的执行编排模块。它将已发布的流程转换为可追踪、可恢复的节点执行,复用现有智能体、Task 队列和守护进程。流程定义何时开始工作、接受哪些结果、何时等待人类;智能体在节点限定的目标内自主执行。 @@ -21,8 +23,8 @@ Graph 保证平台内的派发、结果接纳和状态转换。它不保证模 | 领域 | 第一版纳入 | 后续版本 | | --- | --- | --- | | 流程拓扑 | 一个 Start,1 至 16 个串行 Agent 节点,一个末尾 Approval,一个 End | Switch、Parallel、Join、循环、子图、动态增删节点 | -| 定义 | 工作区内模板,步骤表单,JSON 导入导出,校验,发布不可变版本 | 拖拽画布、内置 AI 生成与修图 | -| AI 辅助编排 | 人可导入 AI 生成的定义,走相同校验和发布流程 | 运行中 AI 改图、自动发布 | +| 定义 | 工作区内模板,串行流程预览与步骤配置,JSON 导入导出,校验,发布不可变版本 | 自由拖拽 DAG 画布 | +| AI 辅助编排 | 页面描述需求生成可编辑草稿,也可导入外部 AI 生成的定义;走相同校验和发布流程 | 运行中 AI 改图、自动发布 | | 执行者 | 每个 Agent 节点直接绑定一个智能体;启动时确定运行时 | 按角色动态选人、自动迁移运行时、Squad 节点 | | 运行 | 人工启动,单父任务同时最多一个非终态 GraphRun | 自动化、定时计划、Webhook 启动 Graph | | 重试 | 输入不变的技术重试,默认最多 3 次 Task 尝试,可在 1 至 3 次内配置 | 原地业务返工、多节点回退、检查点分叉 | diff --git a/docs/design/graph-runtime-compatibility.md b/docs/design/graph-runtime-compatibility.md new file mode 100644 index 00000000000..bbe54bc28f7 --- /dev/null +++ b/docs/design/graph-runtime-compatibility.md @@ -0,0 +1,72 @@ +# Graph runtime compatibility and verification + +Updated: 2026-10-13. This document describes the implemented admission and execution contract and the evidence collected for it. Support in the adapter matrix is not a claim that every third-party CLI has passed a live model deployment test. + +## Existing runtime integration + +Graph reuses existing workspace Runtime, Agent, runtime profiles, the shared `agent.ResolveBackend` factory, task queue, task credentials, plugin manifests, and credential broker. It does not maintain a second Codex-only runtime registry. A runtime profile remains the selected command plus fixed launch prefix; arbitrary wrappers must preserve the selected provider protocol. + +Publication freezes the effective provider/model, agent instructions, bound skills, runtime profile command and arguments, task settings, and plugin contribution entries. OpenClaw catalogs retain both the native agent ID and its actual effective model; Graph refuses an unresolved model instead of freezing the agent ID as if it were an LLM. Claim checks the runtime identity/profile and non-secret custom environment values against the snapshot. Credentials remain live references and can rotate without publishing their values into Graph JSON. Host-native credential and endpoint files are selectively projected at attempt preparation; these local files are not serialized as a server-side publication snapshot. Change them deliberately and refresh/re-publish when changing effective model routing. + +Daemon capability `graph_execution_v2` is required for non-Codex providers, custom runtime profiles, and scripts. Legacy `graph_execution_v1` remains compatible only with the original built-in Codex path. Declared support is checked by shared adapter admission and then by the provider's native launch/handshake. Unsupported thinking controls, non-Codex service tiers, resume flags, model/config overrides, and unscoped MCP overrides fail explicitly. Ordinary non-Graph task dispatch retains its prior behavior. + +## Isolation and stopping + +Each attempt has a private HOME, provider configuration and session state, a frozen context, and a process-tree owner. Windows uses an owned Job Object; Unix uses the existing process-group implementation. Completion waits for process exit and descendants before issuing an execution-stopped receipt. Cancellation and timeout use the same boundary. Script nodes run Node.js or Python directly, without invoking a model; their outputs are bounded and schema checked. Ambient account variables are limited to the selected provider's documented channels. Scripts inherit platform/proxy variables and explicit frozen bindings only. + +This is configuration and session isolation, not an operating-system security sandbox. Agent tools and scripts still execute with the runtime account's host permissions. Code should run on an appropriately isolated runtime host. A local CLI PID cannot prove that a remote gateway's worker stopped. Therefore Graph's OpenClaw gateway mode is rejected with instructions to register the existing Multica daemon on that host and select its OpenClaw runtime in local mode. Ordinary OpenClaw gateway tasks remain supported. + +Nested/loop attempts use unique node-run IDs in artifact refs. Existing V1 manifests without an instance key retain their original artifact-ref layout. Graph never merges these refs into the source branch automatically. + +## Provider matrix + +All rows reuse the registered provider's existing execution protocol and have deterministic adapter/protocol tests, environment projection tests, and process cancellation tests. “Native smoke” means the installed vendor executable made an actual model request; it is listed separately from protocol fixtures. + +| Runtime | Graph configuration/auth source | MCP delivery and required native behavior | Live validation | +|---|---|---|---| +| Codex | Private CODEX_HOME; frozen effective model and runtime settings; existing native auth and selected provider env keys | Existing native Codex Graph isolation and scoped MCP config | Existing adapter regression suite; no new paid native smoke in this work | +| Claude Code | Private `.claude`; selective auth/transport settings; explicit frozen model; bare mode, strict MCP, no session persistence | Native strict MCP config; requires flags documented by current CLI | **Claude Code 2.1.278 + DeepSeek `deepseek-v4-flash` passed native Graph adapter smoke**: completed and physical-stop receipt; full Vega workflow tracked separately | +| CodeBuddy | Private config directory; account/transport-only settings.env; explicit custom bindings win | Existing native MCP argument/config | Protocol/config fixtures | +| GitHub Copilot CLI | Private home/cache/providers registry; GitHub environment token or BYOK registry | `--disable-builtin-mcps` and a private `--additional-mcp-config` file | Protocol/config fixtures; opaque browser OAuth config is not copied | +| Cursor | Private config directory and native HTTP1 transport setting; `CURSOR_API_KEY` required | Existing native private project MCP file | Protocol/config fixtures; browser token store is not imported | +| OpenCode / DevEco | Private selected provider config + native API/OAuth auth store; JSONC/inline/file/env references projected; incompatible managed config rejected | Native remote MCP configuration via isolated inline config | Protocol/config fixtures; custom provider credential variable names must be explicitly bound where native projection does not resolve them | +| Hermes | Private selected profile, provider transport and credential stores; no shared memory | ACP handshake must acknowledge required HTTP MCP and frozen settings | Protocol/config fixtures | +| Kimi | Private native model/provider/service TOML and credentials | ACP handshake, explicit model and thinking acknowledgement | Protocol/config fixtures | +| Kiro | Private home; official API key required | ACP handshake must accept required MCP | Protocol/config fixtures; opaque browser/SQLite token store is not copied | +| Qoder / Qoder CN | Private `.qoder` / `.qoder-cn`; PAT and native VPC setting | ACP handshake; frozen model | Protocol/config fixtures; modelConfigs is not treated as transport/auth | +| Qwen Code | Private modelProviders/auth settings and OAuth credential file | Existing native MCP config | Protocol/config fixtures | +| QwenPaw | Private root registry, selected private agent.json/workspace, credential-only provider stores; fallback/routing disabled | ACP with private registry and required MCP acknowledgement | Protocol/config fixtures | +| Grok | Private native auth/transport config | ACP with frozen model and HTTP MCP acknowledgement | Protocol/config fixtures | +| Reasonix | Private providers/credential config and state | ACP with required model/thinking/MCP acknowledgement | Protocol/config fixtures | +| Pi | Private native auth/models, no discovered extensions/skills/context/templates or project approval | Only native built-in MCP is explicitly loaded; frozen servers use direct exposure; native `mcp list` validates readiness before the model starts | Protocol/config fixtures; CLI must expose native MCP commands | +| OMP | Private modern YAML model/config plus active credential-only projection into a new native SQLite store; explicit profile sources supported | Native mcp.json; project MCP discovery disabled; forced positive MCP timeout and required-ready barrier; explicit private config | Protocol/config/real Node SQLite fixtures; installed OMP model invocation not performed | +| DeepSeek Harness | Private credential YAML and agent/session roots | Existing native stdio protocol with scoped MCP | Protocol/config fixtures | +| OpenClaw local | Private agent registry/config/workspace; frozen native agent ID + effective LLM; only auth records imported, never shared sessions | Native private MCP; local execution only | Protocol/config/real SQLite projection fixtures; local installed 2026.2.26 is below required 2026.5.5, so no native smoke | +| Antigravity CLI | Private model-provider settings; native OS account or Gemini API account | Native private MCP schema (`serverUrl`); structured stream must acknowledge the frozen model and terminal status | Protocol/config fixtures; `agy` is not installed on the test host | + +Minimum versions already used by ordinary runtime discovery do **not** imply that a CLI has the newer Graph-specific features. For example, the existing Claude discovery minimum predates `--bare`. OpenClaw has an explicit Graph minimum of 2026.5.5. ACP adapters negotiate actual MCP capabilities and reject unsupported settings; Antigravity requires init/model and structured terminal events; Pi checks for native MCP commands before executing its no-model validation. Other native flags fail at launch if unsupported. OMP credential-store projection, when using native SQLite auth rather than a configured auth broker, requires Node.js 22 with `node:sqlite` and a recognized native schema (3–8); command-generated credentials and automatic model-routing settings are rejected instead of imported. Exact oldest working versions for every external CLI have not been established by this work. Deploy supported current vendor CLIs and run the targeted native smoke before adding an adapter to an installation's production acceptance matrix. + +## Remote MCP revocation and cleanup + +Graph publishes exact skill and Remote MCP contribution entries from the existing plugin system, including release/config/schema pins, tools, failure policy and auth requirements. Migration 358 composes a Graph task's plugin manifest from its engine-owned immutable attempt, not arbitrary task context. Claim verifies its entries match the frozen Graph snapshot. The broker resolves credentials through the existing task authorization endpoint and rechecks live Graph install/release/grant/binding state on every request, including `auth=none`. Cancellation, attempt replacement, release revocation, or grant withdrawal deny subsequent calls. Tokens are not placed in Graph definitions or browser-visible snapshots. Broker lifetimes and provider files follow task cleanup. + +Evidence includes PostgreSQL migration/ordinary-task preservation tests; Graph manifest identity/entry checks; live revocation/attempt fencing tests; and an HTTP proxy test proving that revoked anonymous requests never reach upstream. + +## Verification boundaries + +Portable Go helper executables exercise each adapter's actual stream/ACP interface and descendant cancellation, without depending on vendor accounts or POSIX-only test scripts. They prove Multica's contract handling; they do not prove a vendor's latest binary implements undocumented behavior. Native configuration tests use actual parsers and, where needed, Node's SQLite API, with source credential stores left unchanged. The Claude native smoke used 1,437 input / 9 output tokens and returned `GRAPH_NATIVE_SMOKE_OK`; no production issue or source repository was changed by that smoke. + +The end-to-end acceptance still needs the registered Vega runtime, published Graph, real task completion/approval and isolated artifact ref. A successful unit suite or native one-turn smoke must not be reported as that full workflow. Linux CI is the full repository regression/race gate; Windows exposes pre-existing POSIX-shell fixture limitations that must be classified separately from Graph regressions. + +## Native references + +- [Claude CLI flags](https://code.claude.com/docs/en/cli-reference) +- [Copilot CLI flags](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-command-reference) and [configuration directory](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference) +- [Cursor configuration](https://cursor.com/docs/cli/reference/configuration) +- [CodeBuddy settings](https://www.codebuddy.ai/docs/cli/settings) +- [OpenCode managed settings](https://opencode.ai/docs/config/#managed-settings) +- [Qoder authentication](https://docs.qoder.com/cli/authentication), [custom models](https://docs.qoder.com/cli/custom-models), [Kiro authentication](https://kiro.dev/docs/getting-started/authentication/) +- [Pi CLI](https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/docs/cli.md) and [MCP](https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/docs/mcp.md) +- [OMP CLI](https://omp.sh/docs/cli), [MCP readiness](https://raw.githubusercontent.com/can1357/oh-my-pi/main/docs/mcp-config.md), [credential-store schema](https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/ai/src/auth/sqlite-credential-store.ts) +- [Antigravity headless protocol](https://www.antigravity.google/docs/cli/headless/) and [MCP schema](https://www.antigravity.google/docs/mcp) +- [DeepSeek Harness local credentials](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/credentials/credentials-local/README.md) diff --git a/docs/design/graph-v2-contract.md b/docs/design/graph-v2-contract.md new file mode 100644 index 00000000000..1ae4d652303 --- /dev/null +++ b/docs/design/graph-v2-contract.md @@ -0,0 +1,81 @@ +# Graph V2 wire contract + +V1 retains `schema_version: 1` and its immutable serial semantics. V2 uses `schema_version: 2`. Go wire definitions live in `server/internal/graph/types_v2.go` and `definition.go`. + +## Definition + +Existing `name`, `nodes`, `edges`, `input_schema` remain. New optional fields: + +- `max_concurrency`: integer 1–32; omitted means 4. This is a run-wide limit, including child scopes. +- `output_schema`: object JSON schema and `outputs`: field-to-binding map. Child loop/subgraph definitions must declare their output schema; an empty output object is allowed. +- `layout`: `{nodes: {[node_key]: {x:number,y:number}}, viewport?: {x:number,y:number,zoom:number}}`. Positions are finite and bounded; zoom is 0.1–4. Layout does not affect readiness. + +Each scope has exactly one `start` and one `end`, at most 128 nodes and 256 edges. Topology is an acyclic directed graph; recursion only exists through bounded embedded bodies. The whole definition has at most four child nesting levels and at most 4,096 execution instances at the declared loop bounds. The overall serialized definition limit remains 256 KiB. + +## Nodes and edges + +All existing node fields remain. Each new node has exactly its matching configuration property: + +All node types may declare `label` (at most 200 UTF-8 bytes) for a human-readable display name. `key` remains the stable binding and edge identity. Run node records freeze the optional label alongside that identity; renaming a later draft does not change historical runs. + +| type | Configuration | +| --- | --- | +| `branch` | `condition: Condition`; exactly two outgoing edges, `port: "true"` and `port: "false"` | +| `parallel` | No configuration; at least two outgoing edges | +| `join` | No configuration; at least two incoming edges; waits until every incoming path has resolved and runs if at least one selected path succeeded | +| `loop` | `inputs` for initial child input; `output_schema`; `loop: {body: Definition,max_iterations:1..100,condition:Condition,next:{[field]:InputBinding}}` | +| `subgraph` | `inputs`; `output_schema`; `subgraph: {definition: Definition}` | +| `delay` | `delay: {seconds:1..86400}` | +| `http` | `output_schema`; `http: {method:"GET"|"POST"|"PUT"|"PATCH"|"DELETE"|"HEAD",url:string,headers?:{[header]:InputBinding},body?:InputBinding,timeout_seconds:1..300,max_response_bytes:1..262144}` | +| `script` | `agent_id`; `inputs`; `output_schema`; `execution_mode:"none"`; `max_attempts:1..3`; `timeout_seconds` matching the script timeout; `script: {language:"javascript"|"python",code:string,timeout_seconds:1..3600}` | +| `approval` | `approval: {approver_ids:string[],mode:"all"|"any"|"quorum",quorum?:integer}`; 1–32 unique members; `quorum` only for `mode:"quorum"` | + +Approval may appear at any point, including in child scopes. `approver_id` remains accepted in V2 as a single-member approval, but cannot be combined with `approval`. + +Edges remain `{from:string,to:string}` with optional `port`. Only branch edges use ports; ordinary nodes have one incoming and one outgoing edge. Start has no incoming and one outgoing; end has one incoming and no outgoing. Use explicit parallel/join nodes for fan-out/fan-in. + +Scripts run on the selected existing agent runtime without an LLM, never on the coordinator. HTTP execution must apply network destination restrictions, response bounds, timeouts, and durable effect handling in the service; the pure definition library only validates configuration syntax and bounds. + +## Bindings and conditions + +Existing binding sources remain `run_input`, `node_output`, `constant`, `object`. New source `iteration_index` returns the one-based current iteration and is valid only in loop scopes. Child `run_input` means that child's input, never its parent's input. Parent values must be explicitly bound through `inputs`. + +Reference bindings may set `optional:true` (missing value resolves to null) or `default:` (missing value resolves to that value). A reference to a conditional ancestor must specify either. References must point to a dependency ancestor in the same scope; siblings and future nodes are rejected. Constants and object bindings cannot use `optional` or `default`. + +`Condition` is a typed bounded AST: + +```json +{"op":"gte","left":{"source":"node_output","node_key":"review","path":"/score"},"right":{"source":"constant","value":80}} +``` + +Operators: `eq`, `ne` (same JSON type, exact numbers); `gt`, `gte`, `lt`, `lte` (numbers or strings of the same type); `exists` (left only, checks reference presence); `all`, `any` (1–32 child `conditions`); `not` (one child `conditions`). Operand operators cannot also contain `conditions`. No `eval`, JavaScript expressions, regex execution, or implicit type coercion. Trees are bounded by depth and total predicate count. + +## Loop semantics + +Loops use do-while semantics: iteration 1 runs once with `node.inputs`. At body completion, exports are resolved and validated, then `loop.condition` runs in the completed child's scope (`run_input`, accepted body node outputs, and `iteration_index`). False completes the loop and publishes that iteration's exports. True resolves `loop.next` in the same completed child's scope, validates against the body input schema, and creates a distinct next scope. True at `max_iterations` terminates with an explicit iteration-budget error; it must never silently report success. Nested loops each have their own iteration index. + +Subgraphs run one isolated child scope and publish the child's validated exports. + +## Pure Go helpers and durable state + +- `OrderedNodes` / `TopologicalNodes`: deterministic topological order for V2, serial order for V1. +- `PlanReady(definition, states, selectedPorts) (ReadinessPlan,error)`: returns ordered `ready`, `skipped`, and `blocked` reasons. Branch selections are supplied from persisted decisions, never re-evaluated implicitly. +- `ResolveScopedInputs(bindings, EvaluationContext)` / `ResolveBinding(binding, EvaluationContext)` / `EvaluateCondition(condition, EvaluationContext)`. +- `EvaluationContext`: `RunInput`, accepted `Outputs` indexed by local node key, and one-based `IterationIndex` (zero outside loops). +- Execution storage must use a distinct scope/instance identity. `node_key` remains the local definition key. `NodeSpec.instance_key` identifies the actual nested iteration instance and prevents task/artifact collisions. + +`WalkNodes(definition, visitor)` yields stable definition paths: `nodeKey` at the root, `loopKey/body/childKey` inside a loop, and `subgraphKey/definition/childKey` inside a subgraph. Scoped instance keys consist of slash-separated alphanumeric/underscore/hyphen segments (up to 128 bytes each, 512 bytes total). + +The pure library neither dispatches effects nor mutates durable state. The service records branch decisions, node skips, child scopes, iteration inputs, approvals, and effects transactionally and enforces the global concurrency and instance budgets. + +## Durable service response and controls + +V2 run responses include `schema_version`, `graph_id`, `graph_name`, the immutable `definition`, optional `output` and `finished_at`, and `scopes`. Each scope has `{id,parent_node_run_id?,definition_path,iteration,input,output?,state}`. Nodes additionally expose `{instance_key,scope_id,definition_path,iteration,selected_port?,started_at?,finished_at?,wake_at?}`. `node_key` stays local to its definition; instance IDs distinguish loop iterations. Runtime bindings and publication snapshots use static definition paths from `WalkNodes`. + +`approvals` retains every gate and its history. The compatibility `approval` field points to the first pending gate. Each approval includes `node_run_id`, `approver_ids`, `mode`, optional `quorum`, immutable `candidate` plus `candidate_digest`, and `decisions:[{member_id,user_id,decision,reason?,decided_at}]`. Candidate evidence contains `{version_hash,input_hash,node_run_id,inputs,outputs,artifacts}`. A member votes once per approval ID. A rejection closes the gate only when the remaining possible approvals cannot meet the threshold. Reassignment requires `node_run_id` when gates are ambiguous and `previous_approver_id` for a multi-member roster, creates a new approval ID, resets votes, and records the invalidated gate. + +An HTTP invocation uses the node-run UUID as its fixed `Idempotency-Key`, and a fresh durable claim token per bounded attempt. Dispatch commits before invoking the executor outside the transaction. GET/HEAD may retry at most three times. Ambiguous outcomes for mutating methods become `effect_outcome_unknown`; explicit retry requires a reason and never changes the invocation ID. Expired or replaced claim completions cannot overwrite the current result. Responses expose `{status_code,body}`; every HTTP status is data for branch conditions. Reserved transport headers and `Idempotency-Key` cannot be overridden by the definition. + +Run documents have a 16 MiB budget, with 1 MiB reserved for stop receipts, terminal transitions, and recent events. Payload admission fails with `storage_budget_exhausted`; cancellation and physical stop proof remain writable. The document retains the latest 500 events and latest/accepted submissions; complete events and proposals remain in their independent tables. Each node instance permits at most 100 submitted proposals across its bounded attempts. + +Failure, rejection, and cancellation drain every active physical attempt before the run becomes terminal. An execution with unknown stop state remains visibly blocked and reserves its concurrency slot. Repository input follows accepted dependency ancestors across subgraphs and previous loop iterations; incomparable parallel commits produce `ambiguous_repository_input`, since merging independent code delivery remains outside this version's scope. diff --git a/docs/graph-v1-delivery.md b/docs/graph-v1-delivery.md index 90ae0592068..4d6ccace949 100644 --- a/docs/graph-v1-delivery.md +++ b/docs/graph-v1-delivery.md @@ -1,10 +1,12 @@ # Graph V1 实现与验证说明 +> 历史 V1 记录。当前 V2 范围、使用方式和发布验收见 [Graph V2 使用与发布说明](graph-v2-delivery.md);本文的串行及单执行器限制不代表 V2。 + 日期:2026-10-13。设计依据:[Graph 模块 V1](design/graph-module-v1.md)。本文记录实现范围、兼容性检查和部署前提;代码推送不等于生产发布。 ## 已实现 -- Web/Desktop 侧栏 Graphs:模板、步骤编辑、完整定义 JSON 导入导出、校验、版本发布和归档。 +- Web/Desktop 侧栏 Graphs:模板、AI 草稿生成、串行流程预览与步骤编辑、完整定义 JSON 导入导出、校验、版本发布和归档。 - 固定版本、父 Issue、JSON 输入、运行时绑定和可选仓库 SHA 的串行执行。 - 运行详情、节点尝试、产物、日志、事件、暂停/恢复、有限重试、解阻、取消、人工审批及审批人替换。 - 旧 Issue 执行入口的归属保护、完成后的父 Issue 解绑、工作区删除接入。 @@ -22,6 +24,17 @@ multica attachment upload ./evidence.txt `result.json` 包含 `outcome`、`output`、可选 `artifacts` 和 `diagnostics`,CLI 绑定当前尝试身份。`reason.json` 是非空诊断对象,例如 `{"reason":"缺少仓库访问凭据"}`。上传返回 `id`、`content_hash`、`size`、`media_type`,可直接填写 `attachment_ref`,并设置当前 `producer_task_id`。 +## 在页面创建 Graph + +1. 打开侧栏 Graph,点击创建。描述要完成的工作、步骤要求和验收标准,选择执行智能体及最终审批人,再生成草稿;也可选择手动创建。 +2. 在流程预览中选择步骤,编辑智能体、执行要求和交付方式。步骤标识、重试、超时、输入映射和输出结构位于高级设置。 +3. 保存草稿后进行校验,由 owner/admin 发布版本。AI 生成不会自动保存、发布或启动任务;生成失败保留当前输入和已有草稿。 +4. 在已保存模板的运行入口选择父任务、输入和运行环境,启动固定版本。执行与人工审批规则保持不变。 + +AI 创建复用服务器已有的 LLM 配置。自托管实例在 `.env` 中设置 `MULTICA_LLM_API_KEY`,按需设置 `MULTICA_LLM_BASE_URL` 和 `MULTICA_LLM_DEFAULT_MODEL`,随后重建后端容器使配置生效;Compose 已传递这三项变量。支持无密钥的自托管兼容服务时,可只设置服务地址。未配置时页面保留手动创建,并提示 AI 暂不可用。 + +生成使用服务器配置的模型;页面选择的智能体是草稿中各步骤的执行者,生成后可逐步调整。仅需求文字会发送给生成模型,不发送智能体密钥、Skill 内容或运行时配置。生成接口要求工作区成员的人类身份,并检查所选智能体和审批人的访问权限。模型输出经过结构及 Graph V1 校验,分支、并行和循环仍不支持。 + ## 本次开放的执行范围 仅开放内置 **Codex** 适配器。Agent 必须明确配置 model;不接受自定义运行时 profile、自定义启动参数或非空 `runtime_config`。其他 provider 暂不开放 Graph。 @@ -61,6 +74,8 @@ Linux CI 随后暴露了子任务创建和更换父任务的相关问题:旧 ## 验证 +创建工作台与 AI 起草的补充验证:API/core 相关 212 个测试、界面 16 个专项测试通过;core、views、Web、Desktop renderer 类型检查通过。后端 Graph handler、Graph 定义及 LLM 包测试在隔离 PostgreSQL / 模拟上游条件下通过,Go 全包编译和相关 vet 通过。实际共享组件已在 1440px、390px 及暗色模式预览中检查,覆盖生成、步骤切换、增删、保存、AI 未配置和生成失败;没有页面水平溢出或浏览器脚本错误。这里的预览采用测试数据,未调用真实付费模型,未代表生产部署验收。 + 已通过 Go 全包编译检查、Graph core 全部测试、Graph 及相关 Task/daemon/Codex/CLI 回归、相关包 `go vet`,以及 Web(含 Fumadocs 生成)、Desktop renderer、共享 views 类型检查和前端专项测试。 真实 PostgreSQL 17 的独立 schema 测试逐项应用 Graph 迁移,覆盖并发启动、事务回滚、暂停领取竞态、执行器重绑、迟到回执、重试预算、无效提交、截止审批、历史归属投影、附件删除竞态、工作区清理和插件授权撤销。两次独立审查发现的实际问题均补修复与回归。 diff --git a/docs/graph-v2-delivery.md b/docs/graph-v2-delivery.md new file mode 100644 index 00000000000..b8026c45b23 --- /dev/null +++ b/docs/graph-v2-delivery.md @@ -0,0 +1,91 @@ +# Graph V2 使用与发布说明 + +本文对应 `codex/graph-production` 的实现。发布、设备和真实模型验收结果以文末证据表为准;本地测试通过不代表已经部署。 + +## 功能范围 + +| 能力 | 行为和边界 | +| --- | --- | +| AI 编排 | 从自然语言创建流程,围绕当前定义继续修改;先展示提案,再选择应用或丢弃。使用既有服务端 LLM 配置。 | +| 可视化编辑 | 编辑节点与连线、分支端口、输入映射、输出结构;支持嵌套流程、撤销和草稿恢复。步骤名称与稳定标识分离。 | +| 分支与并行 | 条件支持类型校验;未选择的路径标记跳过;汇合等待所有输入路径有明确结果。并发上限作用于整个运行,包括子流程。 | +| 有界循环 | 至少执行一轮,完成后判断是否继续;每轮有独立输入、执行实例和记录。达到上限仍需继续时明确失败。 | +| 扩展节点 | 人工审批、延迟、HTTP、JavaScript/Python 脚本和嵌入式子流程。脚本使用所选已有运行时,不在后端服务进程中执行。 | +| 人工审批 | 支持全员、任一人、指定票数;票据绑定不可变候选证据。换人后生成新票据,旧票不计入新审批。 | +| 自动触发 | 定时、Webhook、任务事件;持久化事件、去重键和执行租约。重启后定时器补一个错过的时点,避免集中重放大量模型任务。 | +| 运行与恢复 | 输入结构校验、仓库和提交选择、运行时绑定、暂停/继续/取消、失败重试和关联返工运行。 | +| 观察 | 列表搜索、状态过滤、游标分页、耗时/用量统计、实时刷新、节点日志、结果、审批证据及产物。未定价用量明确列为未定价。 | +| 执行器 | 复用项目已有运行时、任务队列、租约、Skills、插件与 Remote MCP。各原生适配器的认证及版本要求见运行时兼容矩阵。 | + +V1 已发布版本继续按原串行语义运行,新的 V2 发布不会改写历史版本或运行快照。 + +本次范围不包含多仓库交付、并行代码产物自动合并、检查点分叉、自动合并 PR/部署和外部系统补偿事务。并行分支产出不可比较的代码提交时,后续代码节点会明确阻塞;不会自行猜测应该合并哪一个提交。 + +## 使用流程 + +1. 在工作区运行时页面确认所需原生 CLI 在线,创建或选择智能体,配置明确模型。Graph 使用这些已有资源。 +2. 打开 Graph,选择 AI 创建或手动创建。描述目标、判断条件、参与智能体和验收方式;查看提案再应用。 +3. 在画布中调整步骤、分支、并行或循环,选择步骤配置输入来源及输出结构。引用分支可能跳过的输出时设置默认值或允许缺失。 +4. 保存草稿并发布。发布固定节点定义、智能体配置、模型、Skills 和插件引用;凭据在执行时按已授权引用解析。 +5. 启动时选择根任务、运行时与输入。如果需要代码交付,选择仓库和具体提交作为起点。 +6. 在运行页查看各实例的状态及结果。人工审批针对页面显示的候选证据投票;需要重新执行整个流程时使用关联返工运行。 +7. 如需自动启动,在 Graph 详情配置触发器。Webhook 密钥只在生成时展示,丢失后轮换;投递记录显示实际启动或拒绝原因。 + +AI 创建与节点执行是两条接入路径:AI 编排使用 `MULTICA_LLM_API_KEY`、`MULTICA_LLM_BASE_URL` 和 `MULTICA_LLM_DEFAULT_MODEL` 所配置的服务端模型;节点执行使用用户选择的现有运行时与模型。未配置服务端 LLM 时会返回明确错误,不会假装生成成功。 + +## 执行和失败语义 + +- 任务提交的结果先暂存,只有进程停止证据、结果结构和交付检查都通过后才会被接受。取消或失败会等待活动进程停止,无法证明停止时保持可见阻塞。 +- HTTP 只允许受限制的 HTTP(S) 目的地址,拒绝本机/内网等受保护目标与重定向,限制请求头、响应大小和时间。外部状态码作为节点输出交给分支处理。 +- HTTP 使用稳定 `Idempotency-Key`。GET/HEAD 最多尝试三次;修改远端状态的请求若结果不明,需要明确理由才能重试。远端服务未实现幂等时,人工重试仍可能重复操作。 +- 草稿、运行、审批和触发器控制带版本或幂等约束,过期界面的操作会被拒绝并要求刷新。 +- 运行定义最多四层子流程、每层 128 节点/256 连线、按循环上界最多 4,096 个执行实例;单次循环最多 100 轮。完整定义上限 256 KiB,运行文档上限 16 MiB,其中为停止与终态保留 1 MiB。 +- 统计中的模型费用沿用现有用量定价,缺少价格的任务不按零费用解释。 + +## 数据库与部署 + +数据库需要升级到 `371_graph_trigger_event_history`。后端部署使用现有迁移入口,自动执行尚未应用的迁移;无需逐个手工运行 SQL。所有 Graph 并发索引均登记了失效索引清理钩子,中断后重试不会将不可用索引当作成功。 + +正常顺序:通过 CI → 发布服务端与前端 → 确认迁移和就绪检查成功 → 更新执行 Graph 的 daemon → 验收后开放触发器。旧 daemon 不能证明 V2 能力时会被阻止领取相应 Graph 任务,普通任务仍使用原有路径。 + +自托管 Compose 已传递服务端 LLM 配置及两项运行控制: + +| 环境变量 | 设置为 `true` 后 | +| --- | --- | +| `MULTICA_GRAPH_DISABLE_NEW_RUNS` | 拒绝新 Graph 运行,暂停触发器派发;已入队事件保留。 | +| `MULTICA_GRAPH_DISABLE_ADVANCEMENT` | 停止派发后续节点,同时保留回执处理和取消恢复。 | + +两项默认均为 `false`,不改变普通任务、聊天和自动化流程。Compose 修改环境变量后需要重建后端容器才能生效。 + +紧急处理先关闭新运行和推进,再取消或排空正在执行的 Graph。优先保留新增数据库结构并修复应用;不要把旧版 coordinator 直接放回仍有 V2 活跃运行的数据库。数据库降级会删除触发器数据,并尝试恢复 V1 的每运行单个待审批约束;存在多门待审批或仍有 V2 活动运行时不可执行。迁移 CLI 的 `down` 会回退其找到的所有已应用迁移,不能直接用于生产的局部回滚。 + +本机限定工作区的验收示例: + +```powershell +multica daemon start --workspace-id 06c89e9a-f311-4324-b5d9-9acd3114228b --no-auto-update --no-auto-reload +``` + +也可使用 `MULTICA_DAEMON_WORKSPACE_IDS` 设置工作区 UUID 白名单。不显式指定时保留原有发现全部可访问工作区的行为。 + +## 验收证据 + +以下为 2026-10-13 当前记录,待发布后补齐最终链接与版本。 + +| 验收 | 结果 | +| --- | --- | +| 真实 PostgreSQL 接口层全套 | 已通过(23.2 秒);涵盖现有普通业务回归与新增 Graph 接口。 | +| 真实 PostgreSQL 服务层全套 | 已通过(15.6 秒);包含分支、并行、循环、停止恢复、审批、触发器并发、插件和 V1 回归。 | +| 全新迁移、重复迁移 | 标准迁移 CLI 执行 1–371 成功,再次执行无变化。 | +| 带旧数据升级 | 独立测试库从 357 升到 371,V1 运行及待审批记录保留,V2 多门待审批索引有效。 | +| 静默库局部回滚与再次升级 | 独立测试库仅回退 358–371,旧待审批记录及唯一索引保留,再次升级成功。未对生产执行回滚。 | +| 并发迁移重试测试 | `go test ./cmd/migrate -count=1` 已通过(8.1 秒)。 | +| Web 定向测试与类型检查 | 当前 74 项 core、21 项 views 测试及类型检查通过。 | +| Web 生产构建 | Next.js webpack 生产构建已通过,包含全部 Graph 路由;使用本机系统 CA 与指定代理完成字体下载。 | +| Web 浏览器交互 | 使用本地测试 API 完成画布、AI 提案、草稿、启动、审批及 375px 宽度交互;不等同真实模型端到端验收。 | +| Windows 全仓 Go | 已执行,存在既有 Unix 脚本、权限位、HOME 和符号链接用例不兼容;不记为通过。 | +| Linux CI / race | 待最终提交后执行。 | +| Go 漏洞扫描 | `govulncheck ./...` 已通过,未发现可达漏洞。 | +| 原生移动端 | 实现与定向验证进行中;本机不能提供 iOS 模拟器验收。 | +| 本机 Claude Code / DeepSeek | Claude Code 2.1.278 使用真实 `deepseek-v4-flash`,经 Graph 私有目录及原生适配器返回指定验收文本,状态完成、进程停止证据为真,用量为输入 1,437 / 输出 9 tokens;未调用工具或修改仓库。私有任务目录已清理。 | +| Vega 生产工作区端到端 | 尚未开始;上面的原生模型调用不替代发布、创建、任务执行、产物与审批的完整验收。 | +| 生产发布 | 尚未发布本次 V2 改动。 | diff --git a/docs/plans/2026-10-13-graph-creation-studio.md b/docs/plans/2026-10-13-graph-creation-studio.md new file mode 100644 index 00000000000..ed2dbade18f --- /dev/null +++ b/docs/plans/2026-10-13-graph-creation-studio.md @@ -0,0 +1,54 @@ +# Graph Creation Studio Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use `executing-plans` to implement this plan task-by-task. + +**Goal:** Make Graph creation consistent with existing product pages and let users generate an editable serial workflow from a natural-language request. + +**Architecture:** Reuse the existing shared creation-page patterns and Graph save/validate/publish APIs. Generate a definition through the existing server LLM client, validate it against Graph V1 and workspace references, and return it without saving or running anything. The shared editor presents a serial flow and selected-step properties; advanced configuration remains available. + +**Tech Stack:** React, TanStack Query, existing Base UI primitives, zod, Go/Chi, existing `llm.Client`. + +--- + +### Task 1: Generation boundary + +Files: `server/internal/handler/graph_generation.go`, its colocated tests, `server/cmd/server/router.go`, `docker-compose.selfhost.yml`. + +1. Add failing tests for workspace access, disabled LLM, malformed model output, fixed executor/approver IDs, and valid generation without persistence. +2. Run the focused handler tests and verify the failure is the missing generation behavior. +3. Implement `GET /api/graphs/generation-options` returning `{enabled}` and `POST /api/graphs/generate` accepting `{prompt, agent_id, approver_id}` and returning `{definition}`. Use bounded structured generation and full V1 validation. Never save, publish or dispatch tasks here. +4. Pass existing LLM configuration into the self-hosted backend container and document setup; introduce no new credentials or migration. +5. Run focused tests with the isolated test database, followed by relevant service regression tests. + +### Task 2: Shared client + +Files: `packages/core/api/client.ts`, `packages/core/graphs/{types,schemas,queries,mutations}.ts`, `packages/core/graphs/generation.test.ts`. + +1. Add failing API boundary tests for snake-case request encoding, parsed response, malformed definitions and workspace-isolated option keys. +2. Run `pnpm --filter @multica/core test graphs/generation.test.ts` and observe missing API failures. +3. Implement parsed generation methods, `graphGenerationOptions(wsId)` and `useGenerateGraph(wsId)` without touching saved Graph caches. +4. Rerun Graph tests and core typecheck. + +### Task 3: Creation and editing workspace + +Files: `packages/views/graphs/graph-editor.tsx`, `graph-detail-page.tsx`, focused new components/tests, `packages/views/locales/{en,zh-Hans,ja,ko}/graphs.json`. + +1. Add failing interaction tests for AI creation, manual creation, local-only generated drafts, failed requests preserving input, and selection/editing without data loss. +2. Build an AI-first entry with example prompts and a manual shortcut, following `agents/create/choose-create-method-page.tsx`. +3. Build a connected serial-step overview plus a selected-step inspector; move technical fields to advanced controls and put save/validate/publish in a persistent action area. +4. Preserve revision/idempotency guards, permission states, unknown-definition read-only behavior and import/export fidelity. +5. Run focused view tests and typechecks; inspect desktop and narrow-width rendering. Keep Graph execution semantics unchanged. + +### Task 4: Delivery review + +1. Review implementation against this scope and then review code quality and regression risks independently. +2. Check loading, disabled AI, errors, overflow, long labels and keyboard access. +3. Report actual verification and any configuration requirement. Do not claim production deployment or live model success from mocked tests. + +## Scope decisions + +- V1 remains serial: start, 1–16 agent steps, final human approval, end. +- Generation uses a user-selected eligible agent and approver; they are never invented by the model. +- Existing server LLM configuration is reused; unconfigured deployments retain manual creation. +- No parallel edges, loops, scheduling, new runtime, schema migrations, or automatic publication. +- Existing unrelated `.codex/` and `openspec/` changes remain untouched. diff --git a/docs/plans/2026-10-13-graph-production.md b/docs/plans/2026-10-13-graph-production.md new file mode 100644 index 00000000000..c1474840732 --- /dev/null +++ b/docs/plans/2026-10-13-graph-production.md @@ -0,0 +1,76 @@ +# Graph Production Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use `executing-plans` to implement this plan task-by-task. + +**Goal:** Complete the Graph product gaps identified in the audit, excluding complex delivery (multi-repository handoff, checkpoint forks, automatic PR merge/deployment and external compensation). + +**Architecture:** Keep the existing Agent, Runtime, Task queue, daemon, plugin and permission infrastructure. Extend versioned graph definitions and durable execution instances for dependency scheduling, branching, parallel joins and bounded nested execution. Shared Web/Desktop views must expose the same validated contract and preserve drafts through the platform storage adapter. + +**Tech Stack:** Go, PostgreSQL, React, TypeScript, TanStack Query, Zustand, existing runtime adapters and plugin brokers. + +## Delivery constitution + +- Success: every acceptance item below has implementation, meaningful tests and review evidence; real provider acceptance is a separate required gate, never substituted with mocks. +- No deadline was supplied. Do not trade correctness or declared scope for a quick completion claim. +- Red lines: preserve ordinary tasks/chats/automation/runtime behavior and existing published v1 graphs; no discarded user edits; no foreign keys; concurrent indexes each get their own migration; no unverified provider enablement or client-only enforcement; no network/script side effects in the coordinator transaction; no fabricated successful checks. +- Weights: correctness and isolation 30; compatibility 20; product usability 20; maintainability 15; verifiability 15. +- Convergence: at most two architecture comparison rounds before choosing the documented contract. A failed verification leads to a concrete repair, never a weaker acceptance criterion. +- Workspace: reuse the current checkout and its unpublished Graph work on `codex/graph-production`. Unrelated `.codex/skills/openspec-*` and `openspec/` edits are outside scope. No full-tree staging. +- Ownership: workers write only explicitly assigned files. Integration, shared wire contracts, migrations and final delivery are supervised. Report changed files, exact tests/results, and remaining risks. + +## Acceptance matrix + +| ID | Required behavior | Status | +| --- | --- | --- | +| G01 | Conditional paths record their decisions; unselected paths are skipped and cannot accidentally execute | implemented; local regression passed; release acceptance pending | +| G02 | Ready branches execute concurrently; joins honor dependency outcomes; failures/cancellation stop and account for active work | implemented; local regression passed; release acceptance pending | +| G03 | Bounded loops have separate iteration instances and outputs, exit conditions and enforced run budgets; retry remains an attempt within an iteration | implemented; local regression passed; release acceptance pending | +| G04 | Canvas supports adding/connecting/moving nodes, zoom/pan, keyboard operation and undo/redo; saves topology and layout | implemented; browser/review refinements in progress | +| G05 | Visual input/output field mapping, schema-driven run input forms and understandable validation errors | implemented; browser/review refinements in progress | +| G06 | AI uses current graph and conversation to propose changes; diff/accept/reject and undo preserve user edits; all supported topology is generatable | implemented; browser/review refinements in progress | +| G07 | Intermediate/multiple approval gates and multi-person approval policies; readable immutable candidate/evidence review | implemented; local regression passed; release acceptance pending | +| G08 | HTTP, script, delay and subflow nodes execute through bounded, authorized infrastructure and recover correctly | implemented; local regression passed; release acceptance pending | +| G09 | Durable schedules, authenticated/deduplicated webhooks and supported task-event triggers; permissions and revoked access are rechecked | implemented; local regression passed; release acceptance pending | +| G10 | Workspace/user scoped draft persistence, refresh recovery, conflict detection and explicit discard | implemented; browser/review refinements in progress | +| G11 | Existing runtime/provider/profile/config capabilities are integrated through the shared runtime system; frozen config, cancellation and submissions remain enforced | verified provider group integrated; remaining native adapters in progress | +| G12 | Plugin Remote MCP reuses the existing task manifest, authorization and credential broker lifecycle | implemented; local regression passed; release acceptance pending | +| G13 | Search/select task, repository and commit; version and runtime eligibility visible before start | implemented; local regression passed; release acceptance pending | +| G14 | Readable run topology, logs, inputs/outputs/artifacts, approval evidence and linked rework; no raw IDs/JSON required for normal operation | implemented; browser/review refinements in progress | +| G15 | Search/filter/pagination and graph/run duration, usage and failure/blocked statistics; realtime events wired end to end | implemented; local regression passed; release acceptance pending | +| G16 | Dedicated mobile Graph access consistent with permissions and server semantics | native interaction/parity plan agreed in authorized scope; implementation next | +| G17 | Regression, migration/concurrency/recovery tests, Web/Desktop/mobile checks and visual/browser acceptance | local backend suites passed; frontend/mobile/CI review in progress | +| G18 | Real model authoring, real runtime execution, test-repository artifact push and approval acceptance; staged release/rollback evidence and runtime/CLI upgrade compatibility | pending | + +## Work packages + +1. **Versioned contract and planner** — `server/internal/graph/definition.go`, new topology/expression/planning files and tests. First add failing branch/join/loop and compatibility cases, implement strict validation and deterministic readiness/expansion, then run the graph package tests. Freeze the wire contract before editor/engine integration. +2. **Runtime and plugin integration** — `server/internal/daemon/graph*.go`, `server/internal/daemon/execenv/`, `server/internal/handler/graph_snapshot.go`, `graph_claim.go`, `server/internal/service/graph_plugin.go`, existing provider/plugin tests. Reproduce rejected compatible runtimes/configs and missing plugin manifests, extend shared adapters, verify isolation, credentials, lifecycle and ordinary-task regression. +3. **Durable engine** — `server/internal/service/graph*.go`, new migrations following the repository's latest number, Graph API handlers. Test readiness, selected edges, joins, iterations, nested scopes, budgets, approvals, restart/cancel races and v1 compatibility against isolated PostgreSQL before integrating new scheduling. +4. **Special nodes and triggers** — new Graph node/trigger service files, existing daemon execution adapters, scheduler/event hooks and API. Use durable intents/outbox and idempotency; execute effects outside database transactions. Test SSRF/permissions/timeouts, webhook authentication/replay, cron/event deduplication and restart recovery. +5. **Shared product UI and AI** — `packages/core/graphs/`, `packages/views/graphs/`, Graph locales, API client and `graph_generation.go`. Compare existing creation, automation, runtime, task/attachment and approval components before building. Implement canvas, mappings, persisted drafts, conversational proposals, run launch/inspection/rework, filtering/statistics and realtime. Test behaviors through actual shared components and browser interactions. +6. **Mobile integration** — follow `apps/mobile/CLAUDE.md`; implement Graph discovery, authoring/control and accessible review using mobile patterns and the same server semantics. Verify offline/error and response compatibility. +7. **Acceptance and delivery** — regression and migration suites; independent specification and quality reviews; desktop/narrow/mobile/browser verification; real provider/test repository acceptance; full CI and documented release with runtime/CLI compatibility. Do not mark pending real acceptance as complete. + +## Verification commands and evidence + +- Go uses `server/tmp/graph-prepush/toolchains/golang.org/toolchain@v0.0.1-go1.26.9.windows-amd64/bin/go.exe`; use ignored D-drive caches and isolated PostgreSQL. +- Core: `node node_modules/vitest/vitest.mjs run --config packages/core/vitest.config.ts `. +- Views: run the installed Vitest entry from `packages/views`, then root TypeScript with the affected package/application configs. +- Standard build/test scripts and Linux CI remain authoritative. Existing Windows-only baseline failures must be distinguished by reproduction, not ignored broadly. +- Each functional slice records the initial failing behavior, passing commands, review findings and fixes in the progress log below. + +## Progress log + +- 2026-10-13: scope locked from the user's explicit request; unpublished AI creation work retained. Test workspace/runtime/repository details requested for real acceptance. Implementation not yet claimed complete. +- User authorized real acceptance in `vega-z85q` (织女星, `06c89e9a-f311-4324-b5d9-9acd3114228b`), using local Claude Code with DeepSeek and the production repository's main baseline. Acceptance uses a frozen main SHA, dedicated tasks and isolated Graph artifact refs; it does not merge test artifacts into main. Read-only preflight confirmed no agents/runtimes in that workspace, the existing CLI is authenticated to the requested server, and the default local daemon is stopped. +- Engine and pure-contract PostgreSQL regressions cover branch skips, parallel joins, bounded nested loop instances, script queue/submission/stop receipts, multiple approval gates/quorum/replacement, durable delay, HTTP ambiguity/leases/late callbacks, state budgets and dependency-ancestor commit selection. Cross-review repaired trigger workspace lock ordering, concurrent dispatch, disable/revocation before root creation, and committed-run recovery after coordinator crashes. +- Root review reproduced then fixed task-event capture crossing workspace deletion (`55P03` NOWAIT fence avoids reverse-lock deadlocks), large run-list execution snapshots, and HTTP JSON-encoding expansion beyond output limits. The new regression cases are green against PostgreSQL and the bounded HTTP executor. Graph trigger tables now participate in workspace deletion. +- Existing GitHub App and workspace GitLab/Forgejo/Gitea connections support ref/commit browsing, pagination and project/workspace repository scope. Tests verify matching credential destinations, redirect rejection, invalid SHAs and unauthorized repository/member reads. API advertises `commits_supported`; statistics explicitly count unpriced tasks. +- Full local handler suite passed with PostgreSQL: 23.220s. Full service suite with Graph PostgreSQL fixtures passed: 15.636s. Pure graph, bundled-plugin signatures, bounded HTTP, VCS and GitHub snapshot suites also passed. Graph CLI regression passed: 0.146s. These results precede the last optional display-label addition; affected checks will rerun. `-race` is not claimed locally because CGO is unavailable; Linux CI remains required. +- Full regression exposed existing Windows issues: signed embedded release payloads changed under Git CRLF conversion, builtin frontmatter assumed LF, portable archive validation accepted Unix absolute paths on Windows, and equal-clock local skill imports lost FIFO order. Fixes preserve exact signed bytes, normalize embedded text checkout, reject both absolute-path conventions, and tie-break imports by enqueue sequence. No signature bypass or test skip was added. +- Actual shared Web components were exercised in a local browser using fixture APIs: AI Apply/Discard, real ReactFlow dragging/connection/undo, nested-loop script editing, refresh draft recovery, launch/approval and one-time webhook secret display. Desktop and 375px checks found no horizontal overflow or page errors. This is UI acceptance only, not real model/runtime acceptance. Screenshots are in ignored `server/tmp/graph-ui-preview/accept-*.png`. +- Native mobile plan follows existing More-menu, native Stack/formSheet and field/list patterns. State, permissions, candidate/revision checks, multi-gate approvals, counts and navigation identity must agree with Web. Native authoring, monitoring and control remain required; no simulator/device verification has been claimed. +- Standard migration runner passed a fresh 1–371 installation and no-op rerun. A separate PostgreSQL database was migrated to 357, seeded with V1 run/approval records, upgraded to 371, checked for unchanged legacy records and valid multi-gate indexes, rolled back only through the new 358–371 files in quiescence, then upgraded again successfully. New registry tests cover invalid concurrent Graph index cleanup, including rollback 361. +- Independent runtime review identified overbroad ambient provider credential inheritance and anonymous Remote MCP revocation checks; runtime worker is adding focused regression tests and corrections. Compose now exposes both existing Graph rollout controls. Full Windows Go execution remains non-green due to documented Unix shell, permissions and HOME assumptions; Linux CI remains required. +- Real native adapter smoke passed on local Claude Code 2.1.278 with DeepSeek `deepseek-v4-flash`, using `execenv.Prepare`, private Graph home and `agent.New` with environment replacement. It returned the exact requested text without tools, `completed`, physical stop confirmed, usage 1,437 input / 9 output tokens. The generated private task directory was removed after retaining non-secret outcome evidence. This verifies actual provider protocol/auth integration only; Vega production E2E and deployment remain pending. +- Final authoring review reproduced AI rejecting disconnected or actor-less local drafts; a bounded draft traversal now checks nested actors without requiring a publishable topology. The model proposal still undergoes complete validation. Focused regression passed. Production Web webpack build passed using the specified proxy and system CA, and `govulncheck ./...` found no reachable vulnerabilities. Backend CI explicitly selects Node 22 for native SQLite credential projection fixtures. diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 00000000000..392946c67c0 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 4e6a0f004cb..6eab5f086f8 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -215,9 +215,46 @@ import { type Logger, noopLogger } from "../logger"; import { createRequestId } from "../utils"; import { getCurrentSlug } from "../platform/workspace-storage"; import { parseWithFallback } from "./schema"; -import { GraphListSchema, GraphRecordSchema, GraphRunListSchema, GraphRunSchema, GraphValidationSchema, GraphVersionSchema } from "../graphs/schemas"; -import { definitionToWire } from "../graphs/definition"; -import type { GraphApprovalRequest, GraphCommand, GraphDefinition, GraphRecord, GraphRun, GraphRunAction, GraphStartRequest, GraphValidation, GraphVersion } from "../graphs/types"; +import { + GraphGenerationOptionsSchema, + GraphGenerationResultSchema, + GraphListSchema, + GraphRecordSchema, + GraphRunListSchema, + GraphRunSchema, + GraphValidationSchema, + GraphVersionSchema, + GraphSearchSchema, + GraphRunSearchSchema, + GraphStartOptionsSchema, + GraphCommitsSchema, +} from "../graphs/schemas"; +import { + GraphTriggerSchema, + GraphTriggerListSchema, + GraphTriggerSecretSchema, + GraphTriggerEventsSchema, +} from "../graphs/schemas"; +import type { GraphTrigger, GraphTriggerWrite } from "../graphs/types"; +import { definitionToWire, importGraphDefinition } from "../graphs/definition"; +import type { + GraphApprovalRequest, + GraphCommand, + GraphDefinition, + GraphGenerationOptions, + GraphGenerationRequest, + GraphGenerationResult, + GraphRecord, + GraphRun, + GraphRunAction, + GraphStartRequest, + GraphValidation, + GraphVersion, + GraphSearchParams, + GraphSearchResult, + GraphRepositoryOption, + GraphCommitOption, +} from "../graphs/types"; import { AgentTaskListSchema, AttachmentResponseSchema, @@ -450,7 +487,12 @@ export class ApiError extends Error { // identifiers instead of pattern-matching the human-readable message. readonly body?: unknown; - constructor(message: string, status: number, statusText: string, body?: unknown) { + constructor( + message: string, + status: number, + statusText: string, + body?: unknown, + ) { super(message); this.name = "ApiError"; this.status = status; @@ -540,9 +582,7 @@ function subscriberTarget( * explicitly, or a concurrent writer of that singleton silently redirects the * write to the wrong workspace. */ -function workspaceHeader( - slug?: string, -): Record | undefined { +function workspaceHeader(slug?: string): Record | undefined { return slug ? { "X-Workspace-Slug": slug } : undefined; } @@ -571,7 +611,7 @@ export class ApiClient { const match = document.cookie .split("; ") .find((c) => c.startsWith("multica_csrf=")); - return match ? match.split("=")[1] ?? null : null; + return match ? (match.split("=")[1] ?? null) : null; } private authHeaders(): Record { @@ -597,9 +637,12 @@ export class ApiClient { this.options.onUnauthorized?.(); } - private async parseErrorMessage(res: Response, fallback: string): Promise { + private async parseErrorMessage( + res: Response, + fallback: string, + ): Promise { try { - const data = await res.json() as { error?: string }; + const data = (await res.json()) as { error?: string }; if (typeof data.error === "string" && data.error) return data.error; } catch { // Ignore non-JSON error bodies. @@ -610,10 +653,14 @@ export class ApiClient { // Reads the response body once for both human-readable error message and // structured fields. The Response stream can only be consumed once, so // both pieces have to come from a single read. - private async parseErrorBody(res: Response, fallback: string): Promise<{ message: string; body: unknown }> { + private async parseErrorBody( + res: Response, + fallback: string, + ): Promise<{ message: string; body: unknown }> { try { - const data = await res.json() as { error?: string }; - const message = typeof data.error === "string" && data.error ? data.error : fallback; + const data = (await res.json()) as { error?: string }; + const message = + typeof data.error === "string" && data.error ? data.error : fallback; return { message, body: data }; } catch { return { message: fallback, body: undefined }; @@ -650,13 +697,23 @@ export class ApiClient { if (!res.ok) { if (res.status === 401) this.handleUnauthorized(); - const { message, body } = await this.parseErrorBody(res, `API error: ${res.status} ${res.statusText}`); + const { message, body } = await this.parseErrorBody( + res, + `API error: ${res.status} ${res.statusText}`, + ); const logLevel = res.status === 404 ? "warn" : "error"; - this.logger[logLevel](`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message }); + this.logger[logLevel](`← ${res.status} ${path}`, { + rid, + duration: `${Date.now() - start}ms`, + error: message, + }); throw new ApiError(message, res.status, res.statusText, body); } - this.logger.info(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` }); + this.logger.info(`← ${res.status} ${path}`, { + rid, + duration: `${Date.now() - start}ms`, + }); return res; } @@ -673,92 +730,524 @@ export class ApiClient { } // Graphs + async listGraphTriggerEvents( + triggerId: string, + ): Promise<{ + items: { + id: string; + state: string; + runId?: string; + error?: string; + createdAt: string; + updatedAt: string; + }[]; + }> { + return parseWithFallback( + await this.fetch( + `/api/graph-triggers/${encodeURIComponent(triggerId)}/events`, + ), + GraphTriggerEventsSchema, + { items: [] }, + { endpoint: "GET /api/graph-triggers/:id/events" }, + ); + } + async listGraphTriggers(graphId: string): Promise<{ items: GraphTrigger[] }> { + return parseWithFallback( + await this.fetch( + `/api/graphs/${encodeURIComponent(graphId)}/triggers`, + ), + GraphTriggerListSchema, + { items: [] }, + { endpoint: "GET /api/graphs/:id/triggers" }, + ); + } + async saveGraphTrigger( + graphId: string, + request: GraphTriggerWrite, + triggerId?: string, + ): Promise { + const body = { + name: request.name, + kind: request.kind, + version_id: request.versionId, + enabled: request.enabled, + expected_revision: request.expectedRevision, + idempotency_key: request.idempotencyKey, + config: { + cron: request.config.cron, + timezone: request.config.timezone, + project_id: request.config.projectId, + statuses: request.config.statuses, + events: request.config.events, + root_title: request.config.rootTitle, + root_description: request.config.rootDescription, + }, + input: request.input, + runtime_bindings: request.runtimeBindings, + repository: request.repository + ? { + repository_id: request.repository.repositoryId, + base_sha: request.repository.baseSha, + commit_sha: request.repository.commitSha, + artifact_id: request.repository.artifactId, + } + : undefined, + }; + const raw = await this.fetch( + triggerId + ? `/api/graph-triggers/${encodeURIComponent(triggerId)}` + : `/api/graphs/${encodeURIComponent(graphId)}/triggers`, + { method: triggerId ? "PUT" : "POST", body: JSON.stringify(body) }, + ); + const result = parseWithFallback( + raw, + GraphTriggerSchema, + null, + { endpoint: "SAVE /api/graph-triggers" }, + ); + if (!result) + throw new ApiError( + "Invalid trigger response. Refresh before trying again.", + 502, + "Invalid response", + ); + return result; + } + async deleteGraphTrigger( + triggerId: string, + command: GraphCommand, + ): Promise { + await this.fetch( + `/api/graph-triggers/${encodeURIComponent(triggerId)}`, + { + method: "DELETE", + body: JSON.stringify({ + expected_revision: command.expectedRevision, + idempotency_key: command.idempotencyKey, + }), + }, + ); + } + async rotateGraphTriggerSecret( + triggerId: string, + command: GraphCommand, + ): Promise<{ secret: string; webhookUrl: string }> { + const result = parseWithFallback( + await this.fetch( + `/api/graph-triggers/${encodeURIComponent(triggerId)}/rotate-secret`, + { + method: "POST", + body: JSON.stringify({ + expected_revision: command.expectedRevision, + idempotency_key: command.idempotencyKey, + }), + }, + ), + GraphTriggerSecretSchema, + null, + { endpoint: "POST /api/graph-triggers/:id/rotate-secret" }, + ); + if (!result) + throw new ApiError( + "Invalid webhook secret response.", + 502, + "Invalid response", + ); + return result; + } + async searchGraphs( + params: GraphSearchParams = {}, + ): Promise> { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) + if (value !== undefined && value !== "") + query.set(key === "graphId" ? "graph_id" : key, String(value)); + return parseWithFallback( + await this.fetch(`/api/graphs/search?${query}`), + GraphSearchSchema, + { items: [], total: 0 }, + { endpoint: "GET /api/graphs/search" }, + ); + } + async searchGraphRuns( + params: GraphSearchParams = {}, + ): Promise> { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) + if (value !== undefined && value !== "") + query.set(key === "graphId" ? "graph_id" : key, String(value)); + return parseWithFallback( + await this.fetch(`/api/graph-runs/search?${query}`), + GraphRunSearchSchema, + { items: [], total: 0 }, + { endpoint: "GET /api/graph-runs/search" }, + ); + } + async graphStartOptions( + rootIssueId?: string, + ): Promise<{ repositories: GraphRepositoryOption[] }> { + return parseWithFallback( + await this.fetch( + `/api/graphs/start-options${rootIssueId ? `?root_issue_id=${encodeURIComponent(rootIssueId)}` : ""}`, + ), + GraphStartOptionsSchema, + { repositories: [] }, + { endpoint: "GET /api/graphs/start-options" }, + ); + } + async graphRepositoryCommits( + repositoryId: string, + params: { q?: string; ref?: string; cursor?: string } = {}, + ): Promise<{ commits: GraphCommitOption[]; nextCursor?: string }> { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) + if (value) query.set(key, value); + return parseWithFallback( + await this.fetch( + `/api/graphs/repositories/${encodeURIComponent(repositoryId)}/commits?${query}`, + ), + GraphCommitsSchema, + { commits: [] }, + { endpoint: "GET /api/graphs/repositories/:id/commits" }, + ); + } + async getGraphGenerationOptions(): Promise { + return parseWithFallback( + await this.fetch("/api/graphs/generation-options"), + GraphGenerationOptionsSchema, + { enabled: false }, + { endpoint: "GET /api/graphs/generation-options" }, + ); + } + + async generateGraph( + request: GraphGenerationRequest, + ): Promise { + const raw = await this.fetch("/api/graphs/generate", { + method: "POST", + body: JSON.stringify({ + prompt: request.prompt, + agent_id: request.agentId, + approver_id: request.approverId, + ...(request.currentDefinition + ? { current_definition: definitionToWire(request.currentDefinition) } + : {}), + ...(request.messages ? { messages: request.messages } : {}), + }), + }); + const result = parseWithFallback( + raw, + GraphGenerationResultSchema, + null, + { endpoint: "POST /api/graphs/generate" }, + ); + if (!result || !raw || typeof raw !== "object" || !("definition" in raw)) + throw new ApiError( + "Invalid Graph generation response. Your draft has not changed.", + 502, + "Invalid response", + ); + // Generated drafts must belong to the editor's supported schema versions. + // A newer backend must not silently replace a draft with an uneditable graph. + try { + return { + definition: importGraphDefinition(JSON.stringify(raw.definition)), + ...(result.summary ? { summary: result.summary } : {}), + }; + } catch { + throw new ApiError( + "Invalid Graph generation response. Your draft has not changed.", + 502, + "Invalid response", + ); + } + } + async listGraphs(): Promise { - return parseWithFallback(await this.fetch("/api/graphs"), GraphListSchema, [] as GraphRecord[], { endpoint: "GET /api/graphs" }); + return parseWithFallback( + await this.fetch("/api/graphs"), + GraphListSchema, + [] as GraphRecord[], + { endpoint: "GET /api/graphs" }, + ); } async getGraph(id: string): Promise { - return parseWithFallback(await this.fetch(`/api/graphs/${encodeURIComponent(id)}`), GraphRecordSchema, null, { endpoint: "GET /api/graphs/:id" }); + return parseWithFallback( + await this.fetch(`/api/graphs/${encodeURIComponent(id)}`), + GraphRecordSchema, + null, + { endpoint: "GET /api/graphs/:id" }, + ); } - async saveGraph(definition: GraphDefinition, command?: { id: string; expectedRevision: number }, idempotencyKey?: string): Promise { - const raw = await this.fetch(command ? `/api/graphs/${encodeURIComponent(command.id)}` : "/api/graphs", { - method: command ? "PUT" : "POST", - body: JSON.stringify({ definition: definitionToWire(definition), expected_revision: command?.expectedRevision, idempotency_key: idempotencyKey }), - }); - const result = parseWithFallback(raw, GraphRecordSchema, null, { endpoint: "SAVE /api/graphs" }); - if (!result) throw new ApiError("Invalid Graph response. Refresh before trying again.", 502, "Invalid response"); + async saveGraph( + definition: GraphDefinition, + command?: { id: string; expectedRevision: number }, + idempotencyKey?: string, + ): Promise { + const raw = await this.fetch( + command ? `/api/graphs/${encodeURIComponent(command.id)}` : "/api/graphs", + { + method: command ? "PUT" : "POST", + body: JSON.stringify({ + definition: definitionToWire(definition), + expected_revision: command?.expectedRevision, + idempotency_key: idempotencyKey, + }), + }, + ); + const result = parseWithFallback( + raw, + GraphRecordSchema, + null, + { endpoint: "SAVE /api/graphs" }, + ); + if (!result) + throw new ApiError( + "Invalid Graph response. Refresh before trying again.", + 502, + "Invalid response", + ); return result; } async validateGraph(id: string): Promise { - const raw = await this.fetch(`/api/graphs/${encodeURIComponent(id)}/validate`, { method: "POST", body: "{}" }); - return parseWithFallback(raw, GraphValidationSchema, { valid: false, diagnostics: [{ code: "invalid_response", message: "Unable to read validation response." }] }, { endpoint: "POST /api/graphs/:id/validate" }); + const raw = await this.fetch( + `/api/graphs/${encodeURIComponent(id)}/validate`, + { method: "POST", body: "{}" }, + ); + return parseWithFallback( + raw, + GraphValidationSchema, + { + valid: false, + diagnostics: [ + { + code: "invalid_response", + message: "Unable to read validation response.", + }, + ], + }, + { endpoint: "POST /api/graphs/:id/validate" }, + ); } async publishGraph(id: string, command: GraphCommand): Promise { - const raw = await this.fetch(`/api/graphs/${encodeURIComponent(id)}/publish`, { - method: "POST", body: JSON.stringify({ expected_revision: command.expectedRevision, idempotency_key: command.idempotencyKey }), - }); - const result = parseWithFallback(raw, GraphVersionSchema, null, { endpoint: "POST /api/graphs/:id/publish" }); - if (!result) throw new ApiError("Invalid publication response. Refresh before trying again.", 502, "Invalid response"); + const raw = await this.fetch( + `/api/graphs/${encodeURIComponent(id)}/publish`, + { + method: "POST", + body: JSON.stringify({ + expected_revision: command.expectedRevision, + idempotency_key: command.idempotencyKey, + }), + }, + ); + const result = parseWithFallback( + raw, + GraphVersionSchema, + null, + { endpoint: "POST /api/graphs/:id/publish" }, + ); + if (!result) + throw new ApiError( + "Invalid publication response. Refresh before trying again.", + 502, + "Invalid response", + ); return result; } async listGraphRuns(): Promise { - return parseWithFallback(await this.fetch("/api/graph-runs"), GraphRunListSchema, [] as GraphRun[], { endpoint: "GET /api/graph-runs" }); + return parseWithFallback( + await this.fetch("/api/graph-runs"), + GraphRunListSchema, + [] as GraphRun[], + { endpoint: "GET /api/graph-runs" }, + ); } async getGraphRun(id: string): Promise { - return parseWithFallback(await this.fetch(`/api/graph-runs/${encodeURIComponent(id)}`), GraphRunSchema, null, { endpoint: "GET /api/graph-runs/:id" }); + return parseWithFallback( + await this.fetch(`/api/graph-runs/${encodeURIComponent(id)}`), + GraphRunSchema, + null, + { endpoint: "GET /api/graph-runs/:id" }, + ); } async startGraphRun(request: GraphStartRequest): Promise { - const raw = await this.fetch("/api/graph-runs", { method: "POST", body: JSON.stringify({ - version_id: request.versionId, root_issue_id: request.rootIssueId, input: request.input, - runtime_bindings: request.runtimeBindings, deadline: request.deadline, idempotency_key: request.idempotencyKey, - repository: request.repository ? { repository_id: request.repository.repositoryId, base_sha: request.repository.baseSha, commit_sha: request.repository.commitSha, artifact_id: request.repository.artifactId } : undefined, - }) }); - const result = parseWithFallback(raw, GraphRunSchema, null, { endpoint: "POST /api/graph-runs" }); - if (!result) throw new ApiError("Invalid run response. Refresh before trying again.", 502, "Invalid response"); + const raw = await this.fetch("/api/graph-runs", { + method: "POST", + body: JSON.stringify({ + version_id: request.versionId, + root_issue_id: request.rootIssueId, + input: request.input, + runtime_bindings: request.runtimeBindings, + deadline: request.deadline, + idempotency_key: request.idempotencyKey, + previous_run_id: request.previousRunId, + repository: request.repository + ? { + repository_id: request.repository.repositoryId, + base_sha: request.repository.baseSha, + commit_sha: request.repository.commitSha, + artifact_id: request.repository.artifactId, + } + : undefined, + }), + }); + const result = parseWithFallback( + raw, + GraphRunSchema, + null, + { endpoint: "POST /api/graph-runs" }, + ); + if (!result) + throw new ApiError( + "Invalid run response. Refresh before trying again.", + 502, + "Invalid response", + ); return result; } - async controlGraphRun(id: string, action: GraphRunAction, command: GraphCommand): Promise { - const raw = await this.fetch(`/api/graph-runs/${encodeURIComponent(id)}/${action}`, { method: "POST", body: JSON.stringify({ - expected_revision: command.expectedRevision, idempotency_key: command.idempotencyKey, node_run_id: command.nodeRunId, reason: command.reason, - }) }); - const result = parseWithFallback(raw, GraphRunSchema, null, { endpoint: `POST /api/graph-runs/:id/${action}` }); - if (!result) throw new ApiError("Invalid run response. Refresh before trying again.", 502, "Invalid response"); + async controlGraphRun( + id: string, + action: GraphRunAction, + command: GraphCommand, + ): Promise { + const raw = await this.fetch( + `/api/graph-runs/${encodeURIComponent(id)}/${action}`, + { + method: "POST", + body: JSON.stringify({ + expected_revision: command.expectedRevision, + idempotency_key: command.idempotencyKey, + node_run_id: command.nodeRunId, + reason: command.reason, + }), + }, + ); + const result = parseWithFallback( + raw, + GraphRunSchema, + null, + { endpoint: `POST /api/graph-runs/:id/${action}` }, + ); + if (!result) + throw new ApiError( + "Invalid run response. Refresh before trying again.", + 502, + "Invalid response", + ); return result; } - async approveGraphRun(id: string, request: GraphApprovalRequest): Promise { - const raw = await this.fetch(`/api/graph-runs/${encodeURIComponent(id)}/approval`, { method: "POST", body: JSON.stringify({ - expected_revision: request.expectedRevision, idempotency_key: request.idempotencyKey, - approval_id: request.approvalId, candidate_digest: request.candidateDigest, decision: request.decision, reason: request.reason, - }) }); - const result = parseWithFallback(raw, GraphRunSchema, null, { endpoint: "POST /api/graph-runs/:id/approval" }); - if (!result) throw new ApiError("Invalid approval response. Refresh before trying again.", 502, "Invalid response"); + async approveGraphRun( + id: string, + request: GraphApprovalRequest, + ): Promise { + const raw = await this.fetch( + `/api/graph-runs/${encodeURIComponent(id)}/approval`, + { + method: "POST", + body: JSON.stringify({ + expected_revision: request.expectedRevision, + idempotency_key: request.idempotencyKey, + approval_id: request.approvalId, + candidate_digest: request.candidateDigest, + decision: request.decision, + reason: request.reason, + }), + }, + ); + const result = parseWithFallback( + raw, + GraphRunSchema, + null, + { endpoint: "POST /api/graph-runs/:id/approval" }, + ); + if (!result) + throw new ApiError( + "Invalid approval response. Refresh before trying again.", + 502, + "Invalid response", + ); return result; } async archiveGraph(id: string, command: GraphCommand): Promise { - const raw = await this.fetch(`/api/graphs/${encodeURIComponent(id)}/archive`, { method: "POST", body: JSON.stringify({ expected_revision: command.expectedRevision, idempotency_key: command.idempotencyKey }) }); - const result = parseWithFallback(raw, GraphRecordSchema, null, { endpoint: "POST /api/graphs/:id/archive" }); - if (!result) throw new ApiError("Invalid Graph response. Refresh before trying again.", 502, "Invalid response"); + const raw = await this.fetch( + `/api/graphs/${encodeURIComponent(id)}/archive`, + { + method: "POST", + body: JSON.stringify({ + expected_revision: command.expectedRevision, + idempotency_key: command.idempotencyKey, + }), + }, + ); + const result = parseWithFallback( + raw, + GraphRecordSchema, + null, + { endpoint: "POST /api/graphs/:id/archive" }, + ); + if (!result) + throw new ApiError( + "Invalid Graph response. Refresh before trying again.", + 502, + "Invalid response", + ); return result; } - async replaceGraphApprover(id: string, command: GraphCommand & { approverId: string }): Promise { - const raw = await this.fetch(`/api/graph-runs/${encodeURIComponent(id)}/approver`, { method: "POST", body: JSON.stringify({ expected_revision: command.expectedRevision, idempotency_key: command.idempotencyKey, approver_id: command.approverId, reason: command.reason }) }); - const result = parseWithFallback(raw, GraphRunSchema, null, { endpoint: "POST /api/graph-runs/:id/approver" }); - if (!result) throw new ApiError("Invalid run response. Refresh before trying again.", 502, "Invalid response"); + async replaceGraphApprover( + id: string, + command: GraphCommand & { approverId: string; previousApproverId?: string }, + ): Promise { + const raw = await this.fetch( + `/api/graph-runs/${encodeURIComponent(id)}/approver`, + { + method: "POST", + body: JSON.stringify({ + expected_revision: command.expectedRevision, + idempotency_key: command.idempotencyKey, + approver_id: command.approverId, + node_run_id: command.nodeRunId, + previous_approver_id: command.previousApproverId, + reason: command.reason, + }), + }, + ); + const result = parseWithFallback( + raw, + GraphRunSchema, + null, + { endpoint: "POST /api/graph-runs/:id/approver" }, + ); + if (!result) + throw new ApiError( + "Invalid run response. Refresh before trying again.", + 502, + "Invalid response", + ); return result; } - async detachGraphIssue(issueId: string, idempotencyKey: string): Promise { - await this.fetch(`/api/issues/${encodeURIComponent(issueId)}/graph-detach`, { method: "POST", body: JSON.stringify({ idempotency_key: idempotencyKey }) }); + async detachGraphIssue( + issueId: string, + idempotencyKey: string, + ): Promise { + await this.fetch( + `/api/issues/${encodeURIComponent(issueId)}/graph-detach`, + { + method: "POST", + body: JSON.stringify({ idempotency_key: idempotencyKey }), + }, + ); } // Auth @@ -854,30 +1343,43 @@ export class ApiClient { if (params?.workspace_id) search.set("workspace_id", params.workspace_id); if (params?.q?.trim()) search.set("q", params.q.trim()); if (params?.status) search.set("status", params.status); - if (params?.statuses?.length) search.set("statuses", params.statuses.join(",")); + if (params?.statuses?.length) + search.set("statuses", params.statuses.join(",")); if (params?.priority) search.set("priority", params.priority); - if (params?.priorities?.length) search.set("priorities", params.priorities.join(",")); + if (params?.priorities?.length) + search.set("priorities", params.priorities.join(",")); if (params?.assignee_id) search.set("assignee_id", params.assignee_id); - if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(",")); - if (params?.assignee_types?.length) search.set("assignee_types", params.assignee_types.join(",")); + if (params?.assignee_ids?.length) + search.set("assignee_ids", params.assignee_ids.join(",")); + if (params?.assignee_types?.length) + search.set("assignee_types", params.assignee_types.join(",")); if (params?.creator_id) search.set("creator_id", params.creator_id); if (params?.project_id) search.set("project_id", params.project_id); if (params?.assignee_filters?.length) { - search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(",")); + search.set( + "assignee_filters", + params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(","), + ); } if (params?.include_no_assignee) search.set("include_no_assignee", "true"); if (params?.creator_filters?.length) { - search.set("creator_filters", params.creator_filters.map((f) => `${f.type}:${f.id}`).join(",")); + search.set( + "creator_filters", + params.creator_filters.map((f) => `${f.type}:${f.id}`).join(","), + ); } - if (params?.project_ids?.length) search.set("project_ids", params.project_ids.join(",")); + if (params?.project_ids?.length) + search.set("project_ids", params.project_ids.join(",")); if (params?.include_no_project) search.set("include_no_project", "true"); - if (params?.label_ids?.length) search.set("label_ids", params.label_ids.join(",")); + if (params?.label_ids?.length) + search.set("label_ids", params.label_ids.join(",")); if (params?.top_level_only) search.set("top_level_only", "true"); // No `.length` guard on purpose: an empty ids array must still send // `ids=` — the server treats a PRESENT-but-empty list as an empty window // (nothing running), while an absent param means no restriction. if (params?.ids) search.set("ids", params.ids.join(",")); - if (params?.involves_user_id) search.set("involves_user_id", params.involves_user_id); + if (params?.involves_user_id) + search.set("involves_user_id", params.involves_user_id); if (params?.metadata && Object.keys(params.metadata).length > 0) { search.set("metadata", JSON.stringify(params.metadata)); } @@ -900,30 +1402,47 @@ export class ApiClient { method: "POST", body: JSON.stringify(Object.fromEntries(search)), }); - return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, { - endpoint: "POST /api/issues/query", - }); + return parseWithFallback( + raw, + ListIssuesResponseSchema, + EMPTY_LIST_ISSUES_RESPONSE, + { + endpoint: "POST /api/issues/query", + }, + ); } const path = `/api/issues?${search}`; const raw = await this.fetch(path); - return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, { - endpoint: "GET /api/issues", - }); + return parseWithFallback( + raw, + ListIssuesResponseSchema, + EMPTY_LIST_ISSUES_RESPONSE, + { + endpoint: "GET /api/issues", + }, + ); } - async listGroupedIssues(params: ListGroupedIssuesParams): Promise { + async listGroupedIssues( + params: ListGroupedIssuesParams, + ): Promise { const search = new URLSearchParams({ group_by: params.group_by }); if (params.limit) search.set("limit", String(params.limit)); if (params.offset) search.set("offset", String(params.offset)); if (params.workspace_id) search.set("workspace_id", params.workspace_id); - if (params.statuses?.length) search.set("statuses", params.statuses.join(",")); - if (params.priorities?.length) search.set("priorities", params.priorities.join(",")); - if (params.assignee_types?.length) search.set("assignee_types", params.assignee_types.join(",")); + if (params.statuses?.length) + search.set("statuses", params.statuses.join(",")); + if (params.priorities?.length) + search.set("priorities", params.priorities.join(",")); + if (params.assignee_types?.length) + search.set("assignee_types", params.assignee_types.join(",")); if (params.assignee_id) search.set("assignee_id", params.assignee_id); - if (params.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(",")); + if (params.assignee_ids?.length) + search.set("assignee_ids", params.assignee_ids.join(",")); if (params.creator_id) search.set("creator_id", params.creator_id); if (params.project_id) search.set("project_id", params.project_id); - if (params.involves_user_id) search.set("involves_user_id", params.involves_user_id); + if (params.involves_user_id) + search.set("involves_user_id", params.involves_user_id); if (params.metadata && Object.keys(params.metadata).length > 0) { search.set("metadata", JSON.stringify(params.metadata)); } @@ -931,29 +1450,46 @@ export class ApiClient { search.set("properties", JSON.stringify(params.properties)); } if (params.assignee_filters?.length) { - search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(",")); + search.set( + "assignee_filters", + params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(","), + ); } if (params.include_no_assignee) search.set("include_no_assignee", "true"); if (params.creator_filters?.length) { - search.set("creator_filters", params.creator_filters.map((f) => `${f.type}:${f.id}`).join(",")); + search.set( + "creator_filters", + params.creator_filters.map((f) => `${f.type}:${f.id}`).join(","), + ); } - if (params.project_ids?.length) search.set("project_ids", params.project_ids.join(",")); + if (params.project_ids?.length) + search.set("project_ids", params.project_ids.join(",")); if (params.include_no_project) search.set("include_no_project", "true"); - if (params.label_ids?.length) search.set("label_ids", params.label_ids.join(",")); - if (params.group_assignee_type) search.set("group_assignee_type", params.group_assignee_type); - if (params.group_assignee_id) search.set("group_assignee_id", params.group_assignee_id); + if (params.label_ids?.length) + search.set("label_ids", params.label_ids.join(",")); + if (params.group_assignee_type) + search.set("group_assignee_type", params.group_assignee_type); + if (params.group_assignee_id) + search.set("group_assignee_id", params.group_assignee_id); if (params.date_field) search.set("date_field", params.date_field); if (params.date_start) search.set("date_start", params.date_start); if (params.date_end) search.set("date_end", params.date_end); if (params.sort_by) search.set("sort", params.sort_by); if (params.sort_direction) search.set("direction", params.sort_direction); const raw = await this.fetch(`/api/issues/grouped?${search}`); - return parseWithFallback(raw, GroupedIssuesResponseSchema, EMPTY_GROUPED_ISSUES_RESPONSE, { - endpoint: "GET /api/issues/grouped", - }); + return parseWithFallback( + raw, + GroupedIssuesResponseSchema, + EMPTY_GROUPED_ISSUES_RESPONSE, + { + endpoint: "GET /api/issues/grouped", + }, + ); } - async listIssueTableGroups(params: IssueTableGroupsRequest): Promise { + async listIssueTableGroups( + params: IssueTableGroupsRequest, + ): Promise { const raw = await this.fetch("/api/issues/table/groups", { method: "POST", body: JSON.stringify(params), @@ -966,7 +1502,9 @@ export class ApiClient { ); } - async listIssueTableRows(params: IssueTableRowsRequest): Promise { + async listIssueTableRows( + params: IssueTableRowsRequest, + ): Promise { const raw = await this.fetch("/api/issues/table/rows", { method: "POST", body: JSON.stringify(params), @@ -979,7 +1517,9 @@ export class ApiClient { ); } - async listIssueTableFacets(params: IssueTableFacetsRequest): Promise { + async listIssueTableFacets( + params: IssueTableFacetsRequest, + ): Promise { const raw = await this.fetch("/api/issues/table/facets", { method: "POST", body: JSON.stringify(params), @@ -992,32 +1532,56 @@ export class ApiClient { ); } - async searchIssues(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise { + async searchIssues(params: { + q: string; + limit?: number; + offset?: number; + include_closed?: boolean; + signal?: AbortSignal; + }): Promise { const search = new URLSearchParams({ q: params.q }); if (params.limit !== undefined) search.set("limit", String(params.limit)); - if (params.offset !== undefined) search.set("offset", String(params.offset)); + if (params.offset !== undefined) + search.set("offset", String(params.offset)); if (params.include_closed) search.set("include_closed", "true"); const raw = await this.fetch( `/api/issues/search?${search}`, params.signal ? { signal: params.signal } : undefined, ); - return parseWithFallback(raw, SearchIssuesResponseSchema, EMPTY_SEARCH_ISSUES_RESPONSE, { - endpoint: "GET /api/issues/search", - }); + return parseWithFallback( + raw, + SearchIssuesResponseSchema, + EMPTY_SEARCH_ISSUES_RESPONSE, + { + endpoint: "GET /api/issues/search", + }, + ); } - async searchProjects(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise { + async searchProjects(params: { + q: string; + limit?: number; + offset?: number; + include_closed?: boolean; + signal?: AbortSignal; + }): Promise { const search = new URLSearchParams({ q: params.q }); if (params.limit !== undefined) search.set("limit", String(params.limit)); - if (params.offset !== undefined) search.set("offset", String(params.offset)); + if (params.offset !== undefined) + search.set("offset", String(params.offset)); if (params.include_closed) search.set("include_closed", "true"); const raw = await this.fetch( `/api/projects/search?${search}`, params.signal ? { signal: params.signal } : undefined, ); - return parseWithFallback(raw, SearchProjectsResponseSchema, EMPTY_SEARCH_PROJECTS_RESPONSE, { - endpoint: "GET /api/projects/search", - }); + return parseWithFallback( + raw, + SearchProjectsResponseSchema, + EMPTY_SEARCH_PROJECTS_RESPONSE, + { + endpoint: "GET /api/projects/search", + }, + ); } /** @@ -1037,7 +1601,10 @@ export class ApiClient { * an ApiError 404, so `issueIdentifierOptions` propagates it instead of * caching it as "no such issue". */ - async getIssue(id: string, options?: { signal?: AbortSignal }): Promise { + async getIssue( + id: string, + options?: { signal?: AbortSignal }, + ): Promise { const raw = await this.fetch( `/api/issues/${encodeURIComponent(id)}`, options?.signal ? { signal: options.signal } : undefined, @@ -1066,9 +1633,14 @@ export class ApiClient { method: "POST", body: JSON.stringify(data), }); - const issue = parseWithFallback(raw, CreateIssueResponseSchema, null, { - endpoint: "POST /api/issues", - }); + const issue = parseWithFallback( + raw, + CreateIssueResponseSchema, + null, + { + endpoint: "POST /api/issues", + }, + ); if (!issue) { throw new Error(); } @@ -1102,9 +1674,14 @@ export class ApiClient { method: "POST", body: JSON.stringify(data), }); - return parseWithFallback(raw, CreateFeedbackResponseSchema, EMPTY_CREATE_FEEDBACK_RESPONSE, { - endpoint: "POST /api/feedback", - }); + return parseWithFallback( + raw, + CreateFeedbackResponseSchema, + EMPTY_CREATE_FEEDBACK_RESPONSE, + { + endpoint: "POST /api/feedback", + }, + ); } async upsertClientUsage(data: ClientUsageRequest): Promise { @@ -1130,25 +1707,39 @@ export class ApiClient { async listChildIssues(id: string): Promise<{ issues: Issue[] }> { const raw = await this.fetch(`/api/issues/${id}/children`); - return parseWithFallback(raw, ChildIssuesResponseSchema, { issues: [] }, { - endpoint: "GET /api/issues/:id/children", - }); + return parseWithFallback( + raw, + ChildIssuesResponseSchema, + { issues: [] }, + { + endpoint: "GET /api/issues/:id/children", + }, + ); } /** Batched variant — returns children for multiple parents in one request. * Avoids an N-request fan-out in Swimlane (one per visible parent lane). * parentIds must be non-empty; pass a sorted, deduplicated list so the * React Query cache key is stable across renders. */ - async listChildrenByParents(parentIds: string[]): Promise<{ issues: Issue[] }> { + async listChildrenByParents( + parentIds: string[], + ): Promise<{ issues: Issue[] }> { const raw = await this.fetch( `/api/issues/children?parent_ids=${parentIds.join(",")}`, ); - return parseWithFallback(raw, ChildIssuesResponseSchema, { issues: [] }, { - endpoint: "GET /api/issues/children", - }); + return parseWithFallback( + raw, + ChildIssuesResponseSchema, + { issues: [] }, + { + endpoint: "GET /api/issues/children", + }, + ); } - async getChildIssueProgress(): Promise<{ progress: { parent_issue_id: string; total: number; done: number }[] }> { + async getChildIssueProgress(): Promise<{ + progress: { parent_issue_id: string; total: number; done: number }[]; + }> { return this.fetch("/api/issues/child-progress"); } @@ -1156,7 +1747,10 @@ export class ApiClient { await this.fetch(`/api/issues/${id}`, { method: "DELETE" }); } - async batchUpdateIssues(issueIds: string[], updates: UpdateIssueRequest): Promise<{ updated: number }> { + async batchUpdateIssues( + issueIds: string[], + updates: UpdateIssueRequest, + ): Promise<{ updated: number }> { return this.fetch("/api/issues/batch-update", { method: "POST", body: JSON.stringify({ issue_ids: issueIds, updates }), @@ -1193,30 +1787,47 @@ export class ApiClient { type: type ?? "comment", ...(parentId ? { parent_id: parentId } : {}), ...(attachmentIds?.length ? { attachment_ids: attachmentIds } : {}), - ...(suppressAgentIds?.length ? { suppress_agent_ids: suppressAgentIds } : {}), + ...(suppressAgentIds?.length + ? { suppress_agent_ids: suppressAgentIds } + : {}), }), }); } - async previewCommentTriggers(issueId: string, content: string, parentId?: string, editingCommentId?: string): Promise { - const raw = await this.fetch(`/api/issues/${issueId}/comments/trigger-preview`, { - method: "POST", - body: JSON.stringify({ - content, - ...(parentId ? { parent_id: parentId } : {}), - ...(editingCommentId ? { editing_comment_id: editingCommentId } : {}), - }), - }); - return parseWithFallback(raw, CommentTriggerPreviewSchema, { agents: [] }, { - endpoint: "POST /api/issues/:id/comments/trigger-preview", - }); + async previewCommentTriggers( + issueId: string, + content: string, + parentId?: string, + editingCommentId?: string, + ): Promise { + const raw = await this.fetch( + `/api/issues/${issueId}/comments/trigger-preview`, + { + method: "POST", + body: JSON.stringify({ + content, + ...(parentId ? { parent_id: parentId } : {}), + ...(editingCommentId ? { editing_comment_id: editingCommentId } : {}), + }), + }, + ); + return parseWithFallback( + raw, + CommentTriggerPreviewSchema, + { agents: [] }, + { + endpoint: "POST /api/issues/:id/comments/trigger-preview", + }, + ); } /** Dry-run the unified run-enqueue predicate for a prospective issue write * (create / single assign / single status / batch). Returns the runs that * would start; no side effect. The four entry points consult this instead * of re-implementing the rule (MUL-3375). */ - async previewIssueTrigger(params: IssueTriggerPreviewParams): Promise { + async previewIssueTrigger( + params: IssueTriggerPreviewParams, + ): Promise { const raw = await this.fetch("/api/issues/preview-trigger", { method: "POST", body: JSON.stringify({ @@ -1227,31 +1838,46 @@ export class ApiClient { ...(params.status ? { status: params.status } : {}), }), }); - return parseWithFallback(raw, IssueTriggerPreviewSchema, { triggers: [], total_count: 0 }, { - endpoint: "POST /api/issues/preview-trigger", - }); - } - - async listTimeline(issueId: string): Promise { - const raw = await this.fetch( - `/api/issues/${issueId}/timeline`, + return parseWithFallback( + raw, + IssueTriggerPreviewSchema, + { triggers: [], total_count: 0 }, + { + endpoint: "POST /api/issues/preview-trigger", + }, + ); + } + + async listTimeline(issueId: string): Promise { + const raw = await this.fetch(`/api/issues/${issueId}/timeline`); + return parseWithFallback( + raw, + TimelineEntriesSchema, + EMPTY_TIMELINE_ENTRIES, + { + endpoint: "GET /api/issues/:id/timeline", + }, ); - return parseWithFallback(raw, TimelineEntriesSchema, EMPTY_TIMELINE_ENTRIES, { - endpoint: "GET /api/issues/:id/timeline", - }); } async getAssigneeFrequency(): Promise { return this.fetch("/api/assignee-frequency"); } - async updateComment(commentId: string, content: string, attachmentIds?: string[], suppressAgentIds?: string[]): Promise { + async updateComment( + commentId: string, + content: string, + attachmentIds?: string[], + suppressAgentIds?: string[], + ): Promise { return this.fetch(`/api/comments/${commentId}`, { method: "PUT", body: JSON.stringify({ content, attachment_ids: attachmentIds, - ...(suppressAgentIds?.length ? { suppress_agent_ids: suppressAgentIds } : {}), + ...(suppressAgentIds?.length + ? { suppress_agent_ids: suppressAgentIds } + : {}), }), }); } @@ -1265,7 +1891,9 @@ export class ApiClient { } async unresolveComment(commentId: string): Promise { - return this.fetch(`/api/comments/${commentId}/resolve`, { method: "DELETE" }); + return this.fetch(`/api/comments/${commentId}/resolve`, { + method: "DELETE", + }); } async addReaction(commentId: string, emoji: string): Promise { @@ -1282,7 +1910,10 @@ export class ApiClient { }); } - async addIssueReaction(issueId: string, emoji: string): Promise { + async addIssueReaction( + issueId: string, + emoji: string, + ): Promise { return this.fetch(`/api/issues/${issueId}/reactions`, { method: "POST", body: JSON.stringify({ emoji }), @@ -1304,7 +1935,11 @@ export class ApiClient { }); } - async subscribeToIssue(issueId: string, userId?: string, userType?: string): Promise { + async subscribeToIssue( + issueId: string, + userId?: string, + userType?: string, + ): Promise { const body: Record = {}; if (userId) body.user_id = userId; if (userType) body.user_type = userType; @@ -1349,7 +1984,10 @@ export class ApiClient { } // Agents - async listAgents(params?: { workspace_id?: string; include_archived?: boolean }): Promise { + async listAgents(params?: { + workspace_id?: string; + include_archived?: boolean; + }): Promise { const search = new URLSearchParams(); if (params?.workspace_id) search.set("workspace_id", params.workspace_id); if (params?.include_archived) search.set("include_archived", "true"); @@ -1504,7 +2142,10 @@ export class ApiClient { * (MUL-5438); agent actors get a 403. Every successful call writes an * `agent_env_updated` activity_log row. MUL-2600. */ - async updateAgentEnv(id: string, data: UpdateAgentEnvRequest): Promise { + async updateAgentEnv( + id: string, + data: UpdateAgentEnvRequest, + ): Promise { return this.fetch(`/api/agents/${id}/env`, { method: "PUT", body: JSON.stringify(data), @@ -1543,7 +2184,8 @@ export class ApiClient { ): Promise { const search = new URLSearchParams(); if (params?.limit !== undefined) search.set("limit", String(params.limit)); - if (params?.offset !== undefined) search.set("offset", String(params.offset)); + if (params?.offset !== undefined) + search.set("offset", String(params.offset)); const query = search.toString(); const raw = await this.fetch( `/api/cloud-runtime/nodes${query ? `?${query}` : ""}`, @@ -1564,7 +2206,7 @@ export class ApiClient { body: JSON.stringify(data), extraHeaders: { "Content-Type": "application/json" }, }); - const raw = await res.json() as unknown; + const raw = (await res.json()) as unknown; return parseWithFallback( raw, CloudRuntimeNodeSchema, @@ -1595,12 +2237,14 @@ export class ApiClient { }); } - async listCloudBillingTransactions( - params?: { page?: number; page_size?: number }, - ): Promise { + async listCloudBillingTransactions(params?: { + page?: number; + page_size?: number; + }): Promise { const search = new URLSearchParams(); if (params?.page !== undefined) search.set("page", String(params.page)); - if (params?.page_size !== undefined) search.set("page_size", String(params.page_size)); + if (params?.page_size !== undefined) + search.set("page_size", String(params.page_size)); const query = search.toString(); const raw = await this.fetch( `/api/cloud-billing/transactions${query ? `?${query}` : ""}`, @@ -1613,12 +2257,14 @@ export class ApiClient { ); } - async listCloudBillingBatches( - params?: { page?: number; page_size?: number }, - ): Promise { + async listCloudBillingBatches(params?: { + page?: number; + page_size?: number; + }): Promise { const search = new URLSearchParams(); if (params?.page !== undefined) search.set("page", String(params.page)); - if (params?.page_size !== undefined) search.set("page_size", String(params.page_size)); + if (params?.page_size !== undefined) + search.set("page_size", String(params.page_size)); const query = search.toString(); const raw = await this.fetch( `/api/cloud-billing/batches${query ? `?${query}` : ""}`, @@ -1631,12 +2277,14 @@ export class ApiClient { ); } - async listCloudBillingTopups( - params?: { page?: number; page_size?: number }, - ): Promise { + async listCloudBillingTopups(params?: { + page?: number; + page_size?: number; + }): Promise { const search = new URLSearchParams(); if (params?.page !== undefined) search.set("page", String(params.page)); - if (params?.page_size !== undefined) search.set("page_size", String(params.page_size)); + if (params?.page_size !== undefined) + search.set("page_size", String(params.page_size)); const query = search.toString(); const raw = await this.fetch( `/api/cloud-billing/topups${query ? `?${query}` : ""}`, @@ -1764,9 +2412,7 @@ export class ApiClient { body: JSON.stringify({ interval: data.interval, idempotency_key: data.idempotencyKey, - ...(data.customerEmail - ? { customer_email: data.customerEmail } - : {}), + ...(data.customerEmail ? { customer_email: data.customerEmail } : {}), }), extraHeaders: { "Content-Type": "application/json", @@ -1783,12 +2429,13 @@ export class ApiClient { ); } - async reconcileWorkspaceSubscriptionSeats(): Promise< - WorkspaceSubscriptionSeatReconcileResult | null - > { - const res = await this.fetchRaw("/api/cloud-subscriptions/seats/reconcile", { - method: "POST", - }); + async reconcileWorkspaceSubscriptionSeats(): Promise { + const res = await this.fetchRaw( + "/api/cloud-subscriptions/seats/reconcile", + { + method: "POST", + }, + ); const raw = (await res.json()) as unknown; return parseWithFallback( raw, @@ -1801,10 +2448,13 @@ export class ApiClient { async createWorkspaceSubscriptionPortal( idempotencyKey: string, ): Promise { - const res = await this.fetchRaw("/api/cloud-subscriptions/portal-sessions", { - method: "POST", - extraHeaders: { "Idempotency-Key": idempotencyKey }, - }); + const res = await this.fetchRaw( + "/api/cloud-subscriptions/portal-sessions", + { + method: "POST", + extraHeaders: { "Idempotency-Key": idempotencyKey }, + }, + ); const raw = (await res.json()) as unknown; return parseWithFallback( raw, @@ -1843,7 +2493,9 @@ export class ApiClient { }> { return this.fetch(`/api/runtimes/${runtimeId}/unbind-agents-and-delete`, { method: "POST", - body: JSON.stringify({ expected_active_agent_ids: expectedActiveAgentIds }), + body: JSON.stringify({ + expected_active_agent_ids: expectedActiveAgentIds, + }), }); } @@ -2002,14 +2654,18 @@ export class ApiClient { // as the per-runtime endpoints above). // --------------------------------------------------------------------------- - async getDashboardUsageDaily( - params: { days?: number; project_id?: string | null; tz?: string }, - ): Promise { + async getDashboardUsageDaily(params: { + days?: number; + project_id?: string | null; + tz?: string; + }): Promise { const search = new URLSearchParams(); if (params.days) search.set("days", String(params.days)); if (params.project_id) search.set("project_id", params.project_id); if (params.tz) search.set("tz", params.tz); - const raw = await this.fetch(`/api/dashboard/usage/daily?${search}`); + const raw = await this.fetch( + `/api/dashboard/usage/daily?${search}`, + ); return parseWithFallback( raw, DashboardUsageDailyListSchema, @@ -2018,14 +2674,18 @@ export class ApiClient { ); } - async getDashboardUsageByAgent( - params: { days?: number; project_id?: string | null; tz?: string }, - ): Promise { + async getDashboardUsageByAgent(params: { + days?: number; + project_id?: string | null; + tz?: string; + }): Promise { const search = new URLSearchParams(); if (params.days) search.set("days", String(params.days)); if (params.project_id) search.set("project_id", params.project_id); if (params.tz) search.set("tz", params.tz); - const raw = await this.fetch(`/api/dashboard/usage/by-agent?${search}`); + const raw = await this.fetch( + `/api/dashboard/usage/by-agent?${search}`, + ); return parseWithFallback( raw, DashboardUsageByAgentListSchema, @@ -2034,16 +2694,20 @@ export class ApiClient { ); } - async getDashboardAgentRunTime( - params: { days?: number; project_id?: string | null; tz?: string }, - ): Promise { + async getDashboardAgentRunTime(params: { + days?: number; + project_id?: string | null; + tz?: string; + }): Promise { const search = new URLSearchParams(); if (params.days) search.set("days", String(params.days)); if (params.project_id) search.set("project_id", params.project_id); // `tz` aligns the "last N days" cutoff with the viewer's calendar, // matching the per-agent token card. if (params.tz) search.set("tz", params.tz); - const raw = await this.fetch(`/api/dashboard/agent-runtime?${search}`); + const raw = await this.fetch( + `/api/dashboard/agent-runtime?${search}`, + ); return parseWithFallback( raw, DashboardAgentRunTimeListSchema, @@ -2052,16 +2716,20 @@ export class ApiClient { ); } - async getDashboardRunTimeDaily( - params: { days?: number; project_id?: string | null; tz?: string }, - ): Promise { + async getDashboardRunTimeDaily(params: { + days?: number; + project_id?: string | null; + tz?: string; + }): Promise { const search = new URLSearchParams(); if (params.days) search.set("days", String(params.days)); if (params.project_id) search.set("project_id", params.project_id); // `tz` cuts the day buckets in the viewer's calendar so Time / Tasks // align with the Cost / Tokens charts. if (params.tz) search.set("tz", params.tz); - const raw = await this.fetch(`/api/dashboard/runtime/daily?${search}`); + const raw = await this.fetch( + `/api/dashboard/runtime/daily?${search}`, + ); return parseWithFallback( raw, DashboardRunTimeDailyListSchema, @@ -2070,16 +2738,20 @@ export class ApiClient { ); } - async getDashboardFailuresDaily( - params: { days?: number; project_id?: string | null; tz?: string }, - ): Promise { + async getDashboardFailuresDaily(params: { + days?: number; + project_id?: string | null; + tz?: string; + }): Promise { const search = new URLSearchParams(); if (params.days) search.set("days", String(params.days)); if (params.project_id) search.set("project_id", params.project_id); // `tz` cuts the day buckets in the viewer's calendar so the Errors chart // shares an x-axis with the other four metrics. if (params.tz) search.set("tz", params.tz); - const raw = await this.fetch(`/api/dashboard/failures/daily?${search}`); + const raw = await this.fetch( + `/api/dashboard/failures/daily?${search}`, + ); return parseWithFallback( raw, DashboardFailureDailyListSchema, @@ -2088,14 +2760,18 @@ export class ApiClient { ); } - async getDashboardFailuresByAgent( - params: { days?: number; project_id?: string | null; tz?: string }, - ): Promise { + async getDashboardFailuresByAgent(params: { + days?: number; + project_id?: string | null; + tz?: string; + }): Promise { const search = new URLSearchParams(); if (params.days) search.set("days", String(params.days)); if (params.project_id) search.set("project_id", params.project_id); if (params.tz) search.set("tz", params.tz); - const raw = await this.fetch(`/api/dashboard/failures/by-agent?${search}`); + const raw = await this.fetch( + `/api/dashboard/failures/by-agent?${search}`, + ); return parseWithFallback( raw, DashboardFailureByAgentListSchema, @@ -2126,7 +2802,9 @@ export class ApiClient { // than cast: an unparseable body degrades to an explicit "failed" record that // shows the discovery error and keeps manual model entry usable, instead of a // fabricated empty catalog or an endless spinner (MUL-5444). - async initiateListModels(runtimeId: string): Promise { + async initiateListModels( + runtimeId: string, + ): Promise { const raw = await this.fetch(`/api/runtimes/${runtimeId}/models`, { method: "POST", }); @@ -2186,7 +2864,9 @@ export class ApiClient { runtimeId: string, requestId: string, ): Promise { - return this.fetch(`/api/runtimes/${runtimeId}/local-skills/import/${requestId}`); + return this.fetch( + `/api/runtimes/${runtimeId}/local-skills/import/${requestId}`, + ); } async listAgentTasks(agentId: string): Promise { @@ -2240,7 +2920,9 @@ export class ApiClient { return this.fetch(`/api/agent-run-counts`); } - async getActiveTasksForIssue(issueId: string): Promise<{ tasks: AgentTask[] }> { + async getActiveTasksForIssue( + issueId: string, + ): Promise<{ tasks: AgentTask[] }> { return this.fetch(`/api/issues/${issueId}/active-task`); } @@ -2313,9 +2995,14 @@ export class ApiClient { // than crashing the sidebar. async getInboxUnreadSummary(): Promise { const raw = await this.fetch("/api/inbox/unread-summary"); - return parseWithFallback(raw, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, { - endpoint: "GET /api/inbox/unread-summary", - }); + return parseWithFallback( + raw, + InboxUnreadSummarySchema, + EMPTY_INBOX_UNREAD_SUMMARY, + { + endpoint: "GET /api/inbox/unread-summary", + }, + ); } async markAllInboxRead(): Promise<{ count: number }> { @@ -2340,10 +3027,14 @@ export class ApiClient { // follows the active workspace) so a caller can read a SPECIFIC workspace's // preferences — e.g. honoring the mute setting of the workspace an inbox // notification came from while the user is viewing a different one (#3766). - async getNotificationPreferences(workspaceSlug?: string): Promise { + async getNotificationPreferences( + workspaceSlug?: string, + ): Promise { const raw = await this.fetch( "/api/notification-preferences", - workspaceSlug ? { headers: { "X-Workspace-Slug": workspaceSlug } } : undefined, + workspaceSlug + ? { headers: { "X-Workspace-Slug": workspaceSlug } } + : undefined, ); return parseWithFallback( raw, @@ -2373,9 +3064,14 @@ export class ApiClient { // App Config async getConfig(): Promise { const raw = await this.fetch("/api/config"); - return parseWithFallback(raw, AppConfigSchema, EMPTY_APP_CONFIG, { - endpoint: "GET /api/config", - }); + return parseWithFallback( + raw, + AppConfigSchema, + EMPTY_APP_CONFIG, + { + endpoint: "GET /api/config", + }, + ); } // Workspaces @@ -2387,14 +3083,31 @@ export class ApiClient { return this.fetch(`/api/workspaces/${id}`); } - async createWorkspace(data: { name: string; slug: string; description?: string; context?: string; issue_prefix?: string }): Promise { + async createWorkspace(data: { + name: string; + slug: string; + description?: string; + context?: string; + issue_prefix?: string; + }): Promise { return this.fetch("/api/workspaces", { method: "POST", body: JSON.stringify(data), }); } - async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record; repos?: WorkspaceRepo[]; issue_prefix?: string; avatar_url?: string }): Promise { + async updateWorkspace( + id: string, + data: { + name?: string; + description?: string; + context?: string; + settings?: Record; + repos?: WorkspaceRepo[]; + issue_prefix?: string; + avatar_url?: string; + }, + ): Promise { return this.fetch(`/api/workspaces/${id}`, { method: "PATCH", body: JSON.stringify(data), @@ -2404,27 +3117,43 @@ export class ApiClient { async listPluginCatalog(workspaceId: string): Promise { let raw: unknown; try { - raw = await this.fetch(`/api/workspaces/${workspaceId}/plugins/catalog`); + raw = await this.fetch( + `/api/workspaces/${workspaceId}/plugins/catalog`, + ); } catch (error) { - if (error instanceof ApiError && error.status === 404) return EMPTY_PLUGIN_CATALOG; + if (error instanceof ApiError && error.status === 404) + return EMPTY_PLUGIN_CATALOG; throw error; } - return parseWithFallback(raw, PluginCatalogResponseSchema, EMPTY_PLUGIN_CATALOG, { - endpoint: "GET /api/workspaces/{id}/plugins/catalog", - }); + return parseWithFallback( + raw, + PluginCatalogResponseSchema, + EMPTY_PLUGIN_CATALOG, + { + endpoint: "GET /api/workspaces/{id}/plugins/catalog", + }, + ); } - async listPluginInstallations(workspaceId: string): Promise { + async listPluginInstallations( + workspaceId: string, + ): Promise { let raw: unknown; try { raw = await this.fetch(`/api/workspaces/${workspaceId}/plugins`); } catch (error) { - if (error instanceof ApiError && error.status === 404) return EMPTY_PLUGIN_INSTALLATION_LIST; + if (error instanceof ApiError && error.status === 404) + return EMPTY_PLUGIN_INSTALLATION_LIST; throw error; } - return parseWithFallback(raw, PluginInstallationListResponseSchema, EMPTY_PLUGIN_INSTALLATION_LIST, { - endpoint: "GET /api/workspaces/{id}/plugins", - }); + return parseWithFallback( + raw, + PluginInstallationListResponseSchema, + EMPTY_PLUGIN_INSTALLATION_LIST, + { + endpoint: "GET /api/workspaces/{id}/plugins", + }, + ); } /** @@ -2432,11 +3161,20 @@ export class ApiClient { * transport only — the stored entries are write-only server-side, so there * is nothing here to redact. */ - async listWorkspaceMcpServers(workspaceId: string): Promise { - const raw = await this.fetch(`/api/workspaces/${workspaceId}/mcp-servers`); - return parseWithFallback(raw, WorkspaceMcpServerListSchema, [] as WorkspaceMcpServer[], { - endpoint: "GET /api/workspaces/{id}/mcp-servers", - }); + async listWorkspaceMcpServers( + workspaceId: string, + ): Promise { + const raw = await this.fetch( + `/api/workspaces/${workspaceId}/mcp-servers`, + ); + return parseWithFallback( + raw, + WorkspaceMcpServerListSchema, + [] as WorkspaceMcpServer[], + { + endpoint: "GET /api/workspaces/{id}/mcp-servers", + }, + ); } /** @@ -2448,13 +3186,21 @@ export class ApiClient { name: string, config: Record, ): Promise { - const raw = await this.fetch(`/api/workspaces/${workspaceId}/mcp-servers`, { - method: "POST", - body: JSON.stringify({ name, config }), - }); - return parseWithFallback(raw, WorkspaceMcpServerSchema, EMPTY_WORKSPACE_MCP_SERVER, { - endpoint: "POST /api/workspaces/{id}/mcp-servers", - }); + const raw = await this.fetch( + `/api/workspaces/${workspaceId}/mcp-servers`, + { + method: "POST", + body: JSON.stringify({ name, config }), + }, + ); + return parseWithFallback( + raw, + WorkspaceMcpServerSchema, + EMPTY_WORKSPACE_MCP_SERVER, + { + endpoint: "POST /api/workspaces/{id}/mcp-servers", + }, + ); } /** @@ -2470,13 +3216,21 @@ export class ApiClient { `/api/workspaces/${workspaceId}/mcp-servers/${encodeURIComponent(serverId)}`, { method: "PUT", body: JSON.stringify(update) }, ); - return parseWithFallback(raw, WorkspaceMcpServerSchema, EMPTY_WORKSPACE_MCP_SERVER, { - endpoint: "PUT /api/workspaces/{id}/mcp-servers/{serverId}", - }); + return parseWithFallback( + raw, + WorkspaceMcpServerSchema, + EMPTY_WORKSPACE_MCP_SERVER, + { + endpoint: "PUT /api/workspaces/{id}/mcp-servers/{serverId}", + }, + ); } /** Removes a library entry and every assignment to it. */ - async deleteWorkspaceMcpServer(workspaceId: string, serverId: string): Promise { + async deleteWorkspaceMcpServer( + workspaceId: string, + serverId: string, + ): Promise { await this.fetch( `/api/workspaces/${workspaceId}/mcp-servers/${encodeURIComponent(serverId)}`, { method: "DELETE" }, @@ -2486,23 +3240,39 @@ export class ApiClient { /** The workspace MCP servers assigned to this agent, with their toggles. */ async listAgentMcpServers(agentId: string): Promise { const raw = await this.fetch(`/api/agents/${agentId}/mcp-servers`); - return parseWithFallback(raw, WorkspaceMcpServerListSchema, [] as WorkspaceMcpServer[], { - endpoint: "GET /api/agents/{id}/mcp-servers", - }); + return parseWithFallback( + raw, + WorkspaceMcpServerListSchema, + [] as WorkspaceMcpServer[], + { + endpoint: "GET /api/agents/{id}/mcp-servers", + }, + ); } /** * Gives one workspace server to this agent. Every write returns the * resulting assignment list, so the client never has to guess the state. */ - async addAgentMcpServer(agentId: string, serverId: string): Promise { - const raw = await this.fetch(`/api/agents/${agentId}/mcp-servers`, { - method: "POST", - body: JSON.stringify({ server_id: serverId }), - }); - return parseWithFallback(raw, WorkspaceMcpServerListSchema, [] as WorkspaceMcpServer[], { - endpoint: "POST /api/agents/{id}/mcp-servers", - }); + async addAgentMcpServer( + agentId: string, + serverId: string, + ): Promise { + const raw = await this.fetch( + `/api/agents/${agentId}/mcp-servers`, + { + method: "POST", + body: JSON.stringify({ server_id: serverId }), + }, + ); + return parseWithFallback( + raw, + WorkspaceMcpServerListSchema, + [] as WorkspaceMcpServer[], + { + endpoint: "POST /api/agents/{id}/mcp-servers", + }, + ); } async setAgentMcpServerEnabled( @@ -2514,39 +3284,75 @@ export class ApiClient { `/api/agents/${agentId}/mcp-servers/${encodeURIComponent(serverId)}/enabled`, { method: "PUT", body: JSON.stringify({ enabled }) }, ); - return parseWithFallback(raw, WorkspaceMcpServerListSchema, [] as WorkspaceMcpServer[], { - endpoint: "PUT /api/agents/{id}/mcp-servers/{serverId}/enabled", - }); + return parseWithFallback( + raw, + WorkspaceMcpServerListSchema, + [] as WorkspaceMcpServer[], + { + endpoint: "PUT /api/agents/{id}/mcp-servers/{serverId}/enabled", + }, + ); } - async removeAgentMcpServer(agentId: string, serverId: string): Promise { + async removeAgentMcpServer( + agentId: string, + serverId: string, + ): Promise { const raw = await this.fetch( `/api/agents/${agentId}/mcp-servers/${encodeURIComponent(serverId)}`, { method: "DELETE" }, ); - return parseWithFallback(raw, WorkspaceMcpServerListSchema, [] as WorkspaceMcpServer[], { - endpoint: "DELETE /api/agents/{id}/mcp-servers/{serverId}", - }); + return parseWithFallback( + raw, + WorkspaceMcpServerListSchema, + [] as WorkspaceMcpServer[], + { + endpoint: "DELETE /api/agents/{id}/mcp-servers/{serverId}", + }, + ); } - async installPlugin(workspaceId: string, request: PluginReleaseRequest): Promise { - const raw = await this.fetch(`/api/workspaces/${workspaceId}/plugins/install`, { - method: "POST", - body: JSON.stringify(request), - }); - return parseWithFallback(raw, PluginInstallationSchema, EMPTY_PLUGIN_INSTALLATION, { - endpoint: "POST /api/workspaces/{id}/plugins/install", - }); + async installPlugin( + workspaceId: string, + request: PluginReleaseRequest, + ): Promise { + const raw = await this.fetch( + `/api/workspaces/${workspaceId}/plugins/install`, + { + method: "POST", + body: JSON.stringify(request), + }, + ); + return parseWithFallback( + raw, + PluginInstallationSchema, + EMPTY_PLUGIN_INSTALLATION, + { + endpoint: "POST /api/workspaces/{id}/plugins/install", + }, + ); } - async upgradePlugin(workspaceId: string, installationId: string, request: PluginReleaseRequest): Promise { - const raw = await this.fetch(`/api/workspaces/${workspaceId}/plugins/${installationId}/upgrade`, { - method: "POST", - body: JSON.stringify(request), - }); - return parseWithFallback(raw, PluginInstallationSchema, EMPTY_PLUGIN_INSTALLATION, { - endpoint: "POST /api/workspaces/{id}/plugins/{installationId}/upgrade", - }); + async upgradePlugin( + workspaceId: string, + installationId: string, + request: PluginReleaseRequest, + ): Promise { + const raw = await this.fetch( + `/api/workspaces/${workspaceId}/plugins/${installationId}/upgrade`, + { + method: "POST", + body: JSON.stringify(request), + }, + ); + return parseWithFallback( + raw, + PluginInstallationSchema, + EMPTY_PLUGIN_INSTALLATION, + { + endpoint: "POST /api/workspaces/{id}/plugins/{installationId}/upgrade", + }, + ); } async setPluginEnabled( @@ -2556,72 +3362,162 @@ export class ApiClient { binding: PluginBindingRequest, ): Promise { const action = enabled ? "enable" : "disable"; - const raw = await this.fetch(`/api/workspaces/${workspaceId}/plugins/${installationId}/${action}`, { - method: "POST", - body: JSON.stringify(binding), - }); - return parseWithFallback(raw, PluginInstallationSchema, EMPTY_PLUGIN_INSTALLATION, { - endpoint: `POST /api/workspaces/{id}/plugins/{installationId}/${action}`, - }); - } - - async rollbackPlugin(workspaceId: string, installationId: string, version: string): Promise { - const raw = await this.fetch(`/api/workspaces/${workspaceId}/plugins/${installationId}/rollback`, { - method: "POST", - body: JSON.stringify({ version }), - }); - return parseWithFallback(raw, PluginInstallationSchema, EMPTY_PLUGIN_INSTALLATION, { - endpoint: "POST /api/workspaces/{id}/plugins/{installationId}/rollback", - }); + const raw = await this.fetch( + `/api/workspaces/${workspaceId}/plugins/${installationId}/${action}`, + { + method: "POST", + body: JSON.stringify(binding), + }, + ); + return parseWithFallback( + raw, + PluginInstallationSchema, + EMPTY_PLUGIN_INSTALLATION, + { + endpoint: `POST /api/workspaces/{id}/plugins/{installationId}/${action}`, + }, + ); } - async uninstallPlugin(workspaceId: string, installationId: string): Promise { - await this.fetch(`/api/workspaces/${workspaceId}/plugins/${installationId}`, { - method: "DELETE", - }); + async rollbackPlugin( + workspaceId: string, + installationId: string, + version: string, + ): Promise { + const raw = await this.fetch( + `/api/workspaces/${workspaceId}/plugins/${installationId}/rollback`, + { + method: "POST", + body: JSON.stringify({ version }), + }, + ); + return parseWithFallback( + raw, + PluginInstallationSchema, + EMPTY_PLUGIN_INSTALLATION, + { + endpoint: "POST /api/workspaces/{id}/plugins/{installationId}/rollback", + }, + ); } - async configurePluginRemoteMCP(workspaceId: string, installationId: string, contributionKey: string, request: RemoteMCPConfigRequest): Promise { - const raw = await this.fetch(`/api/workspaces/${workspaceId}/plugins/${installationId}/remote-mcp/${encodeURIComponent(contributionKey)}/config`, { - method: "PUT", - body: JSON.stringify(request), - }); - return parseWithFallback(raw, RemoteMCPDiscoveryResponseSchema, { config_revision: 0, discovered_tools: [], discovered_schema_digest: "" }, { - endpoint: "PUT /api/workspaces/{id}/plugins/{installationId}/remote-mcp/{contributionKey}/config", - }); + async uninstallPlugin( + workspaceId: string, + installationId: string, + ): Promise { + await this.fetch( + `/api/workspaces/${workspaceId}/plugins/${installationId}`, + { + method: "DELETE", + }, + ); } - async testPluginRemoteMCP(workspaceId: string, installationId: string, contributionKey: string): Promise { - const raw = await this.fetch(`/api/workspaces/${workspaceId}/plugins/${installationId}/remote-mcp/${encodeURIComponent(contributionKey)}/test`, { - method: "POST", - body: JSON.stringify({}), - }); - return parseWithFallback(raw, RemoteMCPDiscoveryResponseSchema, { config_revision: 0, discovered_tools: [], discovered_schema_digest: "" }, { - endpoint: "POST /api/workspaces/{id}/plugins/{installationId}/remote-mcp/{contributionKey}/test", - }); + async configurePluginRemoteMCP( + workspaceId: string, + installationId: string, + contributionKey: string, + request: RemoteMCPConfigRequest, + ): Promise { + const raw = await this.fetch( + `/api/workspaces/${workspaceId}/plugins/${installationId}/remote-mcp/${encodeURIComponent(contributionKey)}/config`, + { + method: "PUT", + body: JSON.stringify(request), + }, + ); + return parseWithFallback( + raw, + RemoteMCPDiscoveryResponseSchema, + { + config_revision: 0, + discovered_tools: [], + discovered_schema_digest: "", + }, + { + endpoint: + "PUT /api/workspaces/{id}/plugins/{installationId}/remote-mcp/{contributionKey}/config", + }, + ); } - async startPluginRemoteMCPOAuth(workspaceId: string, installationId: string, contributionKey: string, request: RemoteMCPOAuthStartRequest): Promise { - const raw = await this.fetch(`/api/workspaces/${workspaceId}/plugins/${installationId}/remote-mcp/${encodeURIComponent(contributionKey)}/oauth/start`, { - method: "POST", - body: JSON.stringify(request), - }); - return parseWithFallback(raw, RemoteMCPOAuthStartResponseSchema, EMPTY_REMOTE_MCP_OAUTH_START_RESPONSE, { - endpoint: "POST /api/workspaces/{id}/plugins/{installationId}/remote-mcp/{contributionKey}/oauth/start", - }); + async testPluginRemoteMCP( + workspaceId: string, + installationId: string, + contributionKey: string, + ): Promise { + const raw = await this.fetch( + `/api/workspaces/${workspaceId}/plugins/${installationId}/remote-mcp/${encodeURIComponent(contributionKey)}/test`, + { + method: "POST", + body: JSON.stringify({}), + }, + ); + return parseWithFallback( + raw, + RemoteMCPDiscoveryResponseSchema, + { + config_revision: 0, + discovered_tools: [], + discovered_schema_digest: "", + }, + { + endpoint: + "POST /api/workspaces/{id}/plugins/{installationId}/remote-mcp/{contributionKey}/test", + }, + ); } - async approvePluginRemoteMCPTools(workspaceId: string, installationId: string, contributionKey: string, tools: string[]): Promise { - await this.fetch(`/api/workspaces/${workspaceId}/plugins/${installationId}/remote-mcp/${encodeURIComponent(contributionKey)}/approve`, { - method: "POST", - body: JSON.stringify({ tools }), - }); + async startPluginRemoteMCPOAuth( + workspaceId: string, + installationId: string, + contributionKey: string, + request: RemoteMCPOAuthStartRequest, + ): Promise { + const raw = await this.fetch( + `/api/workspaces/${workspaceId}/plugins/${installationId}/remote-mcp/${encodeURIComponent(contributionKey)}/oauth/start`, + { + method: "POST", + body: JSON.stringify(request), + }, + ); + return parseWithFallback( + raw, + RemoteMCPOAuthStartResponseSchema, + EMPTY_REMOTE_MCP_OAUTH_START_RESPONSE, + { + endpoint: + "POST /api/workspaces/{id}/plugins/{installationId}/remote-mcp/{contributionKey}/oauth/start", + }, + ); } - async revokePluginRemoteMCPCredential(workspaceId: string, installationId: string, contributionKey: string): Promise { - await this.fetch(`/api/workspaces/${workspaceId}/plugins/${installationId}/remote-mcp/${encodeURIComponent(contributionKey)}/credential`, { - method: "DELETE", - }); + async approvePluginRemoteMCPTools( + workspaceId: string, + installationId: string, + contributionKey: string, + tools: string[], + ): Promise { + await this.fetch( + `/api/workspaces/${workspaceId}/plugins/${installationId}/remote-mcp/${encodeURIComponent(contributionKey)}/approve`, + { + method: "POST", + body: JSON.stringify({ tools }), + }, + ); + } + + async revokePluginRemoteMCPCredential( + workspaceId: string, + installationId: string, + contributionKey: string, + ): Promise { + await this.fetch( + `/api/workspaces/${workspaceId}/plugins/${installationId}/remote-mcp/${encodeURIComponent(contributionKey)}/credential`, + { + method: "DELETE", + }, + ); } // Members @@ -2629,14 +3525,21 @@ export class ApiClient { return this.fetch(`/api/workspaces/${workspaceId}/members`); } - async createMember(workspaceId: string, data: CreateMemberRequest): Promise { + async createMember( + workspaceId: string, + data: CreateMemberRequest, + ): Promise { return this.fetch(`/api/workspaces/${workspaceId}/members`, { method: "POST", body: JSON.stringify(data), }); } - async updateMember(workspaceId: string, memberId: string, data: UpdateMemberRequest): Promise { + async updateMember( + workspaceId: string, + memberId: string, + data: UpdateMemberRequest, + ): Promise { return this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, { method: "PATCH", body: JSON.stringify(data), @@ -2660,10 +3563,16 @@ export class ApiClient { return this.fetch(`/api/workspaces/${workspaceId}/invitations`); } - async revokeInvitation(workspaceId: string, invitationId: string): Promise { - await this.fetch(`/api/workspaces/${workspaceId}/invitations/${invitationId}`, { - method: "DELETE", - }); + async revokeInvitation( + workspaceId: string, + invitationId: string, + ): Promise { + await this.fetch( + `/api/workspaces/${workspaceId}/invitations/${invitationId}`, + { + method: "DELETE", + }, + ); } async listMyInvitations(): Promise { @@ -2686,18 +3595,26 @@ export class ApiClient { }); } - async createShareLink(workspaceId: string, data: { role?: string; expires_in?: number; max_uses?: number }): Promise { - const raw = await this.fetch(`/api/workspaces/${workspaceId}/share-links`, { - method: "POST", - body: JSON.stringify(data), - }); + async createShareLink( + workspaceId: string, + data: { role?: string; expires_in?: number; max_uses?: number }, + ): Promise { + const raw = await this.fetch( + `/api/workspaces/${workspaceId}/share-links`, + { + method: "POST", + body: JSON.stringify(data), + }, + ); return parseWithFallback(raw, ShareLinkSchema, EMPTY_SHARE_LINK, { endpoint: "POST /api/workspaces/{id}/share-links", }); } async listShareLinks(workspaceId: string): Promise { - const raw = await this.fetch(`/api/workspaces/${workspaceId}/share-links`); + const raw = await this.fetch( + `/api/workspaces/${workspaceId}/share-links`, + ); return parseWithFallback(raw, ShareLinkListResponseSchema, [], { endpoint: "GET /api/workspaces/{id}/share-links", }); @@ -2709,18 +3626,31 @@ export class ApiClient { }); } - async joinByShareLink(code: string): Promise<{ member: MemberWithUser; workspace_id: string; workspace_slug: string }> { + async joinByShareLink( + code: string, + ): Promise<{ + member: MemberWithUser; + workspace_id: string; + workspace_slug: string; + }> { const raw = await this.fetch("/api/share-links/join", { method: "POST", body: JSON.stringify({ code }), }); - return parseWithFallback(raw, JoinShareLinkResponseSchema, EMPTY_JOIN_SHARE_LINK_RESPONSE, { - endpoint: "POST /api/share-links/join", - }); + return parseWithFallback( + raw, + JoinShareLinkResponseSchema, + EMPTY_JOIN_SHARE_LINK_RESPONSE, + { + endpoint: "POST /api/share-links/join", + }, + ); } async getShareLinkInfo(code: string): Promise { - const raw = await this.fetch(`/api/share-links/${encodeURIComponent(code)}`); + const raw = await this.fetch( + `/api/share-links/${encodeURIComponent(code)}`, + ); return parseWithFallback(raw, ShareLinkInfoSchema, EMPTY_SHARE_LINK_INFO, { endpoint: "GET /api/share-links/{code}", }); @@ -2782,7 +3712,10 @@ export class ApiClient { return this.fetch(`/api/agents/${agentId}/skills`); } - async setAgentSkills(agentId: string, data: SetAgentSkillsRequest): Promise { + async setAgentSkills( + agentId: string, + data: SetAgentSkillsRequest, + ): Promise { await this.fetch(`/api/agents/${agentId}/skills`, { method: "PUT", body: JSON.stringify(data), @@ -2792,19 +3725,26 @@ export class ApiClient { // Incremental attach: POST /skills/add only inserts the given ids (the // server upserts with ON CONFLICT DO NOTHING), so callers don't need to // read the agent's current skill set first. - async addAgentSkills(agentId: string, data: SetAgentSkillsRequest): Promise { + async addAgentSkills( + agentId: string, + data: SetAgentSkillsRequest, + ): Promise { await this.fetch(`/api/agents/${agentId}/skills/add`, { method: "POST", body: JSON.stringify(data), }); } - async setAgentSkillEnabled(agentId: string, skillId: string, enabled: boolean): Promise { - await this.fetch(`/api/agents/${agentId}/skills/${skillId}/enabled`, { - method: "PUT", - body: JSON.stringify({ enabled }), - }); - } + async setAgentSkillEnabled( + agentId: string, + skillId: string, + enabled: boolean, + ): Promise { + await this.fetch(`/api/agents/${agentId}/skills/${skillId}/enabled`, { + method: "PUT", + body: JSON.stringify({ enabled }), + }); + } async setAgentRuntimeSkillEnabled( agentId: string, @@ -2816,18 +3756,20 @@ export class ApiClient { }); } - async removeAgentSkill(agentId: string, skillId: string): Promise { - await this.fetch(`/api/agents/${agentId}/skills/${skillId}`, { - method: "DELETE", - }); - } + async removeAgentSkill(agentId: string, skillId: string): Promise { + await this.fetch(`/api/agents/${agentId}/skills/${skillId}`, { + method: "DELETE", + }); + } // Personal Access Tokens async listPersonalAccessTokens(): Promise { return this.fetch("/api/tokens"); } - async createPersonalAccessToken(data: CreatePersonalAccessTokenRequest): Promise { + async createPersonalAccessToken( + data: CreatePersonalAccessTokenRequest, + ): Promise { return this.fetch("/api/tokens", { method: "POST", body: JSON.stringify(data), @@ -2852,7 +3794,8 @@ export class ApiClient { formData.append("file", file); if (opts?.issueId) formData.append("issue_id", opts.issueId); if (opts?.commentId) formData.append("comment_id", opts.commentId); - if (opts?.chatSessionId) formData.append("chat_session_id", opts.chatSessionId); + if (opts?.chatSessionId) + formData.append("chat_session_id", opts.chatSessionId); const rid = createRequestId(); const start = Date.now(); @@ -2868,12 +3811,22 @@ export class ApiClient { if (!res.ok) { if (res.status === 401) this.handleUnauthorized(); - const message = await this.parseErrorMessage(res, `Upload failed: ${res.status}`); - this.logger.error(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms`, error: message }); + const message = await this.parseErrorMessage( + res, + `Upload failed: ${res.status}`, + ); + this.logger.error(`← ${res.status} /api/upload-file`, { + rid, + duration: `${Date.now() - start}ms`, + error: message, + }); throw new Error(message); } - this.logger.info(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms` }); + this.logger.info(`← ${res.status} /api/upload-file`, { + rid, + duration: `${Date.now() - start}ms`, + }); const raw = (await res.json()) as unknown; return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, { endpoint: "POST /api/upload-file", @@ -2928,10 +3881,13 @@ export class ApiClient { sessionId: string, messageId: string, ): Promise { - await this.fetch(`/api/chat/sessions/${sessionId}/quick-actions/regenerate`, { - method: "POST", - body: JSON.stringify({ message_id: messageId }), - }); + await this.fetch( + `/api/chat/sessions/${sessionId}/quick-actions/regenerate`, + { + method: "POST", + body: JSON.stringify({ message_id: messageId }), + }, + ); } async updateChatSession( @@ -2944,14 +3900,20 @@ export class ApiClient { }); } - async setChatSessionPinned(id: string, pinned: boolean): Promise { + async setChatSessionPinned( + id: string, + pinned: boolean, + ): Promise { return this.fetch(`/api/chat/sessions/${id}/pin`, { method: "PATCH", body: JSON.stringify({ pinned }), }); } - async setChatSessionArchived(id: string, archived: boolean): Promise { + async setChatSessionArchived( + id: string, + archived: boolean, + ): Promise { return this.fetch(`/api/chat/sessions/${id}/archive`, { method: "PATCH", body: JSON.stringify({ archived }), @@ -2971,19 +3933,31 @@ export class ApiClient { } async unpinChatAgent(agentId: string): Promise { - await this.fetch(`/api/chat/pinned-agents/${agentId}`, { method: "DELETE" }); + await this.fetch(`/api/chat/pinned-agents/${agentId}`, { + method: "DELETE", + }); } async listChatMessages(sessionId: string): Promise { - const raw: unknown = await this.fetch(`/api/chat/sessions/${sessionId}/messages`); - return parseWithFallback(raw, ChatMessageListSchema, EMPTY_CHAT_MESSAGE_LIST, { - endpoint: "GET /api/chat/sessions/:id/messages", - }); + const raw: unknown = await this.fetch( + `/api/chat/sessions/${sessionId}/messages`, + ); + return parseWithFallback( + raw, + ChatMessageListSchema, + EMPTY_CHAT_MESSAGE_LIST, + { + endpoint: "GET /api/chat/sessions/:id/messages", + }, + ); } async listChatMessagesPage( sessionId: string, - params: { before?: { created_at: string; id: string } | null; limit?: number } = {}, + params: { + before?: { created_at: string; id: string } | null; + limit?: number; + } = {}, ): Promise { const limit = params.limit ?? 50; const query = new URLSearchParams({ limit: String(limit) }); @@ -3032,10 +4006,13 @@ export class ApiClient { if (attachmentIds && attachmentIds.length > 0) { body.attachment_ids = attachmentIds; } - const raw = await this.fetch(`/api/chat/sessions/${sessionId}/messages`, { - method: "POST", - body: JSON.stringify(body), - }); + const raw = await this.fetch( + `/api/chat/sessions/${sessionId}/messages`, + { + method: "POST", + body: JSON.stringify(body), + }, + ); const response = parseWithFallback( raw, SendChatMessageResponseSchema, @@ -3053,11 +4030,14 @@ export class ApiClient { }, workspaceSlug?: string, ): Promise { - const raw = await this.fetch(`/api/chat/sessions/${sessionId}/onboarding`, { - method: "POST", - headers: workspaceHeader(workspaceSlug), - body: JSON.stringify(data), - }); + const raw = await this.fetch( + `/api/chat/sessions/${sessionId}/onboarding`, + { + method: "POST", + headers: workspaceHeader(workspaceSlug), + body: JSON.stringify(data), + }, + ); return parseWithFallback( raw, StartMikaOnboardingResponseSchema, @@ -3067,10 +4047,17 @@ export class ApiClient { } async getPendingChatTask(sessionId: string): Promise { - const raw = await this.fetch(`/api/chat/sessions/${sessionId}/pending-task`); - return parseWithFallback(raw, ChatPendingTaskSchema, EMPTY_CHAT_PENDING_TASK, { - endpoint: "GET /api/chat/sessions/:id/pending-task", - }); + const raw = await this.fetch( + `/api/chat/sessions/${sessionId}/pending-task`, + ); + return parseWithFallback( + raw, + ChatPendingTaskSchema, + EMPTY_CHAT_PENDING_TASK, + { + endpoint: "GET /api/chat/sessions/:id/pending-task", + }, + ); } async prioritizeQueuedChatTask( @@ -3085,7 +4072,9 @@ export class ApiClient { raw, PrioritizeQueuedChatTaskResponseSchema, EMPTY_PRIORITIZE_QUEUED_CHAT_TASK_RESPONSE, - { endpoint: "POST /api/chat/sessions/:id/queued-tasks/:taskId/prioritize" }, + { + endpoint: "POST /api/chat/sessions/:id/queued-tasks/:taskId/prioritize", + }, ); } @@ -3100,26 +4089,41 @@ export class ApiClient { * A 404 means the backend predates the endpoint — treat as "nothing * pending" so older servers never error the composer. */ - async listChatDraftRestores(sessionId: string): Promise { + async listChatDraftRestores( + sessionId: string, + ): Promise { let raw: unknown; try { - raw = await this.fetch(`/api/chat/sessions/${sessionId}/draft-restores`); + raw = await this.fetch( + `/api/chat/sessions/${sessionId}/draft-restores`, + ); } catch (err) { if (err instanceof ApiError && err.status === 404) { return { restores: [] }; } throw err; } - return parseWithFallback(raw, ChatDraftRestoresResponseSchema, EMPTY_CHAT_DRAFT_RESTORES, { - endpoint: "GET /api/chat/sessions/{id}/draft-restores", - }); + return parseWithFallback( + raw, + ChatDraftRestoresResponseSchema, + EMPTY_CHAT_DRAFT_RESTORES, + { + endpoint: "GET /api/chat/sessions/{id}/draft-restores", + }, + ); } /** Idempotent consume — deleting an already-consumed restore is a 204 no-op. */ - async consumeChatDraftRestore(sessionId: string, restoreId: string): Promise { - await this.fetch(`/api/chat/sessions/${sessionId}/draft-restores/${restoreId}`, { - method: "DELETE", - }); + async consumeChatDraftRestore( + sessionId: string, + restoreId: string, + ): Promise { + await this.fetch( + `/api/chat/sessions/${sessionId}/draft-restores/${restoreId}`, + { + method: "DELETE", + }, + ); } async listPendingChatTasks(): Promise { @@ -3131,7 +4135,9 @@ export class ApiClient { } async markChatSessionRead(sessionId: string): Promise { - await this.fetch(`/api/chat/sessions/${sessionId}/read`, { method: "POST" }); + await this.fetch(`/api/chat/sessions/${sessionId}/read`, { + method: "POST", + }); } // Advertises the durable draft-restore capability (#5219). The server only @@ -3144,19 +4150,28 @@ export class ApiClient { ): Promise { const params = new URLSearchParams(); if (options?.queuedAction) { - if (!options.sessionId) throw new Error("sessionId is required for queued-only cancellation"); + if (!options.sessionId) + throw new Error("sessionId is required for queued-only cancellation"); params.set("expected_status", "queued"); params.set("chat_session_id", options.sessionId); params.set("queue_action", options.queuedAction); } const query = params.size > 0 ? `?${params}` : ""; - const raw = await this.fetch(`/api/tasks/${taskId}/cancel${query}`, { - method: "POST", - headers: { "X-Client-Capabilities": CHAT_DRAFT_RESTORE_CAPABILITY }, - }); - return parseWithFallback(raw, CancelTaskResponseSchema, EMPTY_CANCEL_TASK_RESPONSE, { - endpoint: "POST /api/tasks/{taskId}/cancel", - }); + const raw = await this.fetch( + `/api/tasks/${taskId}/cancel${query}`, + { + method: "POST", + headers: { "X-Client-Capabilities": CHAT_DRAFT_RESTORE_CAPABILITY }, + }, + ); + return parseWithFallback( + raw, + CancelTaskResponseSchema, + EMPTY_CANCEL_TASK_RESPONSE, + { + endpoint: "POST /api/tasks/{taskId}/cancel", + }, + ); } async listAttachments(issueId: string): Promise { @@ -3234,7 +4249,9 @@ export class ApiClient { } // Projects - async listProjects(params?: { status?: string }): Promise { + async listProjects(params?: { + status?: string; + }): Promise { const search = new URLSearchParams(); if (params?.status) search.set("status", params.status); return this.fetch(`/api/projects?${search}`); @@ -3251,7 +4268,10 @@ export class ApiClient { }); } - async updateProject(id: string, data: UpdateProjectRequest): Promise { + async updateProject( + id: string, + data: UpdateProjectRequest, + ): Promise { return this.fetch(`/api/projects/${id}`, { method: "PUT", body: JSON.stringify(data), @@ -3300,11 +4320,20 @@ export class ApiClient { } // Labels - async listLabels(resourceType: LabelResourceType = "issue"): Promise { - const raw = await this.fetch(`/api/labels?resource_type=${resourceType}`); - return parseWithFallback(raw, ListLabelsResponseSchema, EMPTY_LIST_LABELS_RESPONSE, { - endpoint: "GET /api/labels", - }); + async listLabels( + resourceType: LabelResourceType = "issue", + ): Promise { + const raw = await this.fetch( + `/api/labels?resource_type=${resourceType}`, + ); + return parseWithFallback( + raw, + ListLabelsResponseSchema, + EMPTY_LIST_LABELS_RESPONSE, + { + endpoint: "GET /api/labels", + }, + ); } async getLabel(id: string): Promise