UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

767 lines (766 loc) • 36.3 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. import { randomUUID } from "node:crypto"; import { parseBundleRef } from "../../core/asset/asset-ref.js"; import { loadConfig } from "../../core/config/config.js"; import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js"; import { appendEvent } from "../../core/events.js"; import { warn } from "../../core/warn.js"; import { insertEventOnce } from "../../storage/repositories/events-repository.js"; import { withWorkflowRunsRepo, } from "../../storage/repositories/workflow-runs-repository.js"; import { getCurrentWorkflowScopeKey } from "../authoring/scope-key.js"; import { frozenSummaryJudge } from "../exec/frozen-judge.js"; import { detectSecretShapedParams } from "../exec/param-secrets.js"; import { freezeWorkflow } from "../freeze/freeze.js"; import { materializeWorkflowParameterFlags, validateWorkflowParams } from "../ir/params.js"; import { canonicalPlanJson, computePlanHash } from "../ir/plan-hash.js"; import { clip, WORKFLOW_UNIT_DIAGNOSTIC_CLIP } from "../resource-limits.js"; import { validateStepSummary } from "../validate-summary.js"; import { resolveAgentIdentity } from "./agent-identity.js"; import { resolveWorkflowRunOutputs } from "./run-outputs.js"; import { frozenStepRows, readRunPlan } from "./run-plan.js"; import { canonicalizeWorkflowRefInput, loadWorkflowAsset, resolveWorkflowEntryId } from "./workflow-asset-loader.js"; /** * Membership test for the journaled `runner` column, which is an untyped string. * The `Record<WorkflowRuntimeKind, …>` is exhaustiveness-checked, so a new runtime * kind cannot be added to the union without being accepted here too. */ const IR_RUNTIME_KINDS = { llm: true, agent: true, sdk: true, exec: true }; function runtimeKindOf(runner) { return runner !== null && Object.hasOwn(IR_RUNTIME_KINDS, runner) ? runner : null; } function toUnitDiagnostic(row) { let diagnostic = null; if (row.result_json !== null) { // `result_json` is a JSON-encoded value: a bare JSON string for a free-text // unit, an object/array for a schema unit. Render the decoded string as-is // (no surrounding quotes) and other shapes as compact JSON, then clip so a // large artifact can't flood the diagnostic surface. let text = row.result_json; try { const parsed = JSON.parse(row.result_json); text = typeof parsed === "string" ? parsed : JSON.stringify(parsed); } catch { /* leave the raw journaled text */ } diagnostic = clip(text, WORKFLOW_UNIT_DIAGNOSTIC_CLIP); } return { unitId: row.unit_id, nodeId: row.node_id, stepId: row.step_id, phase: row.phase, status: row.status, attempts: row.attempts, tokens: row.tokens, failureReason: row.failure_reason, sessionId: row.session_id, diagnostic, startedAt: row.started_at, finishedAt: row.finished_at, engine: row.engine ?? null, runtimeKind: runtimeKindOf(row.runner), platform: null, }; } export async function startWorkflowRun(ref, params = {}, options) { const asset = await loadWorkflowAsset(ref); // Frozen plan: compile the plan ONCE at start and // persist it on the run row in the same transaction as the insert. Every // later invocation executes this snapshot — the asset file is never re-read // for an in-flight run; re-planning is an explicit new run. const frozen = await freezeWorkflow(asset, loadConfig()); const plan = frozen.plan; if (options?.parameterFlags?.length && Object.keys(params).length > 0) { throw new UsageError("Workflow parameters must use either an object or per-parameter flags, not both."); } const effectiveParams = options?.parameterFlags?.length ? materializeWorkflowParameterFlags(plan, options.parameterFlags) : params; // Non-fatal WARNINGS: untyped-step and undeclared-param advisories surface // as `warn()` lines at start (stderr, consistent with the repo's other // author-facing warnings) without blocking the run. for (const w of frozen.warnings) { warn(`workflow run: ${asset.path}:${w.line} — ${w.message}`); } // validate supplied parameters against the frozen param // schemas BEFORE creating the run, so a type-mismatched param (e.g. a string // for a `{ type: array }` param) is rejected with actionable errors instead // of flowing silently into a unit prompt. Programs without declared param // schemas (and every Markdown workflow) validate trivially. const paramErrors = validateWorkflowParams(plan, effectiveParams); if (paramErrors.length > 0) { throw new UsageError(`Cannot start ${asset.ref}: the supplied parameters do not satisfy the workflow's declared schemas:\n` + paramErrors.map((e) => ` - ${e}`).join("\n"), "INVALID_JSON_ARGUMENT"); } const planJson = canonicalPlanJson(plan); const planHash = computePlanHash(plan); const workflowRefs = await workflowRunRefSet(asset.ref, ref); return withWorkflowRunsRepo(async (repo) => { const now = new Date().toISOString(); const runId = randomUUID(); const scopeKey = getCurrentWorkflowScopeKey(); const currentStepId = plan.steps[0]?.stepId ?? null; const workflowEntryId = resolveWorkflowEntryId(asset.sourcePath, asset.ref, asset.adapterId); // Capture the invoking harness/session identity for this run. Explicit options // win; otherwise fall back to best-effort environment detection. This is // identity-only — no background thread or timer is started here. const detected = resolveAgentIdentity(); const agentHarness = options?.agentHarness !== undefined ? options.agentHarness : detected.harness; const agentSessionId = options?.agentSessionId !== undefined ? options.agentSessionId : detected.sessionId; // Concurrency guard (#485): if an active run already exists in this // (workflow_ref, scope_key) pair, refuse to create a parallel run unless // `force: true` is set. Previously every call inserted unconditionally, // so two terminals starting the same workflow could leave two runs racing. // The active-alias query and all inserts share one immediate transaction. // #942: an active run of this ref in a different scope (e.g. a scheduled // task's cwd vs. a shell) is warned about once; the uniqueness guard stays // scope-local. The query excludes the caller's own scope in SQL. const crossScopeActive = repo.findActiveRunOutsideScope(workflowRefs, scopeKey); const crossScopeWarning = crossScopeActive ? `Workflow ${asset.ref} already has an active run in another scope ` + `(id ${crossScopeActive.id}, started ${crossScopeActive.created_at}, scope ${crossScopeActive.scope_key ?? "unknown"}); ` + `starting a separate run here. Resume it from anywhere with "akm workflow run ${crossScopeActive.id}" ` + `or free it with "akm workflow abandon ${crossScopeActive.id}".` : undefined; repo.publishWorkflowRunV4({ workflowRefs, ...(options?.force ? { force: true } : {}), run: { id: runId, workflowRef: asset.ref, scopeKey, workflowEntryId, workflowTitle: asset.title, paramsJson: JSON.stringify(effectiveParams), currentStepId, createdAt: now, updatedAt: now, agentHarness, agentSessionId, }, steps: frozenStepRows(plan).map((step) => ({ runId, stepId: step.stepId, stepTitle: step.stepTitle, instructions: step.instructions, completionJson: step.completionJson, sequenceIndex: step.sequenceIndex, })), planJson, planHash, }); const result = await getWorkflowStatus(runId); if (crossScopeWarning) result.warnings = [...(result.warnings ?? []), crossScopeWarning]; // Params reach every unit prompt in clear (only the journal scrubs them). // Surface a loud, best-effort warning when a param LOOKS like a credential // so the author moves it to an env binding. Advisory only — never blocks // the start. const secretWarnings = detectSecretShapedParams(effectiveParams); if (secretWarnings.length > 0) result.warnings = [...(result.warnings ?? []), ...secretWarnings]; // The implicit engine fallback is announced ONCE, here at run creation — // the frozen plan records the engine actually used, so a resume never // re-announces a decision it did not make. if (frozen.engineAnnouncement) result.warnings = [...(result.warnings ?? []), frozen.engineAnnouncement]; return result; }); } export async function getWorkflowStatus(runId, opts) { return withWorkflowRunsRepo((repo) => { const run = readWorkflowRun(repo, runId); const steps = readWorkflowRunSteps(repo, run.id); const detail = buildWorkflowRunDetail(repo, run, steps); if (opts?.includeUnits) { // The honest diagnostic surface (#22): read the unit journal straight and // project each row, INCLUDING failures whose diagnostic text the // deterministic evidence graph drops. Read-only; never mutates the run. detail.units = repo.getUnitsForRun(run.id).map(toUnitDiagnostic); } return detail; }); } export async function listWorkflowRuns(input) { const scopeKey = input?.allScopes === true ? null : getCurrentWorkflowScopeKey(); const activeOnly = input?.activeOnly === true; const includeChildren = input?.includeChildren === true; if (input?.workflowRef === undefined) { return withWorkflowRunsRepo((repo) => ({ runs: repo .listRuns({ scopeKey, ...(activeOnly ? { activeOnly: true } : {}), ...(includeChildren ? { includeChildren: true } : {}), }) .map(toWorkflowRunSummary), scopeKey, })); } const exactRef = input.workflowRef.trim(); if (!exactRef) { throw new UsageError("Workflow ref filter cannot be empty.", "INVALID_FLAG_VALUE"); } const parsedExactRef = parseBundleRef(exactRef); if (parsedExactRef.fragment !== undefined) { throw new UsageError("Workflow ref filters do not accept fragments.", "INVALID_FLAG_VALUE"); } let workflowRefs = [exactRef]; try { const canonicalRef = await canonicalizeWorkflowSpecifier(exactRef); workflowRefs = await workflowRunRefSet(canonicalRef, exactRef); } catch (error) { if (parsedExactRef.bundle !== undefined) { const exactRows = await withWorkflowRunsRepo((repo) => repo.listRuns({ scopeKey, workflowRef: exactRef, ...(includeChildren ? { includeChildren: true } : {}) })); if (exactRows.length === 0) throw error; return { runs: exactRows.filter((row) => !activeOnly || row.status === "active").map(toWorkflowRunSummary), scopeKey, }; } if (!(error instanceof NotFoundError)) throw error; } return withWorkflowRunsRepo((repo) => ({ runs: repo .listRuns({ scopeKey, workflowRefs, ...(activeOnly ? { activeOnly: true } : {}), ...(includeChildren ? { includeChildren: true } : {}), }) .map(toWorkflowRunSummary), scopeKey, })); } export async function getNextWorkflowStep(specifier, params, options) { return withWorkflowRunsRepo(async (repo) => { const { run, autoStarted, resumed, startWarnings } = await resolveRunSpecifier(repo, specifier, params, options?.parameterFlags, options?.newRun); const steps = readWorkflowRunSteps(repo, run.id); return { ...projectNextResult(run, steps), ...(autoStarted ? { autoStarted: true } : {}), ...(resumed ? { resumed: true } : {}), ...(startWarnings?.length ? { startWarnings } : {}), }; }); } /** * Project a run row + its step rows into a {@link WorkflowNextResult}. The pure * read-shaping half of {@link getNextWorkflowStep}, extracted so the run * snapshot below reproduces the exact same projection without re-running the * auto-start-capable {@link resolveRunSpecifier}. */ function projectNextResult(run, steps) { const currentStep = resolveCurrentStep(run, steps); const done = run.status === "completed" ? true : undefined; return { run: toWorkflowRunSummary(run), workflow: { ref: run.workflow_ref, title: run.workflow_title, steps: steps.map(toWorkflowRunStepState), }, step: currentStep ? toWorkflowRunStepState(currentStep) : null, ...(done ? { done } : {}), }; } export async function resumeWorkflowRun(runId) { return withWorkflowRunsRepo((repo) => { const run = readWorkflowRunOrPrefix(repo, runId); const steps = readWorkflowRunSteps(repo, run.id); if (run.status === "completed") { throw new UsageError(`Workflow run ${run.id} is already completed and cannot be resumed.`); } if (run.status === "active") { return buildWorkflowRunDetail(repo, run, steps); } // blocked or failed → flip back to active and re-open the current step so // it can be reclassified (completed, failed, skipped) after resuming. const now = new Date().toISOString(); repo.transaction(() => { if (run.current_step_id) { repo.reopenStepsForResume(run.id, run.current_step_id); } repo.markRunActive(run.id, now); }); const updated = { ...run, status: "active", updated_at: now }; return buildWorkflowRunDetail(repo, updated, readWorkflowRunSteps(repo, run.id)); }); } /** * Give up on a run (08-F6): flip it to `failed` so it stops counting as * active — the run-level verb the concurrency-guard message in * {@link startWorkflowRun} advertises. Terminal-state runs are refused; * {@link resumeWorkflowRun} can reopen an abandoned run if it was a mistake. */ export async function abandonWorkflowRun(runId) { return withWorkflowRunsRepo((repo) => { const now = new Date().toISOString(); const run = repo.immediateTransaction((db) => { const current = readWorkflowRunOrPrefix(repo, runId); if (current.status === "completed" || current.status === "failed") { throw new UsageError(`Workflow run ${current.id} is already ${current.status}.`); } if (!repo.markRunAbandoned(current.id, now)) { throw new UsageError(`Workflow run ${current.id} is ${current.status} and cannot be abandoned.`); } insertEventOnce(db, { eventType: "workflow_abandoned", ts: now, ref: current.workflow_ref, metadata: { runId: current.id }, idempotencyKey: current.id, idempotencyMetadataKey: "runId", }); return current; }); const updated = { ...run, status: "failed", updated_at: now, completed_at: now, }; return buildWorkflowRunDetail(repo, updated, readWorkflowRunSteps(repo, run.id)); }); } export async function completeWorkflowStep(input) { // Read the step (read-only) up front so the LLM validation gate runs OUTSIDE // the write transaction — a slow/hung LLM must never hold a db write lock. const preflight = await withWorkflowRunsRepo((repo) => { const run = readWorkflowRun(repo, input.runId); const storedPlan = requireRunPlan(run); if (run.status !== "active") { throw new UsageError(`Workflow run ${run.id} is ${run.status} and cannot be updated.`); } const existing = repo.getStep(run.id, input.stepId); if (!existing) { throw new NotFoundError(`Step "${input.stepId}" was not found in workflow run ${run.id}.`); } if (existing.status !== "pending") { throw new UsageError(`Step "${input.stepId}" is already ${existing.status} in workflow run ${run.id}.`); } if (run.current_step_id !== existing.step_id) { throw new UsageError(`Step "${input.stepId}" is not the current step for workflow run ${run.id}. Complete "${run.current_step_id}" first.`); } const stepPlan = storedPlan.steps.find((step) => step.stepId === input.stepId); if (!stepPlan) throw new NotFoundError(`Step "${input.stepId}" was not found in workflow run ${run.id}.`); return { existing, plan: storedPlan, stepPlan }; }); const summary = input.summary?.trim(); // #506: completing a step requires a summary of the work done. if (input.status === "completed" && !summary) { throw new UsageError(`Completing step "${input.stepId}" requires a --summary describing the work done.`, "MISSING_REQUIRED_ARGUMENT"); } // #506: validation gate — a criteria-bearing step must have a frozen judge // and receive an affirmative verdict before it can advance. if (input.status === "completed" && summary) { const criteria = preflight.stepPlan.gate.criteria; if (input.signal?.aborted) throw interruptionReason(input.signal); const judge = input.summaryJudge === undefined ? // Manual completion journals no gate row, so there is no `<stepId>.gate:l<loop>` // identity to agree with — but the dispatch still names the REAL run and // step (frozen-judge falls back to the gate node id for the unit id). frozenSummaryJudge(preflight.stepPlan.gate.frozenJudge, input.signal, undefined, { runId: input.runId, stepId: input.stepId, }) : input.summaryJudge; if (criteria.length > 0 && !judge) { throw new ConfigError(`Workflow run ${input.runId} has completion criteria for step "${input.stepId}" but its frozen plan has no judge. ` + "Set workflow.judgeEngine, abandon this run, and create a new one with `akm workflow run <ref>`.", "INVALID_CONFIG_FILE"); } const verdict = await validateStepSummary({ stepTitle: preflight.stepPlan.title, completionCriteria: criteria, summary }, judge ?? undefined, input.signal); if (!verdict.complete) { // Leave the step pending and return corrective feedback instead of completing. return { ok: false, runId: input.runId, stepId: input.stepId, missing: verdict.missing, feedback: verdict.feedback ?? "The summary does not satisfy the step's completion criteria.", }; } } if (input.signal?.aborted) throw interruptionReason(input.signal); return withWorkflowRunsRepo((repo) => { let updatedRun; let refreshedSteps = []; let outputWarnings; repo.transaction(() => { const run = readWorkflowRun(repo, input.runId); const plan = requireRunPlan(run); if (run.status !== "active") { throw new UsageError(`Workflow run ${run.id} is ${run.status} and cannot be updated.`); } const existing = repo.getStep(run.id, input.stepId); if (!existing) { throw new NotFoundError(`Step "${input.stepId}" was not found in workflow run ${run.id}.`); } if (existing.status !== "pending") { throw new UsageError(`Step "${input.stepId}" is already ${existing.status} in workflow run ${run.id}.`); } if (run.current_step_id !== existing.step_id) { throw new UsageError(`Step "${input.stepId}" is not the current step for workflow run ${run.id}. Complete "${run.current_step_id}" first.`); } if (input.signal?.aborted) throw interruptionReason(input.signal); const completedAt = new Date().toISOString(); // The promoted artifact is persisted whole, so a resume and every later // reference read the real value. const evidenceJson = input.evidence ? JSON.stringify(input.evidence) : null; repo.updateStepCompletion({ status: input.status, notes: input.notes?.trim() || null, evidenceJson, summary: summary || null, completedAt, runId: run.id, stepId: input.stepId, }); refreshedSteps = readWorkflowRunSteps(repo, run.id); const state = deriveRunState(refreshedSteps); // Declared outputs resolve inside this transaction once the run // completes; a failure rolls the step completion back whole. let outputsJson; // undefined = untouched, keep the row's existing value if (state.status === "completed" && plan.outputs) { const resolved = resolveWorkflowRunOutputs(plan, refreshedSteps); outputsJson = JSON.stringify(resolved.outputs); repo.setRunOutputs(run.id, outputsJson); outputWarnings = resolved.errors; } repo.updateRunState({ status: state.status, currentStepId: state.currentStepId, updatedAt: completedAt, completedAt: state.completedAt, runId: run.id, }); updatedRun = { ...run, status: state.status, current_step_id: state.currentStepId, updated_at: completedAt, completed_at: state.completedAt, ...(outputsJson !== undefined ? { outputs_json: outputsJson } : {}), }; }); const detail = buildWorkflowRunDetail(repo, updatedRun, refreshedSteps); if (outputWarnings?.length) { const messages = outputWarnings.map((e) => `Workflow run ${input.runId} declared ${e}`); for (const message of messages) warn(message); detail.warnings = [...(detail.warnings ?? []), ...messages]; } // #11: emit `workflow_step_completed` ONLY for a genuine `completed` // transition; every other non-pending status (failed/skipped/blocked) // carries the honest `workflow_step_updated` name. The status is ALWAYS // in metadata so consumers never infer it from the event name. Raw `notes` // are workflow/model-authored content — an event-stream prompt-injection // surface — and never enter the events log; they live on the step row only. appendEvent({ eventType: input.status === "completed" ? "workflow_step_completed" : "workflow_step_updated", ref: detail.run.workflowRef, metadata: { runId: input.runId, stepId: input.stepId, status: input.status }, }); if (detail.run.status === "completed") { appendEvent({ eventType: "workflow_finished", ref: detail.run.workflowRef, metadata: { runId: input.runId } }); } return detail; }); } async function resolveRunSpecifier(repo, specifier, params, parameterFlags, forceNew) { const hasParameters = (params && Object.keys(params).length > 0) || (parameterFlags?.length ?? 0) > 0; const explicitRun = findRunByIdOrPrefix(repo, specifier); if (explicitRun) { // `--new` starts a fresh run FROM A REF; it never makes sense against a // run id (or an id prefix), which already names the run to act on (#919). if (forceNew) { throw new UsageError(`--new starts a fresh run from a workflow ref; "${specifier}" already names a run id.`, "INVALID_FLAG_VALUE"); } if (hasParameters) { throw new UsageError(`Workflow parameter flags can only be used when starting a new run, not with existing run id "${specifier}".`); } return { run: explicitRun, autoStarted: false }; } const scopeKey = getCurrentWorkflowScopeKey(); const exactRef = specifier.trim(); const parsedExact = parseBundleRef(exactRef); const qualifiedExact = parsedExact.bundle !== undefined && parsedExact.fragment === undefined; const detached = qualifiedExact && !forceNew ? repo.getActiveRunRowForScope(exactRef, scopeKey) : undefined; let ref; try { ref = await canonicalizeWorkflowSpecifier(specifier); } catch (error) { if (detached) { if (hasParameters) { throw new UsageError(`Workflow parameter flags can only be set on a new run; ${specifier} is already active ` + `(id ${detached.id}, scope ${scopeKey}).`, "INVALID_FLAG_VALUE", `Pass --new to start a separate run, or run "akm workflow abandon ${detached.id}" to free up ${specifier} first.`); } return { run: detached, autoStarted: false, resumed: true }; } if (error instanceof NotFoundError && !specifier.includes(":") && !specifier.includes("/")) { throw new NotFoundError(`Workflow run or workflow "${specifier}" not found.`, "WORKFLOW_NOT_FOUND"); } throw error; } const active = forceNew ? undefined : repo.getActiveRunRowForScope(await workflowRunRefSet(ref, exactRef), scopeKey); if (active) { if (hasParameters) { throw new UsageError(`Workflow parameter flags can only be set on a new run; ${ref} is already active ` + `(id ${active.id}, scope ${scopeKey}).`, "INVALID_FLAG_VALUE", `Pass --new to start a separate run, or run "akm workflow abandon ${active.id}" to free up ${ref} first.`); } return { run: active, autoStarted: false, resumed: true }; } // `force` (#485's own escape hatch) is what lets `--new` create a second // active run for this (ref, scope) pair instead of the concurrency guard // refusing it — the caller explicitly asked for a fresh run, so the guard // that exists to catch an ACCIDENTAL second run does not apply (#919). const started = await startWorkflowRun(ref, params ?? {}, { ...(parameterFlags !== undefined ? { parameterFlags } : {}), ...(forceNew ? { force: true } : {}), }); return { run: readWorkflowRun(repo, started.run.id), autoStarted: true, ...(started.warnings?.length ? { startWarnings: started.warnings } : {}), }; } function interruptionReason(signal) { return signal.reason instanceof Error ? signal.reason : new Error("Workflow run interrupted."); } async function canonicalizeWorkflowSpecifier(specifier) { return canonicalizeWorkflowRefInput(specifier); } async function workflowRunRefSet(canonicalRef, exactRef) { const parsed = parseBundleRef(canonicalRef); const refs = new Set([canonicalRef, exactRef.trim()]); const exact = parseBundleRef(exactRef.trim()); if (exact.bundle === undefined) { refs.add(parsed.conceptId); } else { try { if ((await canonicalizeWorkflowSpecifier(parsed.conceptId)) === canonicalRef) refs.add(parsed.conceptId); } catch (error) { if (!(error instanceof NotFoundError)) throw error; } } return [...refs]; } /** * A workflow run id (or an accepted prefix of one, #919) is hex digits and * hyphens, 8+ characters. A workflow ref always contains at least one * character outside that set (a `/` path segment, at minimum), so this * never mistakes a ref for an id — matching input is ALWAYS resolved as an * id/prefix, never falls through to ref resolution. */ const RUN_ID_PREFIX_PATTERN = /^[0-9a-f-]{8,}$/; /** The run `specifier` names: by exact id, or by unique id prefix (#919) when it is id-shaped; `undefined` for a workflow ref. */ function findRunByIdOrPrefix(repo, specifier) { const exact = repo.getRunById(specifier); if (exact || !RUN_ID_PREFIX_PATTERN.test(specifier)) return exact; return repo.getRunById(repo.resolveRunIdPrefix(specifier)); } /** For verbs that take a run id/prefix OR a workflow ref (`status`): the run id, or `undefined` to fall through to ref resolution. */ export async function resolveWorkflowRunTarget(specifier) { return withWorkflowRunsRepo((repo) => findRunByIdOrPrefix(repo, specifier)?.id); } /** User-facing read: accepts an id prefix. Internal reads use {@link readWorkflowRun} with an exact id. */ function readWorkflowRunOrPrefix(repo, specifier) { const run = findRunByIdOrPrefix(repo, specifier); if (!run) throw new NotFoundError(`Workflow run "${specifier}" not found.`, "WORKFLOW_NOT_FOUND"); return run; } function readWorkflowRun(repo, runId) { const run = repo.getRunById(runId); if (!run) { throw new NotFoundError(`Workflow run "${runId}" not found.`, "WORKFLOW_NOT_FOUND"); } return run; } /** * The frozen plan a step completion resolves its gate/outputs against. The * engine has already read (or abandoned) the plan before it gets here; this * throw only covers a direct caller completing a step of a run whose plan is * unreadable. */ function requireRunPlan(run) { const read = readRunPlan(run); if (read.ok) return read.plan; throw new UsageError(read.newer ? read.problem : `${read.problem} Run 'akm workflow abandon ${run.id}' and start a new run with 'akm workflow run ${run.workflow_ref}'.`); } function readWorkflowRunSteps(repo, runId) { return repo.getStepsForRun(runId); } function buildWorkflowRunDetail(repo, run, steps) { const children = childRunTree(repo, run.id, run.id); return { run: toWorkflowRunSummary(run), workflow: { ref: run.workflow_ref, title: run.workflow_title, steps: steps.map(toWorkflowRunStepState), }, ...(children ? { children } : {}), }; } /** * The parent-child status tree for `forRunId`'s children, recursively. Every * blocked node's `resume.then` names the top-of-query run (`rootRunId`); a * block deeper than one level needs every blocked run resumed, deepest first * (docs/guides/run-workflows.md). Absent, never `[]`, when there are no children. */ function childRunTree(repo, rootRunId, forRunId) { const rows = repo.childRunsOf(forRunId); if (rows.length === 0) return undefined; return rows.map((row) => toChildRunNode(repo, rootRunId, row)); } function toChildRunNode(repo, rootRunId, row) { const spawnedByUnitId = row.parent_unit_id ?? ""; // the parent STEP that spawned it, resolved via the real journaled // unit row — null when that unit row is gone. const stepId = row.parent_run_id && row.parent_unit_id ? (repo.getUnit(row.parent_run_id, row.parent_unit_id)?.step_id ?? null) : null; const children = childRunTree(repo, rootRunId, row.id); return { runId: row.id, workflowRef: row.workflow_ref, workflowTitle: row.workflow_title, status: row.status, spawnedByUnitId, stepId, currentStepId: row.current_step_id, createdAt: row.created_at, updatedAt: row.updated_at, ...(row.status === "blocked" ? { resume: { command: `akm workflow resume ${row.id}`, // biome-ignore lint/suspicious/noThenProperty: mirrors spec §4.5's real WorkflowChildRunNode.resume.then field name then: `akm workflow resume ${rootRunId} && akm workflow run ${rootRunId}`, }, } : {}), ...(children ? { children } : {}), }; } function toWorkflowRunSummary(run) { return { id: run.id, workflowRef: run.workflow_ref, scopeKey: run.scope_key, workflowEntryId: run.workflow_entry_id, workflowTitle: run.workflow_title, status: run.status, currentStepId: run.current_step_id, createdAt: run.created_at, updatedAt: run.updated_at, completedAt: run.completed_at, params: parseJsonObject(run.params_json), agentHarness: run.agent_harness ?? null, agentSessionId: run.agent_session_id ?? null, planIrVersion: run.plan_ir_version ?? null, // P3b: all three optional and conditionally spread, so every // pre-existing (non-child, no-outputs-declared) run's envelope is // byte-identical. ...(run.outputs_json ? { outputs: parseJsonObject(run.outputs_json) ?? {} } : {}), ...(run.parent_run_id ? { parentRunId: run.parent_run_id } : {}), ...(run.parent_unit_id ? { spawnedByUnitId: run.parent_unit_id } : {}), }; } function toWorkflowRunStepState(step) { return { id: step.step_id, title: step.step_title, instructions: step.instructions, completionCriteria: parseJsonArray(step.completion_json), sequenceIndex: step.sequence_index, status: step.status, notes: step.notes ?? undefined, evidence: parseJsonObject(step.evidence_json), summary: step.summary ?? undefined, completedAt: step.completed_at, }; } function resolveCurrentStep(run, steps) { if (run.current_step_id) { return steps.find((step) => step.step_id === run.current_step_id); } return steps.find((step) => step.status === "pending"); } function deriveRunState(steps) { const unresolved = steps.find((step) => step.status === "failed" || step.status === "blocked"); if (unresolved) { return { status: unresolved.status === "failed" ? "failed" : "blocked", currentStepId: unresolved.step_id, completedAt: null, }; } const pending = steps.find((step) => step.status === "pending"); if (pending) { return { status: "active", currentStepId: pending.step_id, completedAt: null }; } const completedAt = steps .map((step) => step.completed_at) .filter((value) => typeof value === "string") .sort() .at(-1); return { status: "completed", currentStepId: null, completedAt: completedAt ?? null }; } function parseJsonObject(value) { if (!value) return undefined; try { const parsed = JSON.parse(value); if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { return parsed; } } catch { /* ignore corrupt data */ } return undefined; } function parseJsonArray(value) { if (!value) return undefined; try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) { return parsed.filter((item) => typeof item === "string"); } } catch { /* ignore corrupt data */ } return undefined; } export async function getActiveWorkflowRun(scopeKey = getCurrentWorkflowScopeKey()) { return withWorkflowRunsRepo((repo) => { const row = repo.findActiveOrBlockedRunForScope(scopeKey); if (!row) return null; return { runId: row.id, stepId: row.current_step_id, workflowRef: row.workflow_ref }; }).catch(() => null); // fail-open: never crash show output due to DB error }