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
JavaScript
// 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
}