akm-cli 0.9.2-alpha.4 → 0.9.2

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.
Files changed (143) hide show
  1. package/CHANGELOG.md +493 -0
  2. package/STABILITY.md +23 -5
  3. package/dist/assets/hints/cli-hints-full.md +12 -7
  4. package/dist/assets/tasks/core/extract.yml +3 -5
  5. package/dist/assets/tasks/core/improve.yml +3 -5
  6. package/dist/assets/tasks/core/index-refresh.yml +3 -5
  7. package/dist/assets/tasks/core/sync.yml +3 -5
  8. package/dist/assets/tasks/core/version-check.yml +3 -5
  9. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +3 -5
  10. package/dist/assets/tasks/improve/akm-improve-catchup.yml +6 -6
  11. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +3 -5
  12. package/dist/assets/tasks/improve/akm-improve-frequent.yml +3 -5
  13. package/dist/assets/tasks/improve/akm-improve-nightly.yml +3 -5
  14. package/dist/cli/unknown-flags.js +12 -1
  15. package/dist/cli.js +8 -1
  16. package/dist/commands/command/command-execution.js +23 -2
  17. package/dist/commands/health/improve-metrics.js +38 -0
  18. package/dist/commands/health/windows.js +8 -4
  19. package/dist/commands/health.js +8 -4
  20. package/dist/commands/lint/index.js +1 -1
  21. package/dist/commands/migrate-cli.js +130 -24
  22. package/dist/commands/proposal/validators/proposal-validators.js +7 -2
  23. package/dist/commands/tasks/explain.js +304 -0
  24. package/dist/commands/tasks/tasks-cli.js +185 -3
  25. package/dist/commands/tasks/tasks.js +265 -52
  26. package/dist/commands/workflow/plan.js +159 -0
  27. package/dist/commands/workflow-cli.js +94 -2
  28. package/dist/core/activation-policy.js +2 -12
  29. package/dist/core/adapter/adapters/akm-lint.js +7 -4
  30. package/dist/core/adapter/adapters/akm-metadata.js +26 -14
  31. package/dist/core/adapter/adapters/akm-task-adapter.js +13 -10
  32. package/dist/core/errors.js +45 -0
  33. package/dist/core/json-schema.js +15 -5
  34. package/dist/core/state/migrations.js +57 -0
  35. package/dist/core/state-db.js +16 -14
  36. package/dist/core/subprocess.js +47 -13
  37. package/dist/execution/guarded-source.js +44 -0
  38. package/dist/execution/input-contract.js +250 -0
  39. package/dist/execution/target-ref.js +63 -0
  40. package/dist/indexer/usage/usage-events.js +14 -3
  41. package/dist/integrations/agent/execution-lowering.js +12 -1
  42. package/dist/output/shapes/passthrough.js +2 -0
  43. package/dist/output/text/helpers.js +1 -1
  44. package/dist/output/text/migrate.js +12 -3
  45. package/dist/output/text/workflow-format.js +192 -10
  46. package/dist/output/text/workflow.js +2 -1
  47. package/dist/runtime.js +1 -0
  48. package/dist/scripts/akm-migrate-node.js +11838 -10118
  49. package/dist/scripts/akm-migrate.js +11828 -10117
  50. package/dist/setup/steps/tasks.js +34 -17
  51. package/dist/storage/repositories/task-history-repository.js +5 -1
  52. package/dist/storage/repositories/workflow-runs-repository.js +144 -6
  53. package/dist/tasks/backends/launchd.js +31 -84
  54. package/dist/tasks/embedded.js +13 -7
  55. package/dist/tasks/model/invocation.js +4 -0
  56. package/dist/tasks/prepare/prepare-script-target.js +9 -0
  57. package/dist/tasks/prepare/prepare-support.js +154 -0
  58. package/dist/tasks/prepare/prepare.js +117 -0
  59. package/dist/tasks/prepare/prepared-execution.js +4 -0
  60. package/dist/tasks/prepare/script-capture.js +80 -0
  61. package/dist/tasks/run/attempt-lifecycle.js +165 -0
  62. package/dist/tasks/run/load-task.js +117 -0
  63. package/dist/tasks/run/provenance.js +20 -0
  64. package/dist/tasks/run/run-command-task.js +92 -0
  65. package/dist/tasks/run/run-native-task.js +222 -0
  66. package/dist/tasks/run/run-task.js +99 -0
  67. package/dist/tasks/run/run-workflow-task.js +222 -0
  68. package/dist/tasks/run/task-history.js +134 -0
  69. package/dist/tasks/run/task-log.js +179 -0
  70. package/dist/tasks/run/task-result.js +19 -0
  71. package/dist/tasks/scheduler-binding.js +66 -2
  72. package/dist/tasks/scheduler-invocation.js +63 -3
  73. package/dist/tasks/scheduler-sync.js +77 -14
  74. package/dist/tasks/source/bounded-document.js +455 -0
  75. package/dist/tasks/source/parse-task-source.js +59 -0
  76. package/dist/tasks/source/project-v4.js +62 -0
  77. package/dist/tasks/source/task-input-diagnostics.js +36 -0
  78. package/dist/tasks/source/task-source-v4.js +626 -0
  79. package/dist/tasks/source-v3.js +10 -733
  80. package/dist/tasks/task-run-reserved-flags.js +79 -0
  81. package/dist/workflows/authoring/authoring.js +17 -8
  82. package/dist/workflows/exec/child-invocation.js +34 -0
  83. package/dist/workflows/exec/child-workflow.js +370 -0
  84. package/dist/workflows/exec/exec-unit.js +50 -170
  85. package/dist/workflows/exec/frozen-judge.js +19 -2
  86. package/dist/workflows/exec/native-executor.js +49 -27
  87. package/dist/workflows/exec/param-secrets.js +12 -0
  88. package/dist/workflows/exec/run-workflow.js +48 -59
  89. package/dist/workflows/exec/step-work.js +222 -80
  90. package/dist/workflows/exec/unit-dispatch.js +72 -0
  91. package/dist/workflows/freeze/child-output-references.js +94 -0
  92. package/dist/workflows/freeze/environment.js +174 -0
  93. package/dist/workflows/freeze/identity.js +22 -0
  94. package/dist/workflows/freeze/resolve-steps.js +78 -0
  95. package/dist/workflows/freeze/source-freeze.js +57 -0
  96. package/dist/workflows/freeze/step-values.js +68 -0
  97. package/dist/workflows/freeze/targets/child-workflow.js +206 -0
  98. package/dist/workflows/freeze/targets/command.js +81 -0
  99. package/dist/workflows/freeze/targets/script.js +57 -0
  100. package/dist/workflows/freeze/targets/shell.js +31 -0
  101. package/dist/workflows/freeze/targets/task.js +179 -0
  102. package/dist/workflows/freeze/task-bindings.js +180 -0
  103. package/dist/workflows/ir/compile.js +59 -11
  104. package/dist/workflows/ir/environment-v4.js +3 -3
  105. package/dist/workflows/ir/freeze-v4.js +41 -7
  106. package/dist/workflows/ir/params.js +58 -131
  107. package/dist/workflows/ir/plan-hash.js +3 -3
  108. package/dist/workflows/ir/schema-v4.js +246 -17
  109. package/dist/workflows/parser.js +74 -2
  110. package/dist/workflows/program/schema.js +5 -2
  111. package/dist/workflows/resource-limits.js +20 -0
  112. package/dist/workflows/runtime/plan-classifier.js +24 -7
  113. package/dist/workflows/runtime/run-outputs.js +103 -0
  114. package/dist/workflows/runtime/runs.js +114 -9
  115. package/dist/workflows/runtime/workflow-asset-loader.js +14 -6
  116. package/dist/workflows/source-files.js +5 -5
  117. package/dist/workflows/source-ir/compare.js +17 -0
  118. package/dist/workflows/source-ir/compile.js +7 -3
  119. package/dist/workflows/source-ir/github-yaml.js +64 -17
  120. package/dist/workflows/source-ir/schema.js +69 -21
  121. package/dist/workflows/source-ir/semantics.js +7 -25
  122. package/dist/workflows/source-ir/triggers.js +79 -0
  123. package/dist/workflows/source-ir/uses.js +33 -7
  124. package/docs/migration/README.md +1 -1
  125. package/docs/migration/release-notes/0.9.2.md +87 -11
  126. package/docs/migration/release-notes/README.md +3 -2
  127. package/docs/migration/v0.8-to-v0.9.md +13 -11
  128. package/docs/migration/v0.9.0-troubleshooting.md +20 -13
  129. package/docs/migration/v0.9.1-to-v0.9.2.md +598 -49
  130. package/docs/reference/README.md +1 -1
  131. package/docs/reference/cli.md +140 -46
  132. package/docs/reference/configuration.md +6 -5
  133. package/docs/reference/supported-formats.md +9 -5
  134. package/docs/reference/tasks.md +338 -75
  135. package/docs/reference/workflow-schema.md +290 -16
  136. package/docs/reference/workflows.md +57 -7
  137. package/package.json +1 -1
  138. package/schemas/akm-task.json +173 -118
  139. package/schemas/akm-workflow.json +28 -0
  140. package/dist/tasks/runner.js +0 -941
  141. package/dist/tasks/runtime-v3.js +0 -281
  142. package/dist/workflows/ir/source-freeze-v4.js +0 -506
  143. package/dist/workflows/source-ir/ordering.js +0 -38
@@ -1,941 +0,0 @@
1
- // This Source Code Form is subject to the terms of the Mozilla Public
2
- // License, v. 2.0. If a copy of the MPL was not distributed with this
3
- // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
- /**
5
- * `akm task run <id>` — what cron / launchd / schtasks invoke at the
6
- * scheduled moment.
7
- *
8
- * The durable boundary is intentional: id/bundle resolution, source read,
9
- * strict v3 parsing, target resolution, command authorization/lowering,
10
- * workflow projectability, and frozen script-byte capture all finish before
11
- * an attempt is reserved or a log is created. Once prepared, the runner skips
12
- * disabled scheduler firings or dispatches the immutable command, workflow,
13
- * shell, or script projection and records that actual attempt.
14
- *
15
- * Returns a structured result so the CLI handler can shape it for `output()`
16
- * and so tests can assert against it without scraping stdout.
17
- */
18
- import fs from "node:fs";
19
- import path from "node:path";
20
- import { dispatchPreparedCommandInvocation } from "../commands/command/command-execution.js";
21
- import { armAbortDeadline } from "../core/abort-deadline.js";
22
- import { shouldSkipUnactivatedTask } from "../core/activation-policy.js";
23
- import { detectAdapterId } from "../core/adapter/detect-adapter.js";
24
- import { assertNever } from "../core/assert.js";
25
- import { makeBundleRef } from "../core/asset/asset-ref.js";
26
- import { loadConfig } from "../core/config/config.js";
27
- import { AkmError, NotFoundError, rethrowIfTestIsolationError } from "../core/errors.js";
28
- import { buildTaskRunId, insertTaskLogLines, openLogsDatabase, } from "../core/logs-db.js";
29
- import { getTaskLogDir } from "../core/paths.js";
30
- import { redactCredentialPatterns, redactSensitiveText } from "../core/redaction.js";
31
- import { withStateDb } from "../core/state-db.js";
32
- import { runManagedSubprocess } from "../core/subprocess.js";
33
- import { resolveWriteTarget } from "../core/write-source.js";
34
- import { assertFrozenDirectoryIdentity } from "../execution/directory-identity.js";
35
- import { resolveAdapterConceptOwner } from "../indexer/lookup/adapter-concept-owner.js";
36
- import { resolveAssetPath } from "../sources/resolve.js";
37
- import { decodeTaskHistoryMetadata, finalizeTaskHistoryAttempt, getTaskHistory, getTaskHistoryRuns, queryTaskHistory, reserveTaskHistoryAttempt, upsertTaskHistory, } from "../storage/repositories/task-history-repository.js";
38
- import { runWorkflowSteps } from "../workflows/exec/run-workflow.js";
39
- import { cleanupFrozenScript, frozenScriptCommand, materializeFrozenScript } from "./frozen-script.js";
40
- import { collectTaskLogSensitiveValues } from "./log-redaction.js";
41
- import { resolveAkmInvocation } from "./resolve-akm-bin.js";
42
- import { prepareTaskV3Execution, } from "./runtime-v3.js";
43
- import { scheduledTaskContextEnv } from "./scheduler-invocation.js";
44
- import { parseTaskV3Yaml } from "./source-v3.js";
45
- import { validateTaskConceptId, validateTaskId } from "./task-id.js";
46
- export const INVALID_TASK_ATTEMPT_ID = "_invalid-task-id";
47
- const CONFIG_FREE_TASK_RUNTIME = Object.freeze({
48
- configVersion: "0.9.0",
49
- semanticSearchMode: "off",
50
- });
51
- export async function runTask(id, options) {
52
- const runWorkflowStepsImpl = options.runWorkflowStepsImpl ?? runWorkflowSteps;
53
- const now = options.now ?? (() => new Date());
54
- const requestedStartedAt = now();
55
- const stashDir = options.stashDir;
56
- const adapterId = options.adapterId ?? detectAdapterId(stashDir);
57
- if (adapterId === "akm-task")
58
- validateTaskConceptId(id);
59
- else
60
- validateTaskId(id);
61
- const taskConceptId = adapterId === "akm" ? `tasks/${id}` : id;
62
- const owner = resolveAdapterConceptOwner(stashDir, adapterId, taskConceptId);
63
- if (!owner) {
64
- throw new NotFoundError(`Task ${JSON.stringify(id)} was not found in the configured ${JSON.stringify(adapterId)} component.`, "ASSET_NOT_FOUND");
65
- }
66
- const filePath = owner.path;
67
- const yaml = fs.readFileSync(filePath, "utf8");
68
- const source = parseTaskV3Yaml({ yaml, filePath, workspaceRoot: stashDir });
69
- const requiresCommandConfig = source.target.kind === "uses" &&
70
- (source.target.uses.kind === "builtin-command" || source.target.uses.kind === "command");
71
- const config = requiresCommandConfig ? loadConfig() : CONFIG_FREE_TASK_RUNTIME;
72
- const bundleName = options.bundleName ?? config.defaultBundle ?? "stash";
73
- const task = await prepareTaskV3Execution(source, {
74
- taskId: id,
75
- taskRef: makeBundleRef(bundleName, taskConceptId),
76
- bundleName,
77
- bundleRoot: stashDir,
78
- config,
79
- // Agent profiles build child env from an allowlist, so freeze the closed
80
- // scheduler-restored AKM directory context before command preparation.
81
- ...(options.scheduled ? { schedulerContext: scheduledTaskContextEnv() } : {}),
82
- resolveAsset: async ({ bundle, type, name }) => {
83
- if (bundle === bundleName) {
84
- return { file: await resolveAssetPath(stashDir, type, name), bundleRoot: stashDir };
85
- }
86
- const resolutionConfig = requiresCommandConfig ? config : loadConfig();
87
- const resolvedBundle = resolveWriteTarget(resolutionConfig, bundle, { requireWritable: false });
88
- return {
89
- file: await resolveAssetPath(resolvedBundle.source.path, type, name),
90
- bundleRoot: resolvedBundle.source.path,
91
- };
92
- },
93
- });
94
- // All validation, parsing, source resolution, command cascade preparation,
95
- // and frozen-byte capture above is non-mutating. Only a fully projectable
96
- // task may reserve durable history or create a log.
97
- const attempt = reserveTaskAttempt(id, requestedStartedAt);
98
- const startedAt = attempt.startedAt;
99
- const startedIso = startedAt.toISOString();
100
- const logPath = resolveTaskLogPath(options.logDir, id, startedIso);
101
- try {
102
- if (shouldSkipUnactivatedTask({ enabled: task.enabled, scheduled: options.scheduled === true })) {
103
- return finishDisabledTask(task, logPath, startedAt, now(), attempt.historyReserved);
104
- }
105
- if (task.kind === "workflow") {
106
- return await runWorkflowTask({
107
- task,
108
- logPath,
109
- startedAt,
110
- now,
111
- runWorkflowStepsImpl,
112
- historyReserved: attempt.historyReserved,
113
- ...(options.setTimeoutFn ? { setTimeoutFn: options.setTimeoutFn } : {}),
114
- ...(options.clearTimeoutFn ? { clearTimeoutFn: options.clearTimeoutFn } : {}),
115
- });
116
- }
117
- if (task.kind === "command") {
118
- return await runPreparedCommandTask({
119
- task,
120
- logPath,
121
- startedAt,
122
- now,
123
- historyReserved: attempt.historyReserved,
124
- runAgentImpl: options.runAgentImpl,
125
- agentOptions: options.agentOptions,
126
- chatCompletionImpl: options.chatCompletionImpl,
127
- });
128
- }
129
- options.beforeNativeDispatch?.(task);
130
- return await runNativeTask({
131
- task,
132
- logPath,
133
- startedAt,
134
- now,
135
- historyReserved: attempt.historyReserved,
136
- ...(options.spawnFn ? { spawnFn: options.spawnFn } : {}),
137
- ...(options.setTimeoutFn ? { setTimeoutFn: options.setTimeoutFn } : {}),
138
- ...(options.clearTimeoutFn ? { clearTimeoutFn: options.clearTimeoutFn } : {}),
139
- });
140
- }
141
- catch (failure) {
142
- recordTaskAttemptFailure({
143
- taskId: id,
144
- reason: "task_dispatch_failed",
145
- failure,
146
- startedAt,
147
- finishedAt: now(),
148
- logDir: options.logDir,
149
- historyReserved: attempt.historyReserved,
150
- });
151
- throw failure;
152
- }
153
- }
154
- function preparedResultTarget(task) {
155
- if (task.kind === "workflow")
156
- return { kind: "workflow", ref: task.ref };
157
- if (task.kind === "command")
158
- return { kind: "prompt", engine: task.invocation.request.engine.name ?? null };
159
- return { kind: "command" };
160
- }
161
- function finishDisabledTask(task, logPath, startedAt, observedFinishedAt, historyReserved) {
162
- const finishedAt = finishAttempt(startedAt, observedFinishedAt);
163
- const line = `[akm task] task "${task.taskId}" is disabled — skipping run.`;
164
- const result = {
165
- id: task.taskId,
166
- status: "disabled",
167
- startedAt: startedAt.toISOString(),
168
- finishedAt: finishedAt.toISOString(),
169
- durationMs: finishedAt.getTime() - startedAt.getTime(),
170
- log: logPath,
171
- target: preparedResultTarget(task),
172
- };
173
- persistRunLog({
174
- taskId: task.taskId,
175
- startedAtIso: result.startedAt,
176
- finishedAtIso: result.finishedAt,
177
- logPath,
178
- fileText: `${line}\n`,
179
- dbLines: [{ line }],
180
- redactNames: task.redact,
181
- environment: task.environment,
182
- });
183
- appendHistory(result, historyReserved);
184
- return result;
185
- }
186
- // ── shell and frozen-script targets ─────────────────────────────────────────
187
- function shellCommand(task) {
188
- const command = resolveLeadingBareAkmCommand(task.command, task.shell);
189
- switch (task.shell) {
190
- case "sh":
191
- case "bash":
192
- case "zsh":
193
- return [task.shell, "-c", command];
194
- case "pwsh":
195
- case "powershell":
196
- return [task.shell, "-NoProfile", "-NonInteractive", "-Command", command];
197
- case "cmd":
198
- return ["cmd", "/d", "/s", "/c", command];
199
- default:
200
- return assertNever(task.shell, "shellCommand");
201
- }
202
- }
203
- /**
204
- * Bind an unambiguous leading bare `akm` (including the task-v2 migrator's
205
- * quoted form) to this installation. Explicit paths and arbitrary shell
206
- * fragments remain author-controlled.
207
- */
208
- function resolveLeadingBareAkmCommand(command, shell) {
209
- const leadingBareAkm = /^(\s*)(?:akm(?:\.exe)?|'akm(?:\.exe)?'|"akm(?:\.exe)?")(?=$|[\s;|&])/i;
210
- if (!leadingBareAkm.test(command))
211
- return command;
212
- const invocation = resolveAkmInvocation()
213
- .argv.map((part) => quoteShellArgument(part, shell))
214
- .join(" ");
215
- return command.replace(leadingBareAkm, (_match, leadingWhitespace) => `${leadingWhitespace}${invocation}`);
216
- }
217
- function quoteShellArgument(value, shell) {
218
- switch (shell) {
219
- case "sh":
220
- case "bash":
221
- case "zsh":
222
- return `'${value.replaceAll("'", `'"'"'`)}'`;
223
- case "pwsh":
224
- case "powershell":
225
- return `'${value.replaceAll("'", "''")}'`;
226
- case "cmd":
227
- return `"${value.replaceAll('"', '""')}"`;
228
- default:
229
- return assertNever(shell, "quoteShellArgument");
230
- }
231
- }
232
- async function runNativeTask(input) {
233
- const { task, logPath, startedAt, now, historyReserved } = input;
234
- let materialized;
235
- let cmd = task.kind === "shell" ? shellCommand(task) : [];
236
- // Unset → the unattended default; `null` → the explicit no-timeout opt-out.
237
- const timeoutMs = task.timeoutMs !== undefined ? task.timeoutMs : DEFAULT_SCHEDULED_TASK_TIMEOUT_MS;
238
- const header = task.kind === "shell"
239
- ? `[akm task] task=${task.taskId} kind=run shell=${task.shell}`
240
- : `[akm task] task=${task.taskId} kind=script ref=${task.sourceRef} sha256=${task.sha256}`;
241
- const logLines = [header];
242
- const dbLines = [{ line: header }];
243
- let exitCode = null;
244
- try {
245
- // The projector froze both canonical paths and filesystem identities before
246
- // history mutation. Re-resolve the authored root/cwd immediately before
247
- // spawn so a symlink, ancestor, bundle-root, or directory/file swap cannot
248
- // redirect execution outside that physical workspace.
249
- assertFrozenDirectoryIdentity(task.cwdIdentity);
250
- if (task.kind === "script") {
251
- materialized = materializeFrozenScript(task);
252
- cmd = frozenScriptCommand(task, materialized.file);
253
- }
254
- // Managed spawn (src/core/subprocess.ts): process-GROUP kill so a timeout
255
- // reaps the whole command tree (no orphans), and a SIGTERM→SIGKILL ladder
256
- // so a child that ignores SIGTERM can't wedge the run forever.
257
- const result = await runManagedSubprocess(cmd, {
258
- capture: true,
259
- cwd: task.cwd,
260
- // Stamp task-runner provenance so any akm invocation in the command tree
261
- // records usage events as machine traffic, not user demand (DRIFT-6).
262
- // A more specific stamp already in the environment (e.g. improve's
263
- // AKM_EVENT_SOURCE=improve on its child spawns) still wins in children.
264
- env: {
265
- ...process.env,
266
- ...task.environment,
267
- AKM_EVENT_SOURCE: process.env.AKM_EVENT_SOURCE ?? "task",
268
- },
269
- timeoutMs,
270
- ...(input.spawnFn ? { spawnFn: input.spawnFn } : {}),
271
- ...(input.setTimeoutFn ? { setTimeoutFn: input.setTimeoutFn } : {}),
272
- ...(input.clearTimeoutFn ? { clearTimeoutFn: input.clearTimeoutFn } : {}),
273
- });
274
- // A synchronous spawn throw / exit rejection surfaces as spawn_error below.
275
- if (result.spawnError)
276
- throw result.spawnError;
277
- const { stdout, stderr, timedOut } = result;
278
- exitCode = result.exitCode ?? (timedOut ? 143 : 1);
279
- if (timedOut) {
280
- logLines.push(`timed_out=true timeout_ms=${timeoutMs}`);
281
- dbLines.push({ level: "error", line: `timed_out=true timeout_ms=${timeoutMs}` });
282
- }
283
- logLines.push(`exit_code=${exitCode}`);
284
- dbLines.push({ level: exitCode === 0 ? "info" : "error", line: `exit_code=${exitCode}` });
285
- if (stdout) {
286
- logLines.push("--- stdout ---");
287
- logLines.push(stdout);
288
- dbLines.push(...streamLines(stdout, "stdout", "info"));
289
- }
290
- if (stderr) {
291
- logLines.push("--- stderr ---");
292
- logLines.push(stderr);
293
- dbLines.push(...streamLines(stderr, "stderr", "error"));
294
- }
295
- }
296
- catch (e) {
297
- const msg = e instanceof Error ? e.message : String(e);
298
- logLines.push(`spawn_error=${msg}`);
299
- dbLines.push({ level: "error", line: `spawn_error=${msg}` });
300
- exitCode = 1;
301
- }
302
- finally {
303
- if (materialized)
304
- cleanupFrozenScript(materialized);
305
- }
306
- const finishedAt = finishAttempt(startedAt, now());
307
- persistRunLog({
308
- taskId: task.taskId,
309
- startedAtIso: startedAt.toISOString(),
310
- finishedAtIso: finishedAt.toISOString(),
311
- logPath,
312
- fileText: `${logLines.join("\n")}\n`,
313
- dbLines,
314
- redactNames: task.redact,
315
- environment: task.environment,
316
- });
317
- const status = exitCode === 0 ? "completed" : "failed";
318
- const result = {
319
- id: task.taskId,
320
- status,
321
- startedAt: startedAt.toISOString(),
322
- finishedAt: finishedAt.toISOString(),
323
- durationMs: finishedAt.getTime() - startedAt.getTime(),
324
- log: logPath,
325
- target: { kind: "command", cmd },
326
- detail: { exitCode },
327
- };
328
- appendHistory(result, historyReserved);
329
- return result;
330
- }
331
- // ── workflow target ─────────────────────────────────────────────────────────
332
- /**
333
- * Whole-run timeout applied to a workflow-bound task that does not declare its
334
- * own `timeoutMs` — six hours.
335
- *
336
- * `akm workflow run` deliberately has NO default `--timeout`: a human is
337
- * watching, and Ctrl-C aborts the very same signal the flag's timer would.
338
- * A scheduled task has nobody watching. Without a default, its only bound is
339
- * the per-unit timeout — and a frozen plan may set `timeout: null` (unbounded),
340
- * so one wedged agent unit hangs the run until the machine reboots, holding the
341
- * run lease and silently skipping every later firing (issue 11).
342
- *
343
- * Six hours is deliberately generous rather than tight: the abort is graceful
344
- * (the engine breaks at the next step boundary and the run stays resumable), so
345
- * the cost of over-waiting is bounded while the cost of cutting a legitimate
346
- * long run short is a lost step. It matches the 6h idle window `akm health`
347
- * already uses to call a run stale (`commands/health/report-view-model.ts`),
348
- * and it lands well inside a `@daily` cadence, so a wedged run can never still
349
- * be holding the lease when the next day's firing arrives.
350
- *
351
- * An explicit `timeoutMs:` in the task file always wins; `timeoutMs: null` is
352
- * the explicit opt-out back to unbounded.
353
- */
354
- export const DEFAULT_WORKFLOW_TASK_TIMEOUT_MS = 6 * 60 * 60 * 1000;
355
- /**
356
- * The same unattended default for command and prompt tasks.
357
- *
358
- * The reasoning above is about SCHEDULED runs, not about workflows: nobody is
359
- * watching, and one wedged run silently stops the schedule. Command tasks
360
- * defaulted to `null` (no kill timer) and prompt tasks inherited
361
- * DEFAULT_AGENT_TIMEOUT_MS, also null — so a hung `curl`, a prompting agent
362
- * waiting on stdin, or a stuck engine wedged the task forever while the
363
- * workflow arm was protected. Same value, same opt-out: an explicit
364
- * `timeoutMs:` wins, and `timeoutMs: null` restores unbounded.
365
- */
366
- export const DEFAULT_SCHEDULED_TASK_TIMEOUT_MS = DEFAULT_WORKFLOW_TASK_TIMEOUT_MS;
367
- async function runWorkflowTask(input) {
368
- const { task, logPath, startedAt, now, runWorkflowStepsImpl, historyReserved } = input;
369
- // Unset → the unattended default; `null` → the explicit no-timeout opt-out.
370
- const timeoutMs = task.timeoutMs === undefined ? DEFAULT_WORKFLOW_TASK_TIMEOUT_MS : task.timeoutMs;
371
- // The shared deadline `akm workflow run --timeout` also arms
372
- // ({@link armAbortDeadline}): one AbortController for the run's lifetime,
373
- // aborted by a timer. The engine reads `options.signal` at every step
374
- // boundary and breaks GRACEFULLY — in-flight units are cancelled, the journal
375
- // and the run lease are retained, and the run is left `active`, i.e.
376
- // resumable with `akm workflow resume`.
377
- const controller = new AbortController();
378
- const deadline = armAbortDeadline(controller, {
379
- timeoutMs,
380
- reason: `Workflow task "${task.taskId}" timed out after ${timeoutMs}ms.`,
381
- ...(input.setTimeoutFn ? { setTimeoutFn: input.setTimeoutFn } : {}),
382
- ...(input.clearTimeoutFn ? { clearTimeoutFn: input.clearTimeoutFn } : {}),
383
- });
384
- let detail;
385
- let gateError;
386
- let error;
387
- // The prompt path logs the engine-fallback announcement; a workflow-backed
388
- // task must leave the same trace rather than silently using a chosen engine.
389
- let runWarnings = [];
390
- // Stamp task-runner provenance for the duration of the run (DRIFT-6), as the
391
- // command and prompt arms do. This arm executes IN-PROCESS, so the stamp goes
392
- // on process.env — child akm invocations made by workflow steps inherit it.
393
- // Without it, workflow-task traffic was recorded as user demand. A more
394
- // specific stamp already present wins, matching the command arm.
395
- const priorEventSource = process.env.AKM_EVENT_SOURCE;
396
- process.env.AKM_EVENT_SOURCE = priorEventSource ?? "task";
397
- try {
398
- const execution = await runWorkflowStepsImpl({
399
- target: task.ref,
400
- params: task.params,
401
- signal: controller.signal,
402
- ...(task.maxSteps !== undefined ? { maxSteps: task.maxSteps } : {}),
403
- ...(task.maxRetries !== undefined ? { maxRetries: task.maxRetries } : {}),
404
- });
405
- detail = execution.run;
406
- runWarnings = execution.warnings ?? [];
407
- if (execution.gateRejection) {
408
- gateError = `Verification rejected step "${execution.gateRejection.stepId}": ${execution.gateRejection.feedback}`;
409
- }
410
- }
411
- catch (e) {
412
- if (e instanceof AkmError && e.kind === "config")
413
- throw e;
414
- error = e instanceof Error ? e : new Error(String(e));
415
- }
416
- finally {
417
- deadline.disarm();
418
- if (priorEventSource === undefined)
419
- delete process.env.AKM_EVENT_SOURCE;
420
- else
421
- process.env.AKM_EVENT_SOURCE = priorEventSource;
422
- }
423
- // A timeout is a failed ATTEMPT even though the engine stopped cleanly: the
424
- // aborted run comes back `active` (resumable), which on its own would map to
425
- // task status "active" and a 0 exit code, telling the OS scheduler nothing
426
- // went wrong. Surface it like the command target's `timed_out=true` instead.
427
- //
428
- // Unless the run COMPLETED anyway. The abort is observed between steps, so a
429
- // deadline landing in the run's final bookkeeping can set the flag on a run
430
- // that then finishes — and reporting that as a failure would tell an operator
431
- // to resume a run with nothing left to resume.
432
- const ranToCompletion = detail?.status === "completed";
433
- const timedOutAfterMs = deadline.timedOut() && timeoutMs !== null && !ranToCompletion ? timeoutMs : undefined;
434
- const timeoutError = timedOutAfterMs === undefined
435
- ? undefined
436
- : new Error(`Workflow run timed out after ${timedOutAfterMs}ms and was aborted at a step boundary` +
437
- (detail?.id ? ` — resume it with \`akm workflow resume ${detail.id}\`.` : "."));
438
- // One failure value for the three sinks below (status, log line, history
439
- // detail): a thrown error outranks a gate rejection, which outranks the
440
- // deadline. Re-laddering per sink is how a log line ends up naming a
441
- // different cause than the history row it was written beside.
442
- const failure = error ?? (gateError ? new Error(gateError) : timeoutError);
443
- const finishedAt = finishAttempt(startedAt, now());
444
- const status = failure ? "failed" : mapWorkflowStatus(detail?.status);
445
- const log = renderWorkflowLog({
446
- task,
447
- detail,
448
- error: failure,
449
- warnings: runWarnings,
450
- ...(timedOutAfterMs !== undefined ? { timedOutAfterMs } : {}),
451
- });
452
- persistRunLog({
453
- taskId: task.taskId,
454
- startedAtIso: startedAt.toISOString(),
455
- finishedAtIso: finishedAt.toISOString(),
456
- logPath,
457
- fileText: log.fileText,
458
- dbLines: log.dbLines,
459
- redactNames: task.redact,
460
- environment: task.environment,
461
- });
462
- const result = {
463
- id: task.taskId,
464
- status,
465
- startedAt: startedAt.toISOString(),
466
- finishedAt: finishedAt.toISOString(),
467
- durationMs: finishedAt.getTime() - startedAt.getTime(),
468
- log: logPath,
469
- target: { kind: "workflow", ref: task.ref },
470
- detail: {
471
- runId: detail?.id,
472
- ...(failure ? { error: scrubTaskOutput(task, failure.message) } : {}),
473
- },
474
- };
475
- appendHistory(result, historyReserved);
476
- // Don't re-throw on workflow failure: the OS scheduler reads exit codes,
477
- // not exceptions, and the CLI maps `status: "failed"` to a non-zero exit
478
- // via exitCodeForStatus(). Throwing here would route through the generic
479
- // runWithJsonErrors path and lose the structured result/history we just
480
- // recorded.
481
- return result;
482
- }
483
- /**
484
- * Map the workflow runtime's status into the task-runner status space.
485
- * A workflow normally reaches completed or failed in one orchestration call.
486
- * Active remains representable for explicit engine stops such as a gate.
487
- *
488
- * The parameter is typed as the runtime's `WorkflowRunStatus` union (plus the
489
- * `undefined` that `detail?.run.status` can produce when no detail is present).
490
- * Every union member is handled explicitly and the `default` arm calls
491
- * `assertNever`, so adding a new `WorkflowRunStatus` variant without mapping it
492
- * here is a *compile* error rather than silently collapsing to "completed".
493
- * The previous silent `default: "completed"` is preserved only for the
494
- * `undefined` (no-detail) case, which is handled up front.
495
- */
496
- function mapWorkflowStatus(status) {
497
- // No run detail → treat as completed (unchanged from the prior silent default).
498
- if (status === undefined)
499
- return "completed";
500
- switch (status) {
501
- case "completed":
502
- case "blocked":
503
- case "failed":
504
- case "active":
505
- return status;
506
- default:
507
- return assertNever(status, "mapWorkflowStatus");
508
- }
509
- }
510
- function renderWorkflowLog(input) {
511
- const dbLines = [
512
- { line: `[akm task] task=${input.task.taskId} kind=workflow ref=${input.task.ref}` },
513
- ];
514
- for (const warning of input.warnings ?? [])
515
- dbLines.push({ level: "warn", line: warning });
516
- if (input.timedOutAfterMs !== undefined) {
517
- dbLines.push({ level: "error", line: `timed_out=true timeout_ms=${input.timedOutAfterMs}` });
518
- }
519
- if (input.detail) {
520
- dbLines.push({ line: `run_id=${input.detail.id} status=${input.detail.status}` });
521
- dbLines.push({ line: `workflow_title=${input.detail.workflowTitle}` });
522
- }
523
- if (input.error) {
524
- dbLines.push({ level: "error", line: `error=${input.error.message}` });
525
- }
526
- return { fileText: `${dbLines.map((entry) => entry.line).join("\n")}\n`, dbLines };
527
- }
528
- // ── common command target ───────────────────────────────────────────────────
529
- async function runPreparedCommandTask(input) {
530
- const { task, logPath, startedAt, now, agentOptions } = input;
531
- const result = await dispatchPreparedCommandInvocation(task.invocation, {
532
- ...(input.runAgentImpl ? { runAgent: input.runAgentImpl } : {}),
533
- ...(input.chatCompletionImpl ? { chat: input.chatCompletionImpl } : {}),
534
- ...(agentOptions ? { runOptions: agentOptions } : {}),
535
- });
536
- const engineName = result.engine;
537
- const finishedAt = finishAttempt(startedAt, now());
538
- const log = renderPromptLog({ task, engineName, result, notices: result.notices, warnings: result.warnings });
539
- persistRunLog({
540
- taskId: task.taskId,
541
- startedAtIso: startedAt.toISOString(),
542
- finishedAtIso: finishedAt.toISOString(),
543
- logPath,
544
- fileText: log.fileText,
545
- dbLines: log.dbLines,
546
- redactNames: task.redact,
547
- environment: task.environment,
548
- });
549
- const status = result.ok ? "completed" : "failed";
550
- const out = {
551
- id: task.taskId,
552
- status,
553
- startedAt: startedAt.toISOString(),
554
- finishedAt: finishedAt.toISOString(),
555
- durationMs: finishedAt.getTime() - startedAt.getTime(),
556
- log: logPath,
557
- target: { kind: "prompt", engine: engineName },
558
- detail: result.ok
559
- ? { exitCode: result.exitCode }
560
- : {
561
- reason: result.reason === undefined ? undefined : scrubTaskOutput(task, result.reason),
562
- error: result.error === undefined ? undefined : scrubTaskOutput(task, result.error),
563
- exitCode: result.exitCode,
564
- },
565
- ...(result.notices && result.notices.length > 0
566
- ? {
567
- notices: result.notices.map((notice) => ({
568
- ...notice,
569
- message: scrubTaskOutput(task, notice.message),
570
- })),
571
- }
572
- : {}),
573
- };
574
- appendHistory(out, input.historyReserved);
575
- return out;
576
- }
577
- function renderPromptLog(input) {
578
- const lines = [];
579
- const dbLines = [];
580
- const header = `[akm task] task=${input.task.taskId} kind=prompt engine=${input.engineName}`;
581
- const summary = `ok=${input.result.ok} exit_code=${input.result.exitCode ?? "null"} duration_ms=${input.result.durationMs}`;
582
- lines.push(header, summary);
583
- dbLines.push({ line: header }, { level: input.result.ok ? "info" : "error", line: summary });
584
- for (const warning of input.warnings ?? []) {
585
- lines.push(warning);
586
- dbLines.push({ level: "warn", line: warning });
587
- }
588
- for (const notice of input.notices ?? []) {
589
- const line = `lowering_notice=${notice.code} adapter=${notice.adapter} field=${notice.field ?? ""} message=${notice.message}`;
590
- lines.push(line);
591
- dbLines.push({ level: notice.severity === "warning" ? "warn" : "info", line });
592
- }
593
- if (!input.result.ok) {
594
- const failure = `reason=${input.result.reason ?? ""} error=${input.result.error ?? ""}`;
595
- lines.push(failure);
596
- dbLines.push({ level: "error", line: failure });
597
- }
598
- if (input.result.stdout) {
599
- lines.push("--- agent stdout ---");
600
- lines.push(input.result.stdout);
601
- dbLines.push(...streamLines(input.result.stdout, "stdout", "info"));
602
- }
603
- if (input.result.stderr) {
604
- lines.push("--- agent stderr ---");
605
- lines.push(input.result.stderr);
606
- dbLines.push(...streamLines(input.result.stderr, "stderr", "error"));
607
- }
608
- return { fileText: `${lines.join("\n")}\n`, dbLines };
609
- }
610
- function taskLogPath(logDir, taskId, startedAtIso) {
611
- const tsSlug = startedAtIso.replace(/[:.]/g, "-");
612
- return path.join(logDir, taskId, `${tsSlug}.log`);
613
- }
614
- function resolveTaskLogPath(logDir, taskId, startedAtIso) {
615
- try {
616
- return taskLogPath(logDir ?? getTaskLogDir(), taskId, startedAtIso);
617
- }
618
- catch (error) {
619
- rethrowIfTestIsolationError(error);
620
- return "";
621
- }
622
- }
623
- /**
624
- * Redact logs.db rows against the SAME contiguous text the file sink sees.
625
- *
626
- * The rows arrive already split on "\n" (see {@link streamLines}), but the
627
- * redaction needles are whole env values — and a needle containing a newline
628
- * can never match inside a single line. Scrubbing row-by-row therefore left
629
- * multi-line secrets (PEM keys, multi-line service-account credentials) intact
630
- * in logs.db while the flat .log was correctly scrubbed, defeating all three
631
- * tiers including the explicit `redact:` opt-in.
632
- *
633
- * Consecutive rows sharing a stream and level are rejoined, scrubbed as one
634
- * string, and re-split, so a needle spanning lines matches. Collapsing a
635
- * multi-line secret into a single [REDACTED] row is the intended outcome.
636
- */
637
- export function scrubDbLines(dbLines, scrub) {
638
- const out = [];
639
- for (let i = 0; i < dbLines.length;) {
640
- const { stream, level } = dbLines[i];
641
- let end = i;
642
- while (end < dbLines.length && dbLines[end].stream === stream && dbLines[end].level === level)
643
- end++;
644
- const joined = dbLines
645
- .slice(i, end)
646
- .map((entry) => entry.line)
647
- .join("\n");
648
- for (const line of scrub(joined).split("\n")) {
649
- if (line.length > 0)
650
- out.push({ stream, level, line });
651
- }
652
- i = end;
653
- }
654
- return out;
655
- }
656
- /** Split captured pipe output into per-line logs.db rows (blank lines dropped). */
657
- function streamLines(text, stream, level) {
658
- return (text
659
- .split("\n")
660
- // Windows child output is CRLF-terminated. Splitting on "\n" alone left a
661
- // trailing "\r" on every row and turned blank CRLF lines into phantom
662
- // rows containing just "\r" (length 1 passes the filter below).
663
- .map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
664
- .filter((line) => line.length > 0)
665
- .map((line) => ({ stream, level, line })));
666
- }
667
- /**
668
- * Persist a finished run's log: the flat text file (so `log_path` in
669
- * task_history keeps resolving for humans and older consumers) plus
670
- * structured rows in logs.db keyed by `buildTaskRunId(taskId, startedAt)`.
671
- *
672
- * Both sinks are pattern-redacted (`redactCredentialPatterns`) before being
673
- * written — task output is raw command/agent/LLM text that can echo a
674
- * credential-bearing URL (e.g. a Discord webhook) nothing upstream expects to
675
- * scrub.
676
- *
677
- * The DB write is best-effort, mirroring {@link appendHistory}: an unwritable
678
- * logs.db must never fail a task run.
679
- */
680
- /**
681
- * Exact secret values to scrub from this run's persisted output (#755).
682
- *
683
- * Best-effort by construction: this runs on the persistence path of a run that
684
- * has already finished, so a config that will not load must degrade to
685
- * "pattern-based redaction only" rather than fail the run. It does NOT degrade
686
- * to "log it anyway with no redaction at all" — `redactCredentialPatterns`
687
- * still runs unconditionally in the caller.
688
- */
689
- function taskLogSensitiveValues(redactNames, environment) {
690
- const env = { ...process.env, ...environment };
691
- try {
692
- return collectTaskLogSensitiveValues({
693
- env,
694
- config: loadConfig(),
695
- declaredNames: redactNames,
696
- });
697
- }
698
- catch (error) {
699
- rethrowIfTestIsolationError(error);
700
- // No config — the name heuristic and the task's own `redact:` list still apply.
701
- try {
702
- return collectTaskLogSensitiveValues({ env, declaredNames: redactNames });
703
- }
704
- catch (fallbackError) {
705
- rethrowIfTestIsolationError(fallbackError);
706
- return [];
707
- }
708
- }
709
- }
710
- function scrubTaskOutput(task, text) {
711
- const patterned = redactCredentialPatterns(text);
712
- const sensitive = taskLogSensitiveValues(task.redact, task.environment);
713
- return sensitive.length > 0 ? redactSensitiveText(patterned, sensitive) : patterned;
714
- }
715
- function persistRunLog(input) {
716
- // Two arms, and both are needed. `redactCredentialPatterns` catches
717
- // credential SHAPES nobody listed; the exact-value pass catches configured
718
- // secrets whose value is shaped like nothing in particular (#755). The
719
- // command target had only the first, so a scheduled command that echoed an
720
- // ordinary-looking secret persisted it verbatim to both sinks. Applying the
721
- // exact pass here — the one sink all three target kinds funnel through —
722
- // covers every arm once rather than per-arm; prompt/workflow runs already
723
- // scrub upstream, and redaction is idempotent, so the overlap is free.
724
- const sensitive = taskLogSensitiveValues(input.redactNames, input.environment);
725
- const scrub = (text) => sensitive.length > 0
726
- ? redactSensitiveText(redactCredentialPatterns(text), sensitive)
727
- : redactCredentialPatterns(text);
728
- const fileText = scrub(input.fileText);
729
- const dbLines = scrubDbLines(input.dbLines, scrub);
730
- if (input.logPath) {
731
- try {
732
- // Written at the process umask. #756 pinned 0600/0700 here; that went out
733
- // with the rest of akm's permission enforcement (#791) — the operator owns
734
- // the mode of their own data directory, and akm neither sets nor reports
735
- // on it.
736
- fs.mkdirSync(path.dirname(input.logPath), { recursive: true });
737
- fs.writeFileSync(input.logPath, fileText);
738
- }
739
- catch (error) {
740
- rethrowIfTestIsolationError(error);
741
- // Transitional file logging is fully best-effort.
742
- }
743
- }
744
- try {
745
- const db = openLogsDatabase();
746
- try {
747
- insertTaskLogLines(db, {
748
- taskId: input.taskId,
749
- runId: buildTaskRunId(input.taskId, input.startedAtIso),
750
- ts: input.finishedAtIso,
751
- lines: dbLines,
752
- });
753
- }
754
- finally {
755
- db.close();
756
- }
757
- }
758
- catch (error) {
759
- rethrowIfTestIsolationError(error);
760
- // Structured logging is fully best-effort and must not alter CLI output.
761
- }
762
- }
763
- /** Reserve a collision-free identity through state.db's existing unique index. */
764
- function reserveTaskAttempt(taskId, requestedStartedAt) {
765
- try {
766
- return withStateDb((db) => {
767
- for (let offsetMs = 0;; offsetMs++) {
768
- const startedAt = new Date(requestedStartedAt.getTime() + offsetMs);
769
- const reserved = reserveTaskHistoryAttempt(db, {
770
- task_id: taskId,
771
- status: "active",
772
- started_at: startedAt.toISOString(),
773
- completed_at: null,
774
- failed_at: null,
775
- log_path: null,
776
- target_kind: null,
777
- target_ref: null,
778
- metadata_json: JSON.stringify({ metadataVersion: 2, durationMs: 0, detail: null }),
779
- });
780
- if (reserved)
781
- return { startedAt, historyReserved: true };
782
- }
783
- });
784
- }
785
- catch (error) {
786
- rethrowIfTestIsolationError(error);
787
- // Attempt recording cannot prevent or replace task execution.
788
- return { startedAt: requestedStartedAt, historyReserved: false };
789
- }
790
- }
791
- function finishAttempt(startedAt, observedFinishedAt) {
792
- return observedFinishedAt.getTime() < startedAt.getTime() ? new Date(startedAt) : observedFinishedAt;
793
- }
794
- const SAFE_TASK_ATTEMPT_ERROR_CODES = new Set([
795
- "CONFIG_DIR_UNRESOLVABLE",
796
- "STASH_DIR_NOT_FOUND",
797
- "STASH_DIR_NOT_A_DIRECTORY",
798
- "STASH_DIR_UNREADABLE",
799
- "LLM_NOT_CONFIGURED",
800
- "INVALID_CONFIG_FILE",
801
- "UNSUPPORTED_CONFIG_VERSION",
802
- "TEST_ISOLATION_MISSING",
803
- "INVALID_FLAG_VALUE",
804
- "MISSING_REQUIRED_ARGUMENT",
805
- "PATH_ESCAPE_VIOLATION",
806
- "TASK_SCHEMA_VERSION_UNSUPPORTED",
807
- "ASSET_NOT_FOUND",
808
- "WORKFLOW_NOT_FOUND",
809
- "FILE_NOT_FOUND",
810
- ]);
811
- function safeTaskAttemptErrorCode(failure) {
812
- if (failure instanceof AkmError && SAFE_TASK_ATTEMPT_ERROR_CODES.has(failure.code))
813
- return failure.code;
814
- return "INTERNAL";
815
- }
816
- export function recordTaskAttemptFailure(input) {
817
- let taskId = input.taskId;
818
- try {
819
- validateTaskId(taskId);
820
- }
821
- catch {
822
- taskId = INVALID_TASK_ATTEMPT_ID;
823
- }
824
- const attempt = input.historyReserved === undefined
825
- ? reserveTaskAttempt(taskId, input.startedAt)
826
- : { startedAt: input.startedAt, historyReserved: input.historyReserved };
827
- const finishedAt = finishAttempt(attempt.startedAt, input.finishedAt ?? new Date());
828
- const startedAtIso = attempt.startedAt.toISOString();
829
- const finishedAtIso = finishedAt.toISOString();
830
- const errorCode = safeTaskAttemptErrorCode(input.failure);
831
- const logPath = resolveTaskLogPath(input.logDir, taskId, startedAtIso);
832
- const line = `[akm task] status=failed reason=${input.reason} code=${errorCode}`;
833
- const result = {
834
- id: taskId,
835
- status: "failed",
836
- startedAt: startedAtIso,
837
- finishedAt: finishedAtIso,
838
- durationMs: Math.max(0, finishedAt.getTime() - attempt.startedAt.getTime()),
839
- log: logPath,
840
- target: { kind: "unknown" },
841
- detail: { reason: input.reason, error: errorCode },
842
- };
843
- persistRunLog({
844
- taskId,
845
- startedAtIso,
846
- finishedAtIso,
847
- logPath,
848
- fileText: `${line}\n`,
849
- dbLines: [{ level: "error", line }],
850
- });
851
- appendHistory(result, attempt.historyReserved);
852
- }
853
- // ── history ─────────────────────────────────────────────────────────────────
854
- function appendHistory(result, historyReserved = false) {
855
- const row = {
856
- task_id: result.id,
857
- status: result.status,
858
- started_at: result.startedAt,
859
- completed_at: result.finishedAt,
860
- failed_at: result.status === "failed" ? result.finishedAt : null,
861
- log_path: result.log || null,
862
- target_kind: result.target.kind === "unknown" ? null : result.target.kind,
863
- target_ref: result.target.kind === "workflow" ? result.target.ref : null,
864
- metadata_json: JSON.stringify({
865
- metadataVersion: 2,
866
- durationMs: result.durationMs,
867
- detail: result.detail ?? null,
868
- ...(result.target.kind === "prompt" ? { engine: result.target.engine } : {}),
869
- }),
870
- };
871
- try {
872
- withStateDb((db) => {
873
- if (historyReserved && finalizeTaskHistoryAttempt(db, row))
874
- return;
875
- upsertTaskHistory(db, row);
876
- });
877
- }
878
- catch (error) {
879
- rethrowIfTestIsolationError(error);
880
- // History recording is fully best-effort and must not alter CLI output.
881
- }
882
- }
883
- export function readTaskHistory(options = {}) {
884
- return withStateDb((db) => {
885
- if (options.limit === 0)
886
- return [];
887
- if (options.id) {
888
- // An id-scoped query used the single-row helper, so `--limit` was silently
889
- // discarded and `akm task history --id X --limit 20` always returned one
890
- // run. The CLI documents --limit as "Maximum rows to return"; honour it.
891
- if (options.limit !== undefined && options.limit > 0) {
892
- return getTaskHistoryRuns(db, options.id, options.limit).map(taskHistoryRowToResult);
893
- }
894
- const row = getTaskHistory(db, options.id);
895
- return row ? [taskHistoryRowToResult(row)] : [];
896
- }
897
- return queryTaskHistory(db, options.limit !== undefined && options.limit > 0 ? { limit: options.limit } : {}).map(taskHistoryRowToResult);
898
- });
899
- }
900
- /**
901
- * Convert a `TaskHistoryRow` from state.db back to a `TaskRunResult` shape
902
- * that callers of `readTaskHistory()` expect.
903
- */
904
- function taskHistoryRowToResult(row) {
905
- const meta = decodeTaskHistoryMetadata(row.metadata_json);
906
- const target = row.target_kind === "workflow"
907
- ? { kind: "workflow", ref: row.target_ref ?? "" }
908
- : row.target_kind === "command"
909
- ? { kind: "command" }
910
- : row.target_kind === "prompt"
911
- ? { kind: "prompt", engine: meta.engine ?? null }
912
- : { kind: "unknown" };
913
- return {
914
- id: row.task_id,
915
- status: row.status,
916
- startedAt: row.started_at,
917
- finishedAt: row.completed_at ?? row.failed_at ?? row.started_at,
918
- durationMs: meta.durationMs,
919
- log: row.log_path ?? "",
920
- target,
921
- ...(meta.detail ? { detail: meta.detail } : {}),
922
- };
923
- }
924
- /**
925
- * The exit code surfaced to the OS scheduler. Mapped from {@link TaskRunStatus}
926
- * so cron / launchd / schtasks see a useful return value.
927
- */
928
- export function exitCodeForStatus(status) {
929
- switch (status) {
930
- case "completed":
931
- return 0;
932
- case "active":
933
- return 0;
934
- case "blocked":
935
- return 1;
936
- case "failed":
937
- return 1;
938
- case "disabled":
939
- return 0;
940
- }
941
- }