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
173 lines (172 loc) • 8.15 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/.
/**
* Freeze a compiled workflow plan into the plan a run executes. Every step's
* authored spec resolves through the shared command/task authorities into a
* frozen `root` (engine, model, timeout, concurrency, dispatch target and
* environment descriptors); gates get their judge; the compile-only fields are
* dropped; the source file's sha256 is recorded. Nothing is published here.
*/
import { createHash } from "node:crypto";
import { ConfigError, UsageError } from "../../core/errors.js";
import { warn } from "../../core/warn.js";
import { NO_ENGINE_MESSAGE_SUFFIX } from "../../integrations/agent/engine-fallback.js";
import { resolveExecution } from "../../integrations/agent/execution.js";
import { checkWorkflowPlan } from "../compile.js";
import { defaultMapConcurrency, workflowMaxConcurrency } from "../concurrency-policy.js";
import { canonicalJson } from "../ir/plan-hash.js";
import { WORKFLOW_PLAN_VERSION, } from "../plan.js";
import { classifyWorkflowStepUses } from "../source-semantics.js";
import { assertChildOutputReferences } from "./child-output-references.js";
import { qualifyRef } from "./environment.js";
import { baseUnitOf } from "./step-values.js";
import { childWorkflowDispatch } from "./targets/child-workflow.js";
import { commandDispatch, commandResult } from "./targets/command.js";
import { directScript } from "./targets/script.js";
import { directShell } from "./targets/shell.js";
import { taskDispatch } from "./targets/task.js";
/** Resolve and freeze one workflow (and, recursively, every child it composes). */
export async function freezeWorkflow(asset, config, refPath = [asset.ref]) {
const compiled = asset.plan;
const checked = checkWorkflowPlan(compiled);
if (!checked.ok) {
throw new UsageError(checked.errors.map((error) => `${asset.path}:${error.line}: ${error.message}`).join("\n"), "WORKFLOW_SOURCE_INVALID");
}
const context = {
asset,
config,
plan: compiled,
refPath,
freezeChild: (child, childRefPath) => freezeWorkflow(child, config, childRefPath),
};
let engineAnnouncement;
const steps = [];
for (const step of compiled.steps) {
const { spec, ...frozenStep } = step;
const source = { ...(spec ?? { source: { path: asset.path, start: 1, end: 1 } }), id: step.stepId };
let root;
if (!step.route) {
const resolved = await resolveStep(source, context);
engineAnnouncement ??= resolved.engineAnnouncement;
root = frozenRoot(step.stepId, source, resolved, compiled, config);
}
let frozenJudge = null;
if (step.gate.criteria.length > 0) {
const judge = resolveJudge(source, step.gate.criteria[0] ?? "", context);
if (judge) {
frozenJudge = judge.target;
engineAnnouncement ??= judge.engineAnnouncement;
}
else {
warn(`Workflow step "${step.stepId}" has completion criteria but no verification engine is available ` +
"(set workflow.judgeEngine, defaults.engine, or install an opencode-sdk binary). The step will block " +
"for `akm workflow resume` once it is reached.");
}
}
steps.push({ ...frozenStep, ...(root ? { root } : {}), gate: { ...step.gate, frozenJudge } });
}
assertChildOutputReferences(steps);
const plan = {
irVersion: WORKFLOW_PLAN_VERSION,
title: asset.title,
...(compiled.params ? { params: compiled.params } : {}),
...(compiled.paramSchemas ? { paramSchemas: compiled.paramSchemas } : {}),
...(compiled.budget ? { budget: compiled.budget } : {}),
...(compiled.outputs ? { outputs: compiled.outputs } : {}),
execution: { maxConcurrency: workflowMaxConcurrency(config.workflow?.maxConcurrency) },
sourceHash: asset.sourceHash,
steps,
};
return { plan, warnings: checked.warnings, ...(engineAnnouncement ? { engineAnnouncement } : {}) };
}
async function resolveStep(source, context) {
const baseUnit = baseUnitOf(source);
if (source.exec)
return directShell(source, baseUnit, context);
if (!source.uses)
throw new Error(`workflow step ${source.id} has neither exec nor uses`);
const target = classifyWorkflowStepUses(source.uses);
if (target.kind === "task")
return taskDispatch(source, baseUnit, target.ref, context);
if (target.kind === "workflow") {
return childWorkflowDispatch({
source,
baseUnit,
childRefInput: target.ref,
context,
via: "direct",
authoredInputs: { kind: "with", value: source.with },
});
}
// `commands/<ref>` and `scripts/<ref>` are not binding surfaces; `akm/command`'s
// `with:` is its own argument bag.
if (target.kind !== "builtin-command" && source.with !== undefined) {
const family = target.kind === "command" ? "commands" : "scripts";
throw new UsageError(`Workflow step ${source.id} cannot pass with: to ${family} target ${target.ref}; a ${target.kind} ref is not a binding surface.`, "COMPOSITION_INVALID");
}
if (target.kind === "script")
return directScript(source, baseUnit, target.ref, context);
const action = target.kind === "builtin-command"
? source.with
: { ref: qualifyRef(target.ref, "commands", context.asset, context.config) };
return commandDispatch(source, baseUnit, action, context);
}
/** The gate judge's frozen command target, or undefined when no engine is available and none is configured. */
function resolveJudge(source, rubric, context) {
const configuredEngine = context.config.workflow?.judgeEngine;
try {
const prepared = resolveExecution({
content: rubric.trim() || "Judge workflow completion.",
config: context.config,
...(configuredEngine ? { current: { engine: configuredEngine } } : {}),
});
return commandResult(source, { onError: "fail", source: source.source }, prepared, context);
}
catch (err) {
if (configuredEngine || !(err instanceof ConfigError && err.message.includes(NO_ENGINE_MESSAGE_SUFFIX)))
throw err;
return undefined;
}
}
function frozenRoot(stepId, source, resolved, plan, config) {
const unit = resolved.unit;
const target = resolved.target.kind === "shell"
? {
...resolved.target,
contentHash: createHash("sha256")
.update("akm.workflow.shell.v1\0")
.update(canonicalJson({
exec: resolved.target.exec,
environment: resolved.environment,
cwdIdentity: resolved.target.cwdIdentity,
}))
.digest("hex"),
}
: resolved.target;
const node = {
kind: "unit",
id: source.map ? `${stepId}.unit` : stepId,
instructions: resolved.instructions,
...(source.inputs && source.inputs.length > 0 ? { inputs: [...source.inputs] } : {}),
...(unit.output !== undefined ? { schema: unit.output } : {}),
...(unit.retry ? { retry: { max: unit.retry.max, on: [...unit.retry.on] } } : {}),
onError: unit.onError ?? plan.defaults?.onError ?? "fail",
...(unit.env ? { env: [...unit.env] } : {}),
isolation: unit.isolation ?? "none",
source: { ...source.source },
frozenTarget: target,
environment: [...resolved.environment],
};
if (!source.map)
return node;
return {
kind: "map",
id: `${stepId}.map`,
over: source.map.over,
template: node,
concurrency: source.map.concurrency ?? defaultMapConcurrency(config.workflow?.defaultMapConcurrency),
reducer: source.map.reducer ?? "collect",
source: { ...source.source },
};
}