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
473 lines (472 loc) • 21 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/.
/**
* Scheduler reconciliation: compile the desired bindings from a bundle's
* task and workflow sources, then diff them against the rows the native
* scheduler holds.
*
* `compileSchedulerSources` reads each source once. One source that fails
* to parse or prepare is reported and skipped; every other source still
* compiles (#867). `planSchedulerSync` is pure: given the desired bindings,
* the installed rows, and the bundles this sync owns, it decides which rows
* to install, update, remove, or leave alone. Rows it cannot attribute to
* one of those bundles are never touched.
*/
import fs from "node:fs";
import path from "node:path";
import { makeBundleRef, parseBundleRef } from "../core/asset/asset-ref.js";
import { compareCodePoints, toPosix } from "../core/common.js";
import { UsageError } from "../core/errors.js";
import { canonicalizeWorkflowName, WORKFLOW_EXTENSIONS } from "../core/recognition-util.js";
import { applyInputDefaults, validateInputs } from "../execution/input-contract.js";
import { checkWorkflowPlan, compileWorkflowSource } from "../workflows/compile.js";
import { WorkflowSourceCollisionError, WorkflowSourceNameError, workflowNameForSourcePath, } from "../workflows/source-files.js";
import { prepareTaskV3Execution } from "./prepare/prepare.js";
import { parseSchedule } from "./schedule.js";
import { compileTaskSchedulerBindings, compileWorkflowSchedulerBindings, schedulerBindingNativeId, schedulerNativeBindingId, } from "./scheduler-binding.js";
import { parseTaskSource } from "./source/parse-task-source.js";
import { projectTaskSourceV4 } from "./source/project-v4.js";
import { taskSourceErrorDetail } from "./source-v3.js";
const SCHEDULER_PROJECTION_CONFIG = Object.freeze({
configVersion: "0.9.0",
semanticSearchMode: "off",
});
export async function compileSchedulerSources(input) {
const sourceRoot = path.resolve(input.sourceRoot);
const desired = [];
const failures = [];
await compileTaskSources({ ...input, sourceRoot }, desired, failures);
compileWorkflowSources({ ...input, sourceRoot }, desired, failures);
return Object.freeze({
desired: Object.freeze(desired),
failures: Object.freeze(failures.sort((left, right) => compareCodePoints(left.path, right.path))),
});
}
async function compileTaskSources(input, desired, failures) {
for (const file of taskSourceFiles(input.sourceRoot, input.adapterId)) {
const relative = toPosix(path.relative(input.sourceRoot, file));
const conceptId = relative.slice(0, -".yml".length);
const id = input.adapterId === "akm-task" ? conceptId : path.basename(file, ".yml");
const ref = sourceRef(input.bundleName, conceptId);
if (ref === undefined || (input.enabledRefs && !input.enabledRefs.has(ref)))
continue;
try {
const parsed = parseTaskSource({
yaml: fs.readFileSync(file, "utf8"),
filePath: file,
workspaceRoot: input.sourceRoot,
});
// A task with no `schedule:` is run by hand or by a workflow step; it
// contributes no binding and is not sync's to validate.
if (parsed.v4.schedule.length === 0)
continue;
assertTaskScheduleInputsSatisfyContract(parsed.v4, ref);
assertTaskScheduleCronValid(parsed.v4, input.backend);
// Project so a target that cannot resolve (a missing workflow, an
// unconfigured engine) is reported here rather than at fire time.
// Bindings compile from the original document: the projection drops
// `schedule[i].inputs`, which are delivered through the invocation tail.
await prepareTaskV3Execution(projectTaskSourceV4(parsed.v4), {
taskId: id,
taskRef: ref,
bundleName: input.bundleName,
bundleRoot: input.sourceRoot,
config: input.config ?? SCHEDULER_PROJECTION_CONFIG,
...(input.resolveAsset ? { resolveAsset: input.resolveAsset } : {}),
});
desired.push(...compileTaskSchedulerBindings({
id,
qualifiedRef: ref,
schedules: parsed.v4.schedule.map((schedule) => ({
cron: schedule.cron,
ordinal: schedule.ordinal,
source: `${relative}:${schedule.source}`,
inputs: schedule.inputs,
})),
}));
}
catch (cause) {
const detail = taskSourceErrorDetail(cause);
failures.push({ path: file, ref, reason: detail === errorMessage(cause) ? `${file}: ${detail}` : detail });
}
}
}
/** Task candidates: `tasks/*.yml` for the akm adapter, every `.yml` in the tree for `akm-task`. */
function taskSourceFiles(sourceRoot, adapterId) {
if (adapterId === "akm") {
const dir = path.join(sourceRoot, "tasks");
const names = listDirectory(dir);
return names
.filter((name) => name.endsWith(".yml"))
.map((name) => path.join(dir, name))
.filter((file) => statOrUndefined(file)?.isFile() === true)
.sort(compareCodePoints);
}
if (adapterId === "akm-task")
return walkFiles(sourceRoot).filter((file) => file.endsWith(".yml"));
return [];
}
function compileWorkflowSources(input, desired, failures) {
if (input.adapterId !== "akm" && input.adapterId !== "akm-workflow")
return;
const root = input.adapterId === "akm" ? path.join(input.sourceRoot, "workflows") : input.sourceRoot;
const byName = new Map();
for (const file of walkFiles(root)) {
if (path.basename(file).toLowerCase() === "readme.md")
continue;
const authoredName = workflowNameForSourcePath(input.sourceRoot, input.adapterId, file);
if (authoredName === undefined)
continue;
const extension = path.posix.extname(authoredName).toLowerCase();
const stem = authoredName.slice(0, -extension.length).toLowerCase();
const nestedSuffix = WORKFLOW_EXTENSIONS.find((suffix) => stem.endsWith(suffix));
if (nestedSuffix) {
const relative = toPosix(path.relative(input.sourceRoot, file));
failures.push({ path: file, reason: new WorkflowSourceNameError(relative, nestedSuffix).message });
continue;
}
const canonicalName = canonicalizeWorkflowName(authoredName);
byName.set(canonicalName, [...(byName.get(canonicalName) ?? []), file]);
}
for (const [canonicalName, sources] of [...byName].sort(([left], [right]) => compareCodePoints(left, right))) {
const conceptId = input.adapterId === "akm" ? `workflows/${canonicalName}` : canonicalName;
const ref = sourceRef(input.bundleName, conceptId);
if (ref === undefined || (input.enabledRefs && !input.enabledRefs.has(ref)))
continue;
const file = sources[0];
try {
if (sources.length > 1) {
throw new WorkflowSourceCollisionError(conceptId, sources.map((source) => toPosix(path.relative(input.sourceRoot, source))));
}
const relative = toPosix(path.relative(input.sourceRoot, file));
const compiled = compileWorkflowSource(fs.readFileSync(file, "utf8"), {
path: relative,
workspaceRoot: input.sourceRoot,
});
if (!compiled.ok) {
throw new UsageError(compiled.errors
.map((error) => `${error.path}:${error.line ?? 1} [${error.code}] ${error.message}`)
.join("; "), "WORKFLOW_SOURCE_INVALID");
}
const planDraft = checkWorkflowPlan(compiled.plan);
if (!planDraft.ok) {
throw new UsageError(planDraft.errors.map((error) => `${relative}:${error.line} ${error.message}`).join("; "), "WORKFLOW_SOURCE_INVALID");
}
const schedules = (compiled.plan.schedules ?? []).map((schedule) => ({
cron: schedule.cron,
source: `${relative}:${schedule.line}`,
ordinal: schedule.ordinal,
}));
for (const binding of compileWorkflowSchedulerBindings({ qualifiedRef: ref, schedules })) {
parseSchedule(binding.cron, input.backend);
desired.push(binding);
}
}
catch (cause) {
failures.push({ path: file, ref, reason: errorMessage(cause) });
}
}
}
/** A file whose name cannot form a ref (a `#` in it) cannot be enabled, so it is not this sync's. */
function sourceRef(bundleName, conceptId) {
try {
return makeBundleRef(bundleName, conceptId);
}
catch {
return undefined;
}
}
/** Directory entries, or none when the directory does not exist. Any other read error is the bundle's own failure. */
function listDirectory(dir) {
try {
return fs.readdirSync(dir);
}
catch (error) {
if (error.code === "ENOENT")
return [];
throw error;
}
}
/** Every regular file under `dir` (symlinks followed), sorted. */
function walkFiles(dir) {
const files = [];
const visit = (directory) => {
for (const name of listDirectory(directory)) {
const candidate = path.join(directory, name);
const stat = statOrUndefined(candidate);
if (stat?.isDirectory())
visit(candidate);
else if (stat?.isFile())
files.push(candidate);
}
};
visit(dir);
return files.sort(compareCodePoints);
}
/** A dangling or looping symlink is not a source file; it is skipped, never fatal. */
function statOrUndefined(file) {
try {
return fs.statSync(file);
}
catch {
return undefined;
}
}
/**
* Validate every v4 `schedule:` entry's `inputs` against the task's own
* declared contract with defaults applied — the exact set of values a
* compiled invocation delivers — so a violation is reported at sync rather
* than when the scheduler fires. Shared with `akm task validate`.
*/
export function assertTaskScheduleInputsSatisfyContract(v4, refLabel) {
const contract = v4.inputs ?? {};
for (const scheduleEntry of v4.schedule) {
const defaultedInputs = applyInputDefaults(contract, { ...scheduleEntry.inputs });
const errors = validateInputs(contract, defaultedInputs);
if (errors.length > 0) {
throw new UsageError(`Task ${JSON.stringify(refLabel)} schedule[${scheduleEntry.ordinal}].inputs does not satisfy ` +
`its declared inputs once defaults are applied: ${errors.join("; ")}`, "TASK_SOURCE_INVALID");
}
}
}
/**
* Validate every v4 `schedule:` entry's `cron` against the active backend's
* dialect: cron is the most permissive of the three, so a task authored on
* Linux can carry an expression launchd/schtasks cannot translate. Shared
* with `akm task validate`.
*/
export function assertTaskScheduleCronValid(v4, backend) {
for (const scheduleEntry of v4.schedule)
parseSchedule(scheduleEntry.cron, backend);
}
export function installedRowNativeId(row) {
return row.nativeId ?? schedulerNativeBindingId(row.id);
}
/** The bundle an installed row's own invocation names: `--bundle <x>`, or a workflow ref's bundle. */
export function scheduledInvocationBundle(invocation) {
if (!invocation)
return undefined;
const bundleIndex = invocation.indexOf("--bundle");
if (bundleIndex >= 0)
return invocation[bundleIndex + 1];
if (invocation[0] === "workflow" && invocation[1] === "run" && invocation[2]) {
try {
return parseBundleRef(invocation[2]).bundle;
}
catch {
return undefined;
}
}
return undefined;
}
/**
* The scope that owns an installed row, or none. A row written before
* `--bundle` existed names no bundle and belongs to the primary scope
* unless the bundle path it carries says otherwise; a row that names a
* bundle belongs to the scope of that name — for the primary scope only
* when its `AKM_BUNDLE_DIR` (inline, or in an older row's descriptor) also
* proves the path.
*/
export function installedRowScope(row, scopes) {
const bundle = row.target ?? scheduledInvocationBundle(row.invocation);
for (const scope of scopes) {
if (scope.bundlePath === undefined) {
if (bundle === scope.bundleName)
return scope;
continue;
}
if (bundle === undefined) {
if (row.ownerBundlePath === undefined || row.ownerBundlePath === scope.bundlePath)
return scope;
continue;
}
if (bundle === scope.bundleName && row.ownerBundlePath === scope.bundlePath)
return scope;
}
return undefined;
}
export function planSchedulerSync(input) {
const failures = [];
const installedByNative = new Map();
for (const row of input.installed)
installedByNative.set(installedRowNativeId(row), row);
// Two sources claiming one native row — compared case-insensitively, as
// Task Scheduler and a default macOS volume compare them: neither is
// installed and the row, if any, is left exactly as it is.
const desiredByNative = new Map();
for (const binding of input.desired) {
const key = schedulerBindingNativeId(binding).toLowerCase();
desiredByNative.set(key, [...(desiredByNative.get(key) ?? []), binding]);
}
const skipped = new Set();
for (const group of desiredByNative.values()) {
if (group.length < 2)
continue;
const refs = group.map((binding) => JSON.stringify(binding.logicalSource.ref)).join(" and ");
for (const binding of group) {
const nativeId = schedulerBindingNativeId(binding);
skipped.add(nativeId);
failures.push({
path: binding.source,
ref: binding.logicalSource.ref,
reason: `Scheduler id ${JSON.stringify(nativeId)} is claimed by ${refs}. Rename one task before enabling both.`,
});
}
}
const installed = [];
const updated = [];
const unchanged = [];
const writes = [];
for (const binding of input.desired) {
const nativeId = schedulerBindingNativeId(binding);
if (skipped.has(nativeId))
continue;
const current = installedByNative.get(nativeId);
if (current &&
installedRowScope(current, input.scopes)?.bundleName !== parseBundleRef(binding.logicalSource.ref).bundle) {
skipped.add(nativeId);
failures.push({
path: binding.source,
ref: binding.logicalSource.ref,
reason: foreignRowMessage(nativeId, current),
});
continue;
}
const options = installOptionsFor(input, current);
const expected = input.expectedSignature?.(binding, options);
if (!current) {
installed.push(binding.id);
writes.push(Object.freeze({
kind: "install",
binding,
...(options ? { options } : {}),
...(expected !== undefined ? { expectedSignature: expected } : {}),
}));
continue;
}
if (expected === undefined || current.signature === expected) {
unchanged.push(binding.id);
continue;
}
updated.push(binding.id);
writes.push(Object.freeze({
kind: "update",
binding,
...(options ? { options } : {}),
...(current.signature !== undefined ? { installedSignature: current.signature } : {}),
expectedSignature: expected,
}));
}
const desiredNative = new Set(input.desired.map(schedulerBindingNativeId));
const removals = new Map();
for (const row of input.installed) {
const nativeId = installedRowNativeId(row);
if (desiredNative.has(nativeId) || skipped.has(nativeId))
continue;
const scope = installedRowScope(row, input.scopes);
if (!scope)
continue;
const ref = installedRowRef(row, scope);
if (ref === undefined || !input.keepRefs?.has(ref))
removals.set(nativeId, row);
}
for (const row of input.extraRemovals ?? []) {
const nativeId = installedRowNativeId(row);
if (!desiredNative.has(nativeId) && !removals.has(nativeId))
removals.set(nativeId, row);
}
const removeOperations = [...removals.values()]
.sort((left, right) => compareCodePoints(left.id, right.id))
.map((row) => Object.freeze({
kind: "remove",
id: row.id,
nativeId: installedRowNativeId(row),
...(row.ownerBundlePath !== undefined ? { ownerBundlePath: row.ownerBundlePath } : {}),
}));
return Object.freeze({
desired: input.desired,
installed: Object.freeze(installed),
updated: Object.freeze(updated),
removed: Object.freeze(removeOperations.map((operation) => operation.id)),
unchanged: Object.freeze(unchanged),
operations: Object.freeze([...removeOperations, ...writes]),
failures: Object.freeze(failures),
});
}
/** An installed row keeps its own launcher unless `rebind`; its environment always follows the current policy. */
function installOptionsFor(input, current) {
if (current && !input.rebind) {
const environment = input.installOptions?.environment ?? current.environment;
return Object.freeze({
binding: Object.freeze([...current.binding]),
...(environment !== undefined ? { environment } : {}),
});
}
return input.installOptions ? Object.freeze({ ...input.installOptions }) : undefined;
}
/** The source ref an installed row runs, read from its own invocation. */
function installedRowRef(row, scope) {
const [verb, action, subject] = row.invocation ?? [];
if (action !== "run" || !subject)
return undefined;
if (verb === "workflow")
return subject;
if (verb !== "task")
return undefined;
return `${scope.bundleName}//${scope.adapterId === "akm-task" ? subject : `tasks/${subject}`}`;
}
/** Where an installed row was scheduled from, for messages. */
export function installedRowOwner(row) {
const bundle = row.target ?? scheduledInvocationBundle(row.invocation);
const installation = row.ownerBundlePath
? `the installation whose working stash is ${JSON.stringify(row.ownerBundlePath)}`
: undefined;
if (bundle)
return installation ? `bundle ${JSON.stringify(bundle)} of ${installation}` : `bundle ${JSON.stringify(bundle)}`;
return installation ?? "an installation this sync cannot attribute";
}
function foreignRowMessage(nativeId, row) {
return `Scheduler id ${JSON.stringify(nativeId)} is already scheduled from ${installedRowOwner(row)}; desired source ids must not collide across bundles. Leaving it out of this sync.`;
}
/** Project a set of operations into a preview report. Pure: no I/O, no backend calls. */
export function renderSchedulerPlanPreview(backend, operations, unchanged = [], failures = []) {
const adds = [];
const updates = [];
const removes = [];
for (const operation of operations) {
if (operation.kind === "remove") {
removes.push({
id: operation.id,
kind: "remove",
nativeId: operation.nativeId,
...(operation.ownerBundlePath !== undefined ? { ownerBundlePath: operation.ownerBundlePath } : {}),
...(operation.reason !== undefined ? { reason: operation.reason } : {}),
});
}
else if (operation.kind === "install") {
adds.push({ id: operation.binding.id, kind: "install" });
}
else {
updates.push({
id: operation.binding.id,
kind: "update",
...(operation.installedSignature !== undefined ? { installedFingerprint: operation.installedSignature } : {}),
...(operation.expectedSignature !== undefined ? { expectedFingerprint: operation.expectedSignature } : {}),
});
}
}
return {
backend,
dryRun: true,
adds,
updates,
removes,
unchanged: [...unchanged],
hasRemovals: removes.length > 0,
failures: [...failures],
};
}
function errorMessage(cause) {
return cause instanceof Error ? cause.message : String(cause);
}