akm-cli 0.7.5 → 0.8.0-rc1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/CHANGELOG.md +1 -1
- package/dist/cli/parse-args.js +43 -0
- package/dist/cli.js +804 -461
- package/dist/commands/agent-dispatch.js +102 -0
- package/dist/commands/agent-support.js +62 -0
- package/dist/commands/config-cli.js +68 -84
- package/dist/commands/consolidate.js +823 -0
- package/dist/commands/distill-promotion-policy.js +658 -0
- package/dist/commands/distill.js +244 -52
- package/dist/commands/eval-cases.js +40 -0
- package/dist/commands/events.js +2 -23
- package/dist/commands/graph.js +222 -0
- package/dist/commands/health.js +376 -0
- package/dist/commands/help/help-accept.md +9 -0
- package/dist/commands/help/help-improve.md +53 -0
- package/dist/commands/help/help-proposals.md +15 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +8 -0
- package/dist/commands/history.js +3 -30
- package/dist/commands/improve.js +1170 -0
- package/dist/commands/info.js +2 -2
- package/dist/commands/init.js +2 -2
- package/dist/commands/install-audit.js +5 -1
- package/dist/commands/installed-stashes.js +118 -138
- package/dist/commands/knowledge.js +133 -0
- package/dist/commands/lint/agent-linter.js +46 -0
- package/dist/commands/lint/base-linter.js +251 -0
- package/dist/commands/lint/command-linter.js +46 -0
- package/dist/commands/lint/default-linter.js +13 -0
- package/dist/commands/lint/index.js +107 -0
- package/dist/commands/lint/knowledge-linter.js +13 -0
- package/dist/commands/lint/memory-linter.js +58 -0
- package/dist/commands/lint/registry.js +33 -0
- package/dist/commands/lint/skill-linter.js +42 -0
- package/dist/commands/lint/task-linter.js +47 -0
- package/dist/commands/lint/types.js +1 -0
- package/dist/commands/lint/workflow-linter.js +53 -0
- package/dist/commands/lint.js +1 -0
- package/dist/commands/proposal.js +8 -7
- package/dist/commands/propose.js +78 -28
- package/dist/commands/reflect.js +143 -35
- package/dist/commands/registry-search.js +2 -2
- package/dist/commands/remember.js +54 -0
- package/dist/commands/schema-repair.js +130 -0
- package/dist/commands/search.js +21 -5
- package/dist/commands/show.js +121 -17
- package/dist/commands/source-add.js +10 -10
- package/dist/commands/source-manage.js +11 -19
- package/dist/commands/tasks.js +385 -0
- package/dist/commands/url-checker.js +39 -0
- package/dist/commands/vault.js +2 -23
- package/dist/core/action-contributors.js +25 -0
- package/dist/core/asset-registry.js +4 -16
- package/dist/core/asset-spec.js +10 -0
- package/dist/core/common.js +94 -0
- package/dist/core/concurrent.js +22 -0
- package/dist/core/config.js +222 -128
- package/dist/core/events.js +73 -126
- package/dist/core/frontmatter.js +3 -1
- package/dist/core/markdown.js +17 -0
- package/dist/core/memory-improve.js +678 -0
- package/dist/core/parse.js +155 -0
- package/dist/core/paths.js +101 -3
- package/dist/core/proposal-validators.js +61 -0
- package/dist/core/proposals.js +49 -38
- package/dist/core/state-db.js +775 -0
- package/dist/core/time.js +51 -0
- package/dist/core/warn.js +59 -1
- package/dist/indexer/db-search.js +52 -238
- package/dist/indexer/db.js +377 -1
- package/dist/indexer/ensure-index.js +61 -0
- package/dist/indexer/graph-boost.js +247 -94
- package/dist/indexer/graph-db.js +201 -0
- package/dist/indexer/graph-dedup.js +99 -0
- package/dist/indexer/graph-extraction.js +409 -76
- package/dist/indexer/index-context.js +10 -0
- package/dist/indexer/indexer.js +442 -290
- package/dist/indexer/llm-cache.js +47 -0
- package/dist/indexer/match-contributors.js +141 -0
- package/dist/indexer/matchers.js +24 -190
- package/dist/indexer/memory-inference.js +63 -29
- package/dist/indexer/metadata-contributors.js +26 -0
- package/dist/indexer/metadata.js +188 -175
- package/dist/indexer/path-resolver.js +89 -0
- package/dist/indexer/ranking-contributors.js +204 -0
- package/dist/indexer/ranking.js +74 -0
- package/dist/indexer/search-hit-enrichers.js +22 -0
- package/dist/indexer/search-source.js +24 -9
- package/dist/indexer/semantic-status.js +2 -16
- package/dist/indexer/walker.js +25 -0
- package/dist/integrations/agent/config.js +175 -3
- package/dist/integrations/agent/index.js +3 -1
- package/dist/integrations/agent/pipeline.js +39 -0
- package/dist/integrations/agent/profiles.js +67 -5
- package/dist/integrations/agent/prompts.js +77 -72
- package/dist/integrations/agent/runners.js +31 -0
- package/dist/integrations/agent/sdk-runner.js +120 -0
- package/dist/integrations/agent/spawn.js +71 -16
- package/dist/integrations/lockfile.js +10 -18
- package/dist/integrations/session-logs/index.js +65 -0
- package/dist/integrations/session-logs/providers/claude-code.js +56 -0
- package/dist/integrations/session-logs/providers/opencode.js +52 -0
- package/dist/integrations/session-logs/types.js +1 -0
- package/dist/llm/call-ai.js +74 -0
- package/dist/llm/client.js +61 -122
- package/dist/llm/feature-gate.js +27 -16
- package/dist/llm/graph-extract.js +297 -62
- package/dist/llm/memory-infer.js +49 -71
- package/dist/llm/metadata-enhance.js +39 -22
- package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
- package/dist/output/cli-hints-full.md +277 -0
- package/dist/output/cli-hints-short.md +65 -0
- package/dist/output/cli-hints.js +2 -318
- package/dist/output/renderers.js +190 -123
- package/dist/output/shapes.js +33 -0
- package/dist/output/text.js +239 -2
- package/dist/registry/providers/skills-sh.js +61 -49
- package/dist/registry/providers/static-index.js +44 -48
- package/dist/setup/setup.js +510 -11
- package/dist/sources/provider-factory.js +2 -1
- package/dist/sources/providers/git.js +2 -2
- package/dist/sources/website-ingest.js +4 -0
- package/dist/tasks/backends/cron.js +200 -0
- package/dist/tasks/backends/exec-utils.js +25 -0
- package/dist/tasks/backends/index.js +32 -0
- package/dist/tasks/backends/launchd-template.xml +19 -0
- package/dist/tasks/backends/launchd.js +184 -0
- package/dist/tasks/backends/schtasks-template.xml +29 -0
- package/dist/tasks/backends/schtasks.js +212 -0
- package/dist/tasks/parser.js +198 -0
- package/dist/tasks/resolveAkmBin.js +84 -0
- package/dist/tasks/runner.js +432 -0
- package/dist/tasks/schedule.js +208 -0
- package/dist/tasks/schema.js +13 -0
- package/dist/tasks/validator.js +59 -0
- package/dist/wiki/index-template.md +12 -0
- package/dist/wiki/ingest-workflow-template.md +54 -0
- package/dist/wiki/log-template.md +8 -0
- package/dist/wiki/schema-template.md +61 -0
- package/dist/wiki/wiki-templates.js +12 -0
- package/dist/wiki/wiki.js +10 -61
- package/dist/workflows/authoring.js +5 -25
- package/dist/workflows/renderer.js +8 -3
- package/dist/workflows/runs.js +59 -91
- package/dist/workflows/validator.js +1 -1
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +3 -0
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.8.0.md +43 -0
- package/package.json +3 -2
- package/dist/templates/wiki-templates.js +0 -100
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `akm tasks run <id>` — what cron / launchd / schtasks invoke at the
|
|
3
|
+
* scheduled moment.
|
|
4
|
+
*
|
|
5
|
+
* Responsibilities:
|
|
6
|
+
*
|
|
7
|
+
* 1. Resolve the task file via `resolveAssetPath(stashDir, "task", id)`.
|
|
8
|
+
* 2. Parse the task document. (Validation runs at `tasks add` /
|
|
9
|
+
* `tasks sync` time, not here — at run time we still want to attempt
|
|
10
|
+
* execution and surface the actual failure rather than re-fail on a
|
|
11
|
+
* validation error that the user already knows about.)
|
|
12
|
+
* 3. Refuse to run when `enabled === false` (defense-in-depth).
|
|
13
|
+
* 4. Dispatch by target kind:
|
|
14
|
+
* • workflow → `startWorkflowRun(ref, params)`
|
|
15
|
+
* • prompt → `runAgent(profile, prompt, { stdio: "captured" })`
|
|
16
|
+
* 5. Capture stdout / stderr to `<cacheDir>/tasks/logs/<id>/<ts>.log`.
|
|
17
|
+
* 6. Write a history row to state.db task_history table.
|
|
18
|
+
*
|
|
19
|
+
* Returns a structured result so the CLI handler can shape it for `output()`
|
|
20
|
+
* and so tests can assert against it without scraping stdout.
|
|
21
|
+
*/
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { parseAssetRef } from "../core/asset-ref";
|
|
25
|
+
import { resolveStashDir } from "../core/common";
|
|
26
|
+
import { loadConfig } from "../core/config";
|
|
27
|
+
import { NotFoundError } from "../core/errors";
|
|
28
|
+
import { getTaskLogDir } from "../core/paths";
|
|
29
|
+
import { getTaskHistory, openStateDatabase, queryTaskHistory, upsertTaskHistory } from "../core/state-db";
|
|
30
|
+
import { error } from "../core/warn";
|
|
31
|
+
import { requireAgentProfile, runAgent } from "../integrations/agent";
|
|
32
|
+
import { resolveProcessAgentProfile } from "../integrations/agent/config";
|
|
33
|
+
import { resolveAssetPath } from "../sources/resolve";
|
|
34
|
+
import { startWorkflowRun } from "../workflows/runs";
|
|
35
|
+
import { parseTaskDocument } from "./parser";
|
|
36
|
+
export async function runTask(id, options = {}) {
|
|
37
|
+
const stashDir = options.stashDir ?? resolveStashDir();
|
|
38
|
+
const runAgentImpl = options.runAgentImpl ?? runAgent;
|
|
39
|
+
const startWorkflowRunImpl = options.startWorkflowRunImpl ?? startWorkflowRun;
|
|
40
|
+
const now = options.now ?? (() => new Date());
|
|
41
|
+
const logDir = options.logDir ?? getTaskLogDir();
|
|
42
|
+
const filePath = await resolveAssetPath(stashDir, "task", id);
|
|
43
|
+
const markdown = fs.readFileSync(filePath, "utf8");
|
|
44
|
+
const task = parseTaskDocument({ markdown, filePath, id });
|
|
45
|
+
const startedAt = now();
|
|
46
|
+
const startedIso = startedAt.toISOString();
|
|
47
|
+
const tsSlug = startedIso.replace(/[:.]/g, "-");
|
|
48
|
+
const taskLogDir = path.join(logDir, id);
|
|
49
|
+
fs.mkdirSync(taskLogDir, { recursive: true });
|
|
50
|
+
const logPath = path.join(taskLogDir, `${tsSlug}.log`);
|
|
51
|
+
if (!task.enabled) {
|
|
52
|
+
const finishedAt = now();
|
|
53
|
+
const disabledTarget = task.target.kind === "workflow"
|
|
54
|
+
? { kind: "workflow", ref: task.target.ref }
|
|
55
|
+
: task.target.kind === "command"
|
|
56
|
+
? { kind: "prompt", profile: undefined }
|
|
57
|
+
: { kind: "prompt", profile: task.target.profile };
|
|
58
|
+
const result = {
|
|
59
|
+
id,
|
|
60
|
+
status: "disabled",
|
|
61
|
+
startedAt: startedIso,
|
|
62
|
+
finishedAt: finishedAt.toISOString(),
|
|
63
|
+
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
64
|
+
log: logPath,
|
|
65
|
+
target: disabledTarget,
|
|
66
|
+
};
|
|
67
|
+
fs.writeFileSync(logPath, `[akm tasks] task "${id}" is disabled — skipping run.\n`);
|
|
68
|
+
appendHistory(result);
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
if (task.target.kind === "workflow") {
|
|
72
|
+
return await runWorkflowTask({
|
|
73
|
+
task,
|
|
74
|
+
logPath,
|
|
75
|
+
startedAt,
|
|
76
|
+
now,
|
|
77
|
+
startWorkflowRunImpl,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (task.target.kind === "command") {
|
|
81
|
+
return await runCommandTask({ task, logPath, startedAt, now });
|
|
82
|
+
}
|
|
83
|
+
// Resolve config once here so runPromptTask does not call loadConfig()
|
|
84
|
+
// on every dispatch in a batch run (Fix C6).
|
|
85
|
+
const config = loadConfig();
|
|
86
|
+
return await runPromptTask({
|
|
87
|
+
task,
|
|
88
|
+
stashDir,
|
|
89
|
+
logPath,
|
|
90
|
+
startedAt,
|
|
91
|
+
now,
|
|
92
|
+
runAgentImpl,
|
|
93
|
+
agentOptions: options.agentOptions,
|
|
94
|
+
agentConfig: config.agent,
|
|
95
|
+
agentTimeoutMs: config.agent?.timeoutMs ?? undefined,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
// ── command target ──────────────────────────────────────────────────────────
|
|
99
|
+
async function runCommandTask(input) {
|
|
100
|
+
const { task, logPath, startedAt, now } = input;
|
|
101
|
+
if (task.target.kind !== "command")
|
|
102
|
+
throw new Error("invariant: command target");
|
|
103
|
+
const { cmd } = task.target;
|
|
104
|
+
const timeoutMs = task.timeoutMs !== undefined ? task.timeoutMs : null;
|
|
105
|
+
const logLines = [`[akm tasks] task=${task.id} kind=command cmd=${cmd.join(" ")}`];
|
|
106
|
+
let stdout = "";
|
|
107
|
+
let stderr = "";
|
|
108
|
+
let exitCode = null;
|
|
109
|
+
try {
|
|
110
|
+
const proc = Bun.spawn(cmd, {
|
|
111
|
+
stdin: "ignore",
|
|
112
|
+
stdout: "pipe",
|
|
113
|
+
stderr: "pipe",
|
|
114
|
+
});
|
|
115
|
+
let timer;
|
|
116
|
+
let timedOut = false;
|
|
117
|
+
if (timeoutMs !== null) {
|
|
118
|
+
timer = setTimeout(() => {
|
|
119
|
+
timedOut = true;
|
|
120
|
+
try {
|
|
121
|
+
proc.kill("SIGTERM");
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
/* ignore */
|
|
125
|
+
}
|
|
126
|
+
}, timeoutMs);
|
|
127
|
+
}
|
|
128
|
+
const [stdoutBuf, stderrBuf] = await Promise.all([
|
|
129
|
+
new Response(proc.stdout).text(),
|
|
130
|
+
new Response(proc.stderr).text(),
|
|
131
|
+
]);
|
|
132
|
+
await proc.exited;
|
|
133
|
+
if (timer !== undefined)
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
stdout = stdoutBuf;
|
|
136
|
+
stderr = stderrBuf;
|
|
137
|
+
exitCode = proc.exitCode ?? (timedOut ? 143 : 1);
|
|
138
|
+
if (timedOut) {
|
|
139
|
+
logLines.push(`timed_out=true timeout_ms=${timeoutMs}`);
|
|
140
|
+
}
|
|
141
|
+
logLines.push(`exit_code=${exitCode}`);
|
|
142
|
+
if (stdout) {
|
|
143
|
+
logLines.push("--- stdout ---");
|
|
144
|
+
logLines.push(stdout);
|
|
145
|
+
}
|
|
146
|
+
if (stderr) {
|
|
147
|
+
logLines.push("--- stderr ---");
|
|
148
|
+
logLines.push(stderr);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
153
|
+
logLines.push(`spawn_error=${msg}`);
|
|
154
|
+
exitCode = 1;
|
|
155
|
+
}
|
|
156
|
+
fs.writeFileSync(logPath, `${logLines.join("\n")}\n`);
|
|
157
|
+
const finishedAt = now();
|
|
158
|
+
const status = exitCode === 0 ? "completed" : "failed";
|
|
159
|
+
const result = {
|
|
160
|
+
id: task.id,
|
|
161
|
+
status,
|
|
162
|
+
startedAt: startedAt.toISOString(),
|
|
163
|
+
finishedAt: finishedAt.toISOString(),
|
|
164
|
+
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
165
|
+
log: logPath,
|
|
166
|
+
target: { kind: "prompt", profile: undefined },
|
|
167
|
+
detail: { exitCode },
|
|
168
|
+
};
|
|
169
|
+
appendHistory(result);
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
// ── workflow target ─────────────────────────────────────────────────────────
|
|
173
|
+
async function runWorkflowTask(input) {
|
|
174
|
+
const { task, logPath, startedAt, now, startWorkflowRunImpl } = input;
|
|
175
|
+
if (task.target.kind !== "workflow")
|
|
176
|
+
throw new Error("invariant: workflow target");
|
|
177
|
+
const ref = parseAssetRef(task.target.ref);
|
|
178
|
+
if (ref.type !== "workflow") {
|
|
179
|
+
throw new NotFoundError(`Task "${task.id}" workflow target must be a workflow ref (got "${task.target.ref}").`, "WORKFLOW_NOT_FOUND");
|
|
180
|
+
}
|
|
181
|
+
let detail;
|
|
182
|
+
let error;
|
|
183
|
+
try {
|
|
184
|
+
detail = await startWorkflowRunImpl(task.target.ref, task.target.params);
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
error = e instanceof Error ? e : new Error(String(e));
|
|
188
|
+
}
|
|
189
|
+
const finishedAt = now();
|
|
190
|
+
const status = error ? "failed" : mapWorkflowStatus(detail?.run.status);
|
|
191
|
+
const log = renderWorkflowLog({ task, detail, error });
|
|
192
|
+
fs.writeFileSync(logPath, log);
|
|
193
|
+
const result = {
|
|
194
|
+
id: task.id,
|
|
195
|
+
status,
|
|
196
|
+
startedAt: startedAt.toISOString(),
|
|
197
|
+
finishedAt: finishedAt.toISOString(),
|
|
198
|
+
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
199
|
+
log: logPath,
|
|
200
|
+
target: { kind: "workflow", ref: task.target.ref },
|
|
201
|
+
detail: {
|
|
202
|
+
runId: detail?.run.id,
|
|
203
|
+
...(error ? { error: error.message } : {}),
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
appendHistory(result);
|
|
207
|
+
// Don't re-throw on workflow failure: the OS scheduler reads exit codes,
|
|
208
|
+
// not exceptions, and the CLI maps `status: "failed"` to a non-zero exit
|
|
209
|
+
// via exitCodeForStatus(). Throwing here would route through the generic
|
|
210
|
+
// runWithJsonErrors path and lose the structured result/history we just
|
|
211
|
+
// recorded.
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Map the workflow runtime's status into the task-runner status space.
|
|
216
|
+
* Workflows can legitimately remain `active` after `startWorkflowRun`
|
|
217
|
+
* returns (multi-step workflows pause for user input); recording them as
|
|
218
|
+
* "completed" would be misleading. We preserve "active" as a first-class
|
|
219
|
+
* task status with exit code 0 — the OS scheduler treats it as success.
|
|
220
|
+
*/
|
|
221
|
+
function mapWorkflowStatus(status) {
|
|
222
|
+
switch (status) {
|
|
223
|
+
case "completed":
|
|
224
|
+
case "blocked":
|
|
225
|
+
case "failed":
|
|
226
|
+
case "active":
|
|
227
|
+
return status;
|
|
228
|
+
default:
|
|
229
|
+
return "completed";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function renderWorkflowLog(input) {
|
|
233
|
+
const lines = [];
|
|
234
|
+
lines.push(`[akm tasks] task=${input.task.id} kind=workflow ref=${input.task.target.ref}`);
|
|
235
|
+
if (input.detail) {
|
|
236
|
+
lines.push(`run_id=${input.detail.run.id} status=${input.detail.run.status}`);
|
|
237
|
+
lines.push(`workflow_title=${input.detail.run.workflowTitle}`);
|
|
238
|
+
}
|
|
239
|
+
if (input.error) {
|
|
240
|
+
lines.push(`error=${input.error.message}`);
|
|
241
|
+
}
|
|
242
|
+
return `${lines.join("\n")}\n`;
|
|
243
|
+
}
|
|
244
|
+
// ── prompt target ───────────────────────────────────────────────────────────
|
|
245
|
+
async function runPromptTask(input) {
|
|
246
|
+
const { task, stashDir, logPath, startedAt, now, runAgentImpl, agentOptions } = input;
|
|
247
|
+
if (task.target.kind !== "prompt")
|
|
248
|
+
throw new Error("invariant: prompt target");
|
|
249
|
+
// Use pre-resolved agent config when available to avoid redundant loadConfig()
|
|
250
|
+
// calls in batch task runs (Fix C6). Fall back to loadConfig() for callers
|
|
251
|
+
// that invoke runPromptTask directly without threading config.
|
|
252
|
+
const agentCfg = input.agentConfig !== undefined ? input.agentConfig : loadConfig().agent;
|
|
253
|
+
// Resolve the profile for this task. When the task doc specifies a profile,
|
|
254
|
+
// use it directly. Otherwise fall back to the per-process config for "task"
|
|
255
|
+
// (agent.processes["task"]), which itself falls back to agent.default.
|
|
256
|
+
let profile;
|
|
257
|
+
let processTimeoutMs;
|
|
258
|
+
if (task.target.profile) {
|
|
259
|
+
// Task doc explicitly names a profile — honour it directly.
|
|
260
|
+
profile = requireAgentProfile(agentCfg, task.target.profile);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
// No per-task profile: use process config for "task" as a fallback.
|
|
264
|
+
const resolved = resolveProcessAgentProfile("task", agentCfg);
|
|
265
|
+
profile = resolved.profile;
|
|
266
|
+
processTimeoutMs = resolved.timeoutMs;
|
|
267
|
+
}
|
|
268
|
+
// Task-level timeoutMs (including null = disabled) wins over global config.
|
|
269
|
+
// Resolution: task.timeoutMs → process entry timeoutMs → input.agentTimeoutMs → agentCfg.timeoutMs.
|
|
270
|
+
const agentTimeoutMs = task.timeoutMs !== undefined
|
|
271
|
+
? task.timeoutMs
|
|
272
|
+
: processTimeoutMs !== undefined
|
|
273
|
+
? processTimeoutMs
|
|
274
|
+
: input.agentTimeoutMs !== undefined
|
|
275
|
+
? input.agentTimeoutMs
|
|
276
|
+
: agentCfg?.timeoutMs;
|
|
277
|
+
const promptText = await resolvePromptText(task, stashDir);
|
|
278
|
+
const result = await runAgentImpl(profile, promptText, {
|
|
279
|
+
stdio: "captured",
|
|
280
|
+
timeoutMs: agentTimeoutMs,
|
|
281
|
+
cwd: stashDir,
|
|
282
|
+
...agentOptions,
|
|
283
|
+
});
|
|
284
|
+
const finishedAt = now();
|
|
285
|
+
const log = renderPromptLog({ task, profileName: profile.name, result });
|
|
286
|
+
fs.writeFileSync(logPath, log);
|
|
287
|
+
const status = result.ok ? "completed" : "failed";
|
|
288
|
+
const out = {
|
|
289
|
+
id: task.id,
|
|
290
|
+
status,
|
|
291
|
+
startedAt: startedAt.toISOString(),
|
|
292
|
+
finishedAt: finishedAt.toISOString(),
|
|
293
|
+
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
294
|
+
log: logPath,
|
|
295
|
+
target: { kind: "prompt", profile: profile.name },
|
|
296
|
+
detail: result.ok
|
|
297
|
+
? { exitCode: result.exitCode }
|
|
298
|
+
: { reason: result.reason, error: result.error, exitCode: result.exitCode },
|
|
299
|
+
};
|
|
300
|
+
appendHistory(out);
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
async function resolvePromptText(task, stashDir) {
|
|
304
|
+
if (task.target.kind !== "prompt")
|
|
305
|
+
throw new Error("invariant: prompt target");
|
|
306
|
+
const src = task.target.source;
|
|
307
|
+
if (src.kind === "inline")
|
|
308
|
+
return src.text;
|
|
309
|
+
if (src.kind === "file") {
|
|
310
|
+
const taskDir = path.dirname(task.source.path);
|
|
311
|
+
const filePath = path.isAbsolute(src.path) ? src.path : path.resolve(taskDir, src.path);
|
|
312
|
+
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
|
313
|
+
throw new NotFoundError(`Prompt file not found: ${filePath}`, "FILE_NOT_FOUND");
|
|
314
|
+
}
|
|
315
|
+
return fs.readFileSync(filePath, "utf8");
|
|
316
|
+
}
|
|
317
|
+
// asset
|
|
318
|
+
const ref = parseAssetRef(src.ref);
|
|
319
|
+
const assetPath = await resolveAssetPath(stashDir, ref.type, ref.name);
|
|
320
|
+
return fs.readFileSync(assetPath, "utf8");
|
|
321
|
+
}
|
|
322
|
+
function renderPromptLog(input) {
|
|
323
|
+
const lines = [];
|
|
324
|
+
lines.push(`[akm tasks] task=${input.task.id} kind=prompt profile=${input.profileName}`);
|
|
325
|
+
lines.push(`ok=${input.result.ok} exit_code=${input.result.exitCode ?? "null"} duration_ms=${input.result.durationMs}`);
|
|
326
|
+
if (!input.result.ok) {
|
|
327
|
+
lines.push(`reason=${input.result.reason ?? ""} error=${input.result.error ?? ""}`);
|
|
328
|
+
}
|
|
329
|
+
if (input.result.stdout) {
|
|
330
|
+
lines.push("--- agent stdout ---");
|
|
331
|
+
lines.push(input.result.stdout);
|
|
332
|
+
}
|
|
333
|
+
if (input.result.stderr) {
|
|
334
|
+
lines.push("--- agent stderr ---");
|
|
335
|
+
lines.push(input.result.stderr);
|
|
336
|
+
}
|
|
337
|
+
return `${lines.join("\n")}\n`;
|
|
338
|
+
}
|
|
339
|
+
// ── history ─────────────────────────────────────────────────────────────────
|
|
340
|
+
function appendHistory(result) {
|
|
341
|
+
try {
|
|
342
|
+
const db = openStateDatabase();
|
|
343
|
+
try {
|
|
344
|
+
upsertTaskHistory(db, {
|
|
345
|
+
task_id: result.id,
|
|
346
|
+
status: result.status,
|
|
347
|
+
started_at: result.startedAt,
|
|
348
|
+
completed_at: result.finishedAt,
|
|
349
|
+
failed_at: result.status === "failed" ? result.finishedAt : null,
|
|
350
|
+
log_path: result.log,
|
|
351
|
+
target_kind: result.target.kind,
|
|
352
|
+
target_ref: result.target.kind === "workflow" ? result.target.ref : null,
|
|
353
|
+
metadata_json: JSON.stringify({
|
|
354
|
+
durationMs: result.durationMs,
|
|
355
|
+
detail: result.detail ?? null,
|
|
356
|
+
profile: result.target.kind === "prompt" ? result.target.profile : undefined,
|
|
357
|
+
}),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
db.close();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
error(`[akm] task history DB write failed: ${String(err)}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
export function readTaskHistory(options = {}) {
|
|
369
|
+
const db = openStateDatabase();
|
|
370
|
+
try {
|
|
371
|
+
let rows;
|
|
372
|
+
if (options.id) {
|
|
373
|
+
const row = getTaskHistory(db, options.id);
|
|
374
|
+
rows = row ? [taskHistoryRowToResult(row)] : [];
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
rows = queryTaskHistory(db, {}).map(taskHistoryRowToResult);
|
|
378
|
+
}
|
|
379
|
+
rows.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
|
|
380
|
+
if (options.limit !== undefined && options.limit >= 0) {
|
|
381
|
+
return rows.slice(0, options.limit);
|
|
382
|
+
}
|
|
383
|
+
return rows;
|
|
384
|
+
}
|
|
385
|
+
finally {
|
|
386
|
+
db.close();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Convert a `TaskHistoryRow` from state.db back to a `TaskRunResult` shape
|
|
391
|
+
* that callers of `readTaskHistory()` expect.
|
|
392
|
+
*/
|
|
393
|
+
function taskHistoryRowToResult(row) {
|
|
394
|
+
let meta = {};
|
|
395
|
+
try {
|
|
396
|
+
meta = JSON.parse(row.metadata_json);
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
// ignore corrupt JSON
|
|
400
|
+
}
|
|
401
|
+
const target = row.target_kind === "workflow"
|
|
402
|
+
? { kind: "workflow", ref: row.target_ref ?? "" }
|
|
403
|
+
: { kind: "prompt", profile: meta.profile };
|
|
404
|
+
return {
|
|
405
|
+
id: row.task_id,
|
|
406
|
+
status: row.status,
|
|
407
|
+
startedAt: row.started_at,
|
|
408
|
+
finishedAt: row.completed_at ?? row.failed_at ?? row.started_at,
|
|
409
|
+
durationMs: meta.durationMs ?? 0,
|
|
410
|
+
log: row.log_path ?? "",
|
|
411
|
+
target,
|
|
412
|
+
...(meta.detail !== undefined ? { detail: meta.detail } : {}),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* The exit code surfaced to the OS scheduler. Mapped from {@link TaskRunStatus}
|
|
417
|
+
* so cron / launchd / schtasks see a useful return value.
|
|
418
|
+
*/
|
|
419
|
+
export function exitCodeForStatus(status) {
|
|
420
|
+
switch (status) {
|
|
421
|
+
case "completed":
|
|
422
|
+
return 0;
|
|
423
|
+
case "active":
|
|
424
|
+
return 0;
|
|
425
|
+
case "blocked":
|
|
426
|
+
return 1;
|
|
427
|
+
case "failed":
|
|
428
|
+
return 1;
|
|
429
|
+
case "disabled":
|
|
430
|
+
return 0;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform schedule parsing and translation.
|
|
3
|
+
*
|
|
4
|
+
* Users always type cron-style expressions:
|
|
5
|
+
*
|
|
6
|
+
* • `m h dom mon dow` — five-field cron (UNIX minute/hour/dom/mon/dow)
|
|
7
|
+
* • `@hourly` `@daily` `@weekly` `@monthly`
|
|
8
|
+
*
|
|
9
|
+
* Each {@link ScheduleSpec} can then be translated to:
|
|
10
|
+
*
|
|
11
|
+
* • a verbatim cron line (Linux backend),
|
|
12
|
+
* • a launchd plist `<StartCalendarInterval>` / `<StartInterval>` (macOS),
|
|
13
|
+
* • Task Scheduler XML triggers (Windows).
|
|
14
|
+
*
|
|
15
|
+
* The shared subset is `*`, single integers, `*\/N`, plus the `@hourly /
|
|
16
|
+
* @daily / @weekly / @monthly` aliases. Patterns outside that — multi-value
|
|
17
|
+
* lists, ranges, step values other than `*\/N`, day-of-month AND
|
|
18
|
+
* day-of-week combinations — are rejected with a {@link UsageError}.
|
|
19
|
+
*
|
|
20
|
+
* Cron is the most permissive of the three backends; some patterns it
|
|
21
|
+
* accepts (e.g. `@hourly` = `0 * * * *`) have no clean schtasks primitive.
|
|
22
|
+
* Validation runs against the *active* backend, so a task authored on
|
|
23
|
+
* Linux may fail to translate when copied to macOS/Windows. `tasks sync`
|
|
24
|
+
* re-validates against the local backend and surfaces any incompatibility.
|
|
25
|
+
*/
|
|
26
|
+
import { UsageError } from "../core/errors";
|
|
27
|
+
const ALIAS_TO_CRON = {
|
|
28
|
+
"@hourly": "0 * * * *",
|
|
29
|
+
"@daily": "0 0 * * *",
|
|
30
|
+
"@midnight": "0 0 * * *",
|
|
31
|
+
"@weekly": "0 0 * * 0",
|
|
32
|
+
"@monthly": "0 0 1 * *",
|
|
33
|
+
};
|
|
34
|
+
const FIELD_LIMITS = {
|
|
35
|
+
minute: { min: 0, max: 59 },
|
|
36
|
+
hour: { min: 0, max: 23 },
|
|
37
|
+
dom: { min: 1, max: 31 },
|
|
38
|
+
month: { min: 1, max: 12 },
|
|
39
|
+
dow: { min: 0, max: 6 },
|
|
40
|
+
};
|
|
41
|
+
const SUPPORTED_HINT = "Supported subset: `*`, single integers (`5`), and step-on-star (`*/N`). " +
|
|
42
|
+
"Aliases: `@hourly`, `@daily`, `@weekly`, `@monthly`. " +
|
|
43
|
+
"Lists, ranges, and named days/months are not supported.";
|
|
44
|
+
export function parseSchedule(input, backend) {
|
|
45
|
+
const cron = expandAlias(input);
|
|
46
|
+
const fields = parseCronFields(cron, input);
|
|
47
|
+
const spec = { raw: input, cron, fields };
|
|
48
|
+
// Validate translatability for the active backend so the caller does not
|
|
49
|
+
// silently accept expressions that backend cannot run. Note: cron is the
|
|
50
|
+
// most permissive of the three (e.g. `@hourly` is `0 * * * *` which has
|
|
51
|
+
// no clean schtasks primitive), so a task authored on Linux may not be
|
|
52
|
+
// portable to macOS/Windows. `tasks sync` on the destination platform
|
|
53
|
+
// re-runs this with the local backend and will surface any incompatibility.
|
|
54
|
+
if (backend === "launchd")
|
|
55
|
+
translateToLaunchd(spec);
|
|
56
|
+
if (backend === "schtasks")
|
|
57
|
+
translateToSchtasks(spec);
|
|
58
|
+
return spec;
|
|
59
|
+
}
|
|
60
|
+
function expandAlias(raw) {
|
|
61
|
+
const trimmed = raw.trim();
|
|
62
|
+
if (!trimmed) {
|
|
63
|
+
throw new UsageError("Schedule is empty.", "MISSING_REQUIRED_ARGUMENT");
|
|
64
|
+
}
|
|
65
|
+
const lower = trimmed.toLowerCase();
|
|
66
|
+
if (lower in ALIAS_TO_CRON)
|
|
67
|
+
return ALIAS_TO_CRON[lower];
|
|
68
|
+
return trimmed;
|
|
69
|
+
}
|
|
70
|
+
function parseCronFields(cron, original) {
|
|
71
|
+
const parts = cron.split(/\s+/);
|
|
72
|
+
if (parts.length !== 5) {
|
|
73
|
+
throw new UsageError(`Invalid schedule "${original}": expected 5 fields, got ${parts.length}. ${SUPPORTED_HINT}`, "INVALID_FLAG_VALUE");
|
|
74
|
+
}
|
|
75
|
+
const [m, h, dom, mon, dow] = parts;
|
|
76
|
+
return {
|
|
77
|
+
minute: parseField(m, "minute", FIELD_LIMITS.minute, original),
|
|
78
|
+
hour: parseField(h, "hour", FIELD_LIMITS.hour, original),
|
|
79
|
+
dom: parseField(dom, "day-of-month", FIELD_LIMITS.dom, original),
|
|
80
|
+
month: parseField(mon, "month", FIELD_LIMITS.month, original),
|
|
81
|
+
dow: parseField(dow, "day-of-week", FIELD_LIMITS.dow, original),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function parseField(raw, name, limit, original) {
|
|
85
|
+
if (raw === "*")
|
|
86
|
+
return { kind: "star" };
|
|
87
|
+
const stepMatch = raw.match(/^\*\/(\d+)$/);
|
|
88
|
+
if (stepMatch) {
|
|
89
|
+
const step = Number(stepMatch[1]);
|
|
90
|
+
// Step must be ≥1 and ≤ the field's range size, not just `max`. For
|
|
91
|
+
// 1-based fields like day-of-month (1-31) and month (1-12) the previous
|
|
92
|
+
// `max + 1` bound let invalid steps like `*/32` or `*/13` slip through.
|
|
93
|
+
const range = limit.max - limit.min + 1;
|
|
94
|
+
if (!Number.isInteger(step) || step <= 0 || step > range) {
|
|
95
|
+
throw new UsageError(`Invalid ${name} step "${raw}" in schedule "${original}". ${SUPPORTED_HINT}`, "INVALID_FLAG_VALUE");
|
|
96
|
+
}
|
|
97
|
+
return { kind: "step", step };
|
|
98
|
+
}
|
|
99
|
+
if (/^\d+$/.test(raw)) {
|
|
100
|
+
const value = Number(raw);
|
|
101
|
+
if (value < limit.min || value > limit.max) {
|
|
102
|
+
throw new UsageError(`Invalid ${name} value "${raw}" in schedule "${original}" (allowed ${limit.min}-${limit.max}).`, "INVALID_FLAG_VALUE");
|
|
103
|
+
}
|
|
104
|
+
return { kind: "value", value };
|
|
105
|
+
}
|
|
106
|
+
throw new UsageError(`Unsupported ${name} expression "${raw}" in schedule "${original}". ${SUPPORTED_HINT}`, "INVALID_FLAG_VALUE");
|
|
107
|
+
}
|
|
108
|
+
// ── Backend translators ─────────────────────────────────────────────────────
|
|
109
|
+
/** Verbatim cron line, alias-expanded. */
|
|
110
|
+
export function translateToCron(spec) {
|
|
111
|
+
return spec.cron;
|
|
112
|
+
}
|
|
113
|
+
export function translateToLaunchd(spec) {
|
|
114
|
+
const f = spec.fields;
|
|
115
|
+
// `*/N` minute (everything else `*`) → StartInterval = N*60 seconds.
|
|
116
|
+
if (f.minute.kind === "step" &&
|
|
117
|
+
f.hour.kind === "star" &&
|
|
118
|
+
f.dom.kind === "star" &&
|
|
119
|
+
f.month.kind === "star" &&
|
|
120
|
+
f.dow.kind === "star") {
|
|
121
|
+
return { intervalSeconds: f.minute.step * 60 };
|
|
122
|
+
}
|
|
123
|
+
// `*/N` hour → StartInterval = N*3600.
|
|
124
|
+
if (f.minute.kind === "value" &&
|
|
125
|
+
f.minute.value === 0 &&
|
|
126
|
+
f.hour.kind === "step" &&
|
|
127
|
+
f.dom.kind === "star" &&
|
|
128
|
+
f.month.kind === "star" &&
|
|
129
|
+
f.dow.kind === "star") {
|
|
130
|
+
return { intervalSeconds: f.hour.step * 3600 };
|
|
131
|
+
}
|
|
132
|
+
// Otherwise build a calendar dict from concrete values. launchd treats any
|
|
133
|
+
// omitted key as "every value", so a `*` field translates to "no key".
|
|
134
|
+
// Exception: launchd does not support arbitrary step values inside a
|
|
135
|
+
// calendar dict — reject those.
|
|
136
|
+
const calendar = {};
|
|
137
|
+
rejectStepInsideCalendar(f.minute, "minute", spec);
|
|
138
|
+
rejectStepInsideCalendar(f.hour, "hour", spec);
|
|
139
|
+
rejectStepInsideCalendar(f.dom, "day-of-month", spec);
|
|
140
|
+
rejectStepInsideCalendar(f.month, "month", spec);
|
|
141
|
+
rejectStepInsideCalendar(f.dow, "day-of-week", spec);
|
|
142
|
+
if (f.minute.kind === "value")
|
|
143
|
+
calendar.Minute = f.minute.value;
|
|
144
|
+
if (f.hour.kind === "value")
|
|
145
|
+
calendar.Hour = f.hour.value;
|
|
146
|
+
if (f.dom.kind === "value")
|
|
147
|
+
calendar.Day = f.dom.value;
|
|
148
|
+
if (f.month.kind === "value")
|
|
149
|
+
calendar.Month = f.month.value;
|
|
150
|
+
if (f.dow.kind === "value")
|
|
151
|
+
calendar.Weekday = f.dow.value;
|
|
152
|
+
// launchd's CalendarInterval requires at least one specific key. If every
|
|
153
|
+
// field is `*` the schedule has no anchor and we'd need a StartInterval
|
|
154
|
+
// instead — treat this as "every minute".
|
|
155
|
+
if (Object.keys(calendar).length === 0) {
|
|
156
|
+
return { intervalSeconds: 60 };
|
|
157
|
+
}
|
|
158
|
+
return { calendar };
|
|
159
|
+
}
|
|
160
|
+
function rejectStepInsideCalendar(field, name, spec) {
|
|
161
|
+
if (field.kind === "step") {
|
|
162
|
+
throw new UsageError(`Schedule "${spec.raw}" uses step (${name} = */N) in a position macOS launchd cannot express. ${SUPPORTED_HINT}`, "INVALID_FLAG_VALUE", "Either restrict the step to the minute or hour field only, or rewrite the schedule with concrete values.");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
export function translateToSchtasks(spec) {
|
|
166
|
+
const f = spec.fields;
|
|
167
|
+
// `*/N` minute → MINUTE, every N.
|
|
168
|
+
if (f.minute.kind === "step" &&
|
|
169
|
+
f.hour.kind === "star" &&
|
|
170
|
+
f.dom.kind === "star" &&
|
|
171
|
+
f.month.kind === "star" &&
|
|
172
|
+
f.dow.kind === "star") {
|
|
173
|
+
return { kind: "minute", everyMinutes: f.minute.step };
|
|
174
|
+
}
|
|
175
|
+
// `0 */N * * *` → HOURLY, every N.
|
|
176
|
+
if (f.minute.kind === "value" &&
|
|
177
|
+
f.minute.value === 0 &&
|
|
178
|
+
f.hour.kind === "step" &&
|
|
179
|
+
f.dom.kind === "star" &&
|
|
180
|
+
f.month.kind === "star" &&
|
|
181
|
+
f.dow.kind === "star") {
|
|
182
|
+
return { kind: "hour", everyHours: f.hour.step };
|
|
183
|
+
}
|
|
184
|
+
// `M H * * *` → DAILY at H:M.
|
|
185
|
+
if (f.minute.kind === "value" &&
|
|
186
|
+
f.hour.kind === "value" &&
|
|
187
|
+
f.dom.kind === "star" &&
|
|
188
|
+
f.month.kind === "star" &&
|
|
189
|
+
f.dow.kind === "star") {
|
|
190
|
+
return { kind: "daily", atHour: f.hour.value, atMinute: f.minute.value };
|
|
191
|
+
}
|
|
192
|
+
// `M H * * D` → WEEKLY at H:M on day D.
|
|
193
|
+
if (f.minute.kind === "value" &&
|
|
194
|
+
f.hour.kind === "value" &&
|
|
195
|
+
f.dom.kind === "star" &&
|
|
196
|
+
f.month.kind === "star" &&
|
|
197
|
+
f.dow.kind === "value") {
|
|
198
|
+
return {
|
|
199
|
+
kind: "weekly",
|
|
200
|
+
atHour: f.hour.value,
|
|
201
|
+
atMinute: f.minute.value,
|
|
202
|
+
daysOfWeek: [f.dow.value],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
throw new UsageError(`Schedule "${spec.raw}" cannot be expressed as a Windows Task Scheduler trigger. ${SUPPORTED_HINT}`, "INVALID_FLAG_VALUE", "Use one of: */N minutes, every N hours (0 */N * * *), daily at HH:MM, or weekly on a single weekday.");
|
|
206
|
+
}
|
|
207
|
+
/** Human-readable summary used by `tasks doctor`. */
|
|
208
|
+
export const SCHEDULE_SUPPORTED_SUBSET_HINT = SUPPORTED_HINT;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task asset schema. A task pairs a cron-style schedule with exactly one of:
|
|
3
|
+
*
|
|
4
|
+
* • a workflow target — invoked via `startWorkflowRun()`
|
|
5
|
+
* • a prompt target — invoked via `runAgent()` against the configured
|
|
6
|
+
* agent harness (e.g. `opencode run`)
|
|
7
|
+
* • a command target — invoked directly via `Bun.spawn()`, no AI agent
|
|
8
|
+
*
|
|
9
|
+
* Tasks are stored as markdown files at `<stash>/tasks/<id>.md`. The
|
|
10
|
+
* frontmatter holds the schedule and target; for inline-prompt tasks the
|
|
11
|
+
* markdown body is the prompt text.
|
|
12
|
+
*/
|
|
13
|
+
export const TASK_SCHEMA_VERSION = 1;
|