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.
- package/CHANGELOG.md +493 -0
- package/STABILITY.md +23 -5
- package/dist/assets/hints/cli-hints-full.md +12 -7
- package/dist/assets/tasks/core/extract.yml +3 -5
- package/dist/assets/tasks/core/improve.yml +3 -5
- package/dist/assets/tasks/core/index-refresh.yml +3 -5
- package/dist/assets/tasks/core/sync.yml +3 -5
- package/dist/assets/tasks/core/version-check.yml +3 -5
- package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +3 -5
- package/dist/assets/tasks/improve/akm-improve-catchup.yml +6 -6
- package/dist/assets/tasks/improve/akm-improve-consolidate.yml +3 -5
- package/dist/assets/tasks/improve/akm-improve-frequent.yml +3 -5
- package/dist/assets/tasks/improve/akm-improve-nightly.yml +3 -5
- package/dist/cli/unknown-flags.js +12 -1
- package/dist/cli.js +8 -1
- package/dist/commands/command/command-execution.js +23 -2
- package/dist/commands/health/improve-metrics.js +38 -0
- package/dist/commands/health/windows.js +8 -4
- package/dist/commands/health.js +8 -4
- package/dist/commands/lint/index.js +1 -1
- package/dist/commands/migrate-cli.js +130 -24
- package/dist/commands/proposal/validators/proposal-validators.js +7 -2
- package/dist/commands/tasks/explain.js +304 -0
- package/dist/commands/tasks/tasks-cli.js +185 -3
- package/dist/commands/tasks/tasks.js +265 -52
- package/dist/commands/workflow/plan.js +159 -0
- package/dist/commands/workflow-cli.js +94 -2
- package/dist/core/activation-policy.js +2 -12
- package/dist/core/adapter/adapters/akm-lint.js +7 -4
- package/dist/core/adapter/adapters/akm-metadata.js +26 -14
- package/dist/core/adapter/adapters/akm-task-adapter.js +13 -10
- package/dist/core/errors.js +45 -0
- package/dist/core/json-schema.js +15 -5
- package/dist/core/state/migrations.js +57 -0
- package/dist/core/state-db.js +16 -14
- package/dist/core/subprocess.js +47 -13
- package/dist/execution/guarded-source.js +44 -0
- package/dist/execution/input-contract.js +250 -0
- package/dist/execution/target-ref.js +63 -0
- package/dist/indexer/usage/usage-events.js +14 -3
- package/dist/integrations/agent/execution-lowering.js +12 -1
- package/dist/output/shapes/passthrough.js +2 -0
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/migrate.js +12 -3
- package/dist/output/text/workflow-format.js +192 -10
- package/dist/output/text/workflow.js +2 -1
- package/dist/runtime.js +1 -0
- package/dist/scripts/akm-migrate-node.js +11838 -10118
- package/dist/scripts/akm-migrate.js +11828 -10117
- package/dist/setup/steps/tasks.js +34 -17
- package/dist/storage/repositories/task-history-repository.js +5 -1
- package/dist/storage/repositories/workflow-runs-repository.js +144 -6
- package/dist/tasks/backends/launchd.js +31 -84
- package/dist/tasks/embedded.js +13 -7
- package/dist/tasks/model/invocation.js +4 -0
- package/dist/tasks/prepare/prepare-script-target.js +9 -0
- package/dist/tasks/prepare/prepare-support.js +154 -0
- package/dist/tasks/prepare/prepare.js +117 -0
- package/dist/tasks/prepare/prepared-execution.js +4 -0
- package/dist/tasks/prepare/script-capture.js +80 -0
- package/dist/tasks/run/attempt-lifecycle.js +165 -0
- package/dist/tasks/run/load-task.js +117 -0
- package/dist/tasks/run/provenance.js +20 -0
- package/dist/tasks/run/run-command-task.js +92 -0
- package/dist/tasks/run/run-native-task.js +222 -0
- package/dist/tasks/run/run-task.js +99 -0
- package/dist/tasks/run/run-workflow-task.js +222 -0
- package/dist/tasks/run/task-history.js +134 -0
- package/dist/tasks/run/task-log.js +179 -0
- package/dist/tasks/run/task-result.js +19 -0
- package/dist/tasks/scheduler-binding.js +66 -2
- package/dist/tasks/scheduler-invocation.js +63 -3
- package/dist/tasks/scheduler-sync.js +77 -14
- package/dist/tasks/source/bounded-document.js +455 -0
- package/dist/tasks/source/parse-task-source.js +59 -0
- package/dist/tasks/source/project-v4.js +62 -0
- package/dist/tasks/source/task-input-diagnostics.js +36 -0
- package/dist/tasks/source/task-source-v4.js +626 -0
- package/dist/tasks/source-v3.js +10 -733
- package/dist/tasks/task-run-reserved-flags.js +79 -0
- package/dist/workflows/authoring/authoring.js +17 -8
- package/dist/workflows/exec/child-invocation.js +34 -0
- package/dist/workflows/exec/child-workflow.js +370 -0
- package/dist/workflows/exec/exec-unit.js +50 -170
- package/dist/workflows/exec/frozen-judge.js +19 -2
- package/dist/workflows/exec/native-executor.js +49 -27
- package/dist/workflows/exec/param-secrets.js +12 -0
- package/dist/workflows/exec/run-workflow.js +48 -59
- package/dist/workflows/exec/step-work.js +222 -80
- package/dist/workflows/exec/unit-dispatch.js +72 -0
- package/dist/workflows/freeze/child-output-references.js +94 -0
- package/dist/workflows/freeze/environment.js +174 -0
- package/dist/workflows/freeze/identity.js +22 -0
- package/dist/workflows/freeze/resolve-steps.js +78 -0
- package/dist/workflows/freeze/source-freeze.js +57 -0
- package/dist/workflows/freeze/step-values.js +68 -0
- package/dist/workflows/freeze/targets/child-workflow.js +206 -0
- package/dist/workflows/freeze/targets/command.js +81 -0
- package/dist/workflows/freeze/targets/script.js +57 -0
- package/dist/workflows/freeze/targets/shell.js +31 -0
- package/dist/workflows/freeze/targets/task.js +179 -0
- package/dist/workflows/freeze/task-bindings.js +180 -0
- package/dist/workflows/ir/compile.js +59 -11
- package/dist/workflows/ir/environment-v4.js +3 -3
- package/dist/workflows/ir/freeze-v4.js +41 -7
- package/dist/workflows/ir/params.js +58 -131
- package/dist/workflows/ir/plan-hash.js +3 -3
- package/dist/workflows/ir/schema-v4.js +246 -17
- package/dist/workflows/parser.js +74 -2
- package/dist/workflows/program/schema.js +5 -2
- package/dist/workflows/resource-limits.js +20 -0
- package/dist/workflows/runtime/plan-classifier.js +24 -7
- package/dist/workflows/runtime/run-outputs.js +103 -0
- package/dist/workflows/runtime/runs.js +114 -9
- package/dist/workflows/runtime/workflow-asset-loader.js +14 -6
- package/dist/workflows/source-files.js +5 -5
- package/dist/workflows/source-ir/compare.js +17 -0
- package/dist/workflows/source-ir/compile.js +7 -3
- package/dist/workflows/source-ir/github-yaml.js +64 -17
- package/dist/workflows/source-ir/schema.js +69 -21
- package/dist/workflows/source-ir/semantics.js +7 -25
- package/dist/workflows/source-ir/triggers.js +79 -0
- package/dist/workflows/source-ir/uses.js +33 -7
- package/docs/migration/README.md +1 -1
- package/docs/migration/release-notes/0.9.2.md +87 -11
- package/docs/migration/release-notes/README.md +3 -2
- package/docs/migration/v0.8-to-v0.9.md +13 -11
- package/docs/migration/v0.9.0-troubleshooting.md +20 -13
- package/docs/migration/v0.9.1-to-v0.9.2.md +598 -49
- package/docs/reference/README.md +1 -1
- package/docs/reference/cli.md +140 -46
- package/docs/reference/configuration.md +6 -5
- package/docs/reference/supported-formats.md +9 -5
- package/docs/reference/tasks.md +338 -75
- package/docs/reference/workflow-schema.md +290 -16
- package/docs/reference/workflows.md +57 -7
- package/package.json +1 -1
- package/schemas/akm-task.json +173 -118
- package/schemas/akm-workflow.json +28 -0
- package/dist/tasks/runner.js +0 -941
- package/dist/tasks/runtime-v3.js +0 -281
- package/dist/workflows/ir/source-freeze-v4.js +0 -506
- package/dist/workflows/source-ir/ordering.js +0 -38
package/dist/tasks/runner.js
DELETED
|
@@ -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
|
-
}
|