/** * `manage_workflows` core implementation — thin reads/controls over * {@link WorkflowRunManager}: check a run's status, abort a run, or list recent * runs. All state lives in the run manager / journal; this delegates and * compacts the result to JSON. * * NOTE: this runs in-process and returns a `runId`-keyed, deliberately trimmed * projection for the model — a DIFFERENT contract from the HTTP wire shape * (`id`-keyed `toWireRun`/`workflowRunSchema` in * `runtime/routes/workflow-routes.ts`). The two are intentionally not unified: * converging on `toWireRun` here would change this tool's emitted JSON. Both * project from the same `WorkflowRun` source type, so field renames are caught * by the type checker. */ import { z } from "zod"; import { getEffectiveProfilesForProvider } from "../../config/default-profile-catalog.js"; import { loadConfig } from "../../config/loader.js"; import { callerOwnsWorkflowRun } from "../../workflows/capabilities.js"; import type { WorkflowRun } from "../../workflows/journal-store.js"; import { getWorkflowRunManager } from "../../workflows/run-manager.js"; import { invalidToolInputResult, nullAsOmitted, } from "../shared/zod-tool-schema.js"; import type { ToolContext, ToolExecutionResult } from "../types.js"; /** * Model-input schema, `safeParse`d at the top of * {@link executeManageWorkflows}. Same in-tool pattern and TOOLS.json drift * guard as the other bundled-skill tools — see the schema block in * `tools/document/document-tool.ts` for the framework. * * `action` is deliberately UNDECLARED (loose passthrough): the switch's * default case owns the unknown-action error, and `executor.ts` reads * `input.action` / `input.run_id` pre-execution for the requireFreshApproval * promotion (those reads are already defensive and see the raw input either * way). */ export const manageWorkflowsInputSchema = z.looseObject({ run_id: nullAsOmitted(z.string()), }); export async function executeManageWorkflows( input: Record, context: ToolContext, ): Promise { const parsedInput = manageWorkflowsInputSchema.safeParse(input); if (!parsedInput.success) { return invalidToolInputResult("manage_workflows", parsedInput.error); } const action = input.action as string | undefined; const runId = parsedInput.data.run_id; const manager = getWorkflowRunManager(); // Authorization scope ({@link callerOwnsWorkflowRun}, shared with the // executor's resume-approval gate): a guardian may inspect/control every run, // but a non-guardian conversation is limited to runs IT originated. Without // this, a contact could enumerate a guardian's workflows (names/statuses) via // list_runs and then abort/resume them by id — the tool is low-risk and // reachable in non-guardian conversations. A run the caller may not see is // treated as not-found so the tool never reveals another conversation's run. const ownsRun = (run: WorkflowRun): boolean => callerOwnsWorkflowRun(run, context); switch (action) { case "status": { if (!runId) { return { content: '"run_id" is required for action "status".', isError: true, }; } const run = manager.status(runId); if (!run || !ownsRun(run)) { return { content: JSON.stringify({ runId, found: false }), isError: false, }; } return { content: JSON.stringify({ runId: run.id, name: run.name, status: run.status, agentsSpawned: run.agentsSpawned, inputTokens: run.inputTokens, outputTokens: run.outputTokens, error: run.error, }), isError: false, }; } case "get_result": { if (!runId) { return { content: '"run_id" is required for action "get_result".', isError: true, }; } // The full result payload rides on the run record (journal getRun); // `status` omits it to stay lightweight, so this action returns it in // full. The completion-summary wake truncates large results and points // the assistant here for the complete value. const run = manager.status(runId); if (!run || !ownsRun(run)) { return { content: JSON.stringify({ runId, found: false }), isError: false, }; } return { content: JSON.stringify({ runId: run.id, status: run.status, result: run.result ?? null, error: run.error ?? null, }), isError: false, }; } case "abort": { if (!runId) { return { content: '"run_id" is required for action "abort".', isError: true, }; } // Only signal a run the caller owns. A non-owned (or absent) run is a // no-op with the same response shape, so existence is never revealed. const run = manager.status(runId); if (run && ownsRun(run)) { manager.abort(runId); } return { content: JSON.stringify({ runId, message: "Abort signalled (no-op if the run already finished).", }), isError: false, }; } case "resume": { if (!runId) { return { content: '"run_id" is required for action "resume".', isError: true, }; } // Don't resume — or reveal — a run the caller doesn't own; mirror // resume()'s own not-found message. const run = manager.status(runId); if (!run || !ownsRun(run)) { return { content: `Failed to resume workflow: Workflow run ${runId} not found.`, isError: true, }; } try { const { runId: resumedId } = manager.resume(runId); return { content: JSON.stringify({ runId: resumedId, status: "running", message: "Workflow resumed. The completed prefix is replayed from the journal and the run continues from the first unfinished step. You will be notified in this conversation when it completes — do NOT poll.", }), isError: false, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { content: `Failed to resume workflow: ${msg}`, isError: true }; } } case "list_runs": { const runs = manager.list().filter(ownsRun); return { content: JSON.stringify({ runs: runs.map((r) => ({ runId: r.id, name: r.name, status: r.status, agentsSpawned: r.agentsSpawned, })), }), isError: false, }; } case "list_profiles": { // Mirror the `config/llm/profiles` route: sorted profile names plus the // workspace-wide active profile. Read-only — leaves use this to pick a // valid `profile` for `run_workflow` (an unknown profile throws). const { llm } = loadConfig(); const profiles = getEffectiveProfilesForProvider( llm?.profiles, llm?.defaultProvider ?? null, ); return { content: JSON.stringify({ profiles: Object.keys(profiles).sort(), activeProfile: typeof llm?.activeProfile === "string" ? llm.activeProfile : null, }), isError: false, }; } default: return { content: 'Unknown action. Use one of: "status", "get_result", "abort", "resume", "list_runs", "list_profiles".', isError: true, }; } }