akm-cli 0.9.0 → 0.9.1-beta.1

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 (109) hide show
  1. package/CHANGELOG.md +707 -0
  2. package/README.md +28 -63
  3. package/STABILITY.md +4 -2
  4. package/dist/commands/agent/contribute-cli.js +1 -1
  5. package/dist/commands/feedback-cli.js +7 -1
  6. package/dist/commands/health/llm-usage.js +2 -1
  7. package/dist/commands/health/surfaces.js +4 -77
  8. package/dist/commands/health.js +65 -11
  9. package/dist/commands/improve/distill/quality-gate.js +6 -1
  10. package/dist/commands/improve/eligibility.js +7 -1
  11. package/dist/commands/improve/improve.js +126 -10
  12. package/dist/commands/improve/locks.js +7 -0
  13. package/dist/commands/improve/memory/memory-improve.js +8 -0
  14. package/dist/commands/improve/run-context.js +5 -0
  15. package/dist/commands/improve/session-asset.js +4 -0
  16. package/dist/commands/lint/base-linter.js +31 -7
  17. package/dist/commands/lint/index.js +200 -50
  18. package/dist/commands/lint/types.js +22 -1
  19. package/dist/commands/proposal/repository.js +17 -1
  20. package/dist/commands/sources/info.js +12 -2
  21. package/dist/commands/sources/installed-stashes.js +6 -1
  22. package/dist/commands/tasks/tasks.js +8 -2
  23. package/dist/commands/workflow-cli.js +17 -11
  24. package/dist/core/abort-deadline.js +28 -0
  25. package/dist/core/adapter/adapters/agent-skills-adapter.js +67 -5
  26. package/dist/core/adapter/adapters/akm-adapter.js +13 -10
  27. package/dist/core/adapter/adapters/akm-lint.js +72 -20
  28. package/dist/core/adapter/adapters/akm-task-adapter.js +40 -19
  29. package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
  30. package/dist/core/asset/frontmatter.js +4 -0
  31. package/dist/core/common.js +66 -6
  32. package/dist/core/concurrent.js +32 -0
  33. package/dist/core/config/schema/workflow.js +11 -0
  34. package/dist/core/errors.js +25 -0
  35. package/dist/core/events.js +30 -24
  36. package/dist/core/file-lock.js +7 -1
  37. package/dist/core/improve-result.js +5 -0
  38. package/dist/core/json-schema.js +327 -9
  39. package/dist/core/loopback.js +89 -0
  40. package/dist/core/migration-operation.js +17 -2
  41. package/dist/core/path-access.js +107 -0
  42. package/dist/core/redaction.js +64 -17
  43. package/dist/core/spawn-env.js +234 -0
  44. package/dist/core/state-db-scope.js +134 -0
  45. package/dist/core/subprocess.js +181 -37
  46. package/dist/core/write-provenance.js +85 -0
  47. package/dist/core/write-source.js +7 -0
  48. package/dist/indexer/db/graph-db.js +17 -6
  49. package/dist/indexer/ensure-index.js +10 -3
  50. package/dist/indexer/index-written-assets.js +17 -2
  51. package/dist/indexer/indexer.js +55 -15
  52. package/dist/indexer/passes/memory-inference.js +4 -0
  53. package/dist/indexer/search/db-search.js +8 -15
  54. package/dist/integrations/agent/engine-resolution.js +24 -11
  55. package/dist/integrations/agent/model-aliases.js +1 -1
  56. package/dist/integrations/agent/profiles.js +9 -1
  57. package/dist/integrations/agent/spawn.js +15 -87
  58. package/dist/integrations/lockfile.js +45 -2
  59. package/dist/output/text/lint-format.js +17 -4
  60. package/dist/scripts/akm-migrate-node.js +1435 -753
  61. package/dist/scripts/akm-migrate.js +1403 -721
  62. package/dist/sources/providers/git-stash.js +19 -0
  63. package/dist/sources/providers/git.js +1 -1
  64. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
  65. package/dist/storage/database.js +6 -1
  66. package/dist/storage/managed-db.js +19 -0
  67. package/dist/storage/repositories/index-connection.js +28 -3
  68. package/dist/storage/repositories/index-entries-repository.js +6 -1
  69. package/dist/storage/repositories/workflow-runs-repository.js +66 -13
  70. package/dist/tasks/log-redaction.js +156 -0
  71. package/dist/tasks/parser.js +82 -5
  72. package/dist/tasks/runner.js +139 -11
  73. package/dist/tasks/schema.js +65 -0
  74. package/dist/workflows/concurrency-policy.js +95 -1
  75. package/dist/workflows/exec/dispatch-redaction.js +114 -0
  76. package/dist/workflows/exec/exec-unit.js +542 -0
  77. package/dist/workflows/exec/frozen-judge.js +114 -42
  78. package/dist/workflows/exec/native-executor.js +457 -238
  79. package/dist/workflows/exec/param-secrets.js +4 -3
  80. package/dist/workflows/exec/run-workflow.js +424 -219
  81. package/dist/workflows/exec/step-work.js +496 -165
  82. package/dist/workflows/exec/unit-dispatch.js +31 -1
  83. package/dist/workflows/exec/unit-writer.js +53 -13
  84. package/dist/workflows/exec/worktree.js +454 -41
  85. package/dist/workflows/ir/compile.js +26 -2
  86. package/dist/workflows/ir/freeze.js +82 -15
  87. package/dist/workflows/ir/schema.js +105 -20
  88. package/dist/workflows/parser.js +216 -18
  89. package/dist/workflows/program/schema.js +24 -0
  90. package/dist/workflows/renderer.js +32 -4
  91. package/dist/workflows/resource-limits.js +182 -0
  92. package/dist/workflows/runtime/runs.js +146 -6
  93. package/dist/workflows/validate-summary.js +17 -2
  94. package/docs/README.md +74 -32
  95. package/docs/migration/release-notes/0.9.0.md +2 -1
  96. package/docs/migration/v0.7-to-v0.8.md +2 -1
  97. package/docs/migration/v0.8-to-v0.9.md +3 -1
  98. package/docs/reference/README.md +11 -4
  99. package/docs/reference/bundle-types.md +19 -0
  100. package/docs/reference/cli.md +105 -16
  101. package/docs/reference/configuration.md +15 -2
  102. package/docs/reference/data-and-telemetry.md +30 -10
  103. package/docs/reference/supported-formats.md +50 -0
  104. package/docs/reference/workflow-schema.md +1014 -0
  105. package/docs/reference/workflows.md +37 -633
  106. package/package.json +13 -6
  107. package/schemas/akm-config.json +8 -0
  108. package/schemas/akm-task.json +27 -5
  109. package/schemas/akm-workflow.json +85 -10
@@ -12,6 +12,10 @@
12
12
  * workflow: workflows/daily-backup
13
13
  * params:
14
14
  * region: us-east-1
15
+ * timeoutMs: 3600000 # whole-run bound; omit for the unattended
16
+ * # default, `null` to opt out entirely
17
+ * maxSteps: 20 # optional run bounds, same as the
18
+ * maxRetries: 1 # `akm workflow run` flags
15
19
  * # ...or:
16
20
  * prompt: agents/my-agent # asset ref
17
21
  * # ...or:
@@ -27,6 +31,8 @@
27
31
  * description: …
28
32
  * when_to_use: …
29
33
  * tags: [scheduled, backup]
34
+ * redact: [ACME_DEPLOY_TOKEN] # optional: env var NAMES (never values) to
35
+ * # scrub from this task's persisted log
30
36
  * ```
31
37
  *
32
38
  * Validation lives in {@link validateTaskDocument}. The parser enforces the
@@ -38,7 +44,8 @@ import { parse as parseYaml } from "yaml";
38
44
  import { isFullRefInput } from "../core/asset/resolve-ref.js";
39
45
  import { UsageError } from "../core/errors.js";
40
46
  import { formatExtraParamsIssue, validateExtraParams } from "../core/extra-params.js";
41
- import { TASK_SCHEMA_VERSION } from "./schema.js";
47
+ import { WORKFLOW_ENV_VAR_NAME_PATTERN, WORKFLOW_MAX_RETRIES } from "../workflows/resource-limits.js";
48
+ import { TASK_MAX_REDACT_NAMES, TASK_MAX_TIMEOUT_MS, TASK_SCHEMA_VERSION, } from "./schema.js";
42
49
  import { validateTaskId } from "./task-id.js";
43
50
  export function parseTaskDocument(input) {
44
51
  const { yaml, filePath } = input;
@@ -79,15 +86,25 @@ export function parseTaskDocument(input) {
79
86
  }
80
87
  let target;
81
88
  if (hasWorkflow) {
82
- rejectTargetFields(data, ["params"], id, filePath);
89
+ rejectTargetFields(data, ["params", "timeoutMs", "maxSteps", "maxRetries"], id, filePath);
83
90
  const ref = requireString(data.workflow, "workflow", filePath);
84
91
  if (!ref) {
85
92
  throw new UsageError(`Task "${id}" has empty \`workflow\`. File: ${filePath}`, "INVALID_FLAG_VALUE");
86
93
  }
94
+ // The three run bounds `akm workflow run` takes as flags, declared in the
95
+ // task file instead: an unattended run gets the same abort path the
96
+ // interactive CLI has. `timeoutMs` left unset falls back to the runner's
97
+ // default (see DEFAULT_WORKFLOW_TASK_TIMEOUT_MS); `null` opts out.
98
+ const workflowTimeoutMs = readTimeout(data.timeoutMs, filePath);
99
+ const maxSteps = readBoundedInteger(data.maxSteps, "maxSteps", 1, undefined, filePath);
100
+ const maxRetries = readBoundedInteger(data.maxRetries, "maxRetries", 0, WORKFLOW_MAX_RETRIES, filePath);
87
101
  target = {
88
102
  kind: "workflow",
89
103
  ref,
90
104
  params: readParams(data.params, filePath),
105
+ ...(workflowTimeoutMs !== undefined ? { timeoutMs: workflowTimeoutMs } : {}),
106
+ ...(maxSteps !== undefined ? { maxSteps } : {}),
107
+ ...(maxRetries !== undefined ? { maxRetries } : {}),
91
108
  };
92
109
  }
93
110
  else if (hasCommand) {
@@ -114,6 +131,7 @@ export function parseTaskDocument(input) {
114
131
  };
115
132
  }
116
133
  const timeoutMs = hasCommand ? readTimeout(data.timeoutMs, filePath) : undefined;
134
+ const redact = readRedactNames(data.redact, filePath);
117
135
  return {
118
136
  version: TASK_SCHEMA_VERSION,
119
137
  schemaVersion: TASK_SCHEMA_VERSION,
@@ -127,6 +145,7 @@ export function parseTaskDocument(input) {
127
145
  ...(tags ? { tags } : {}),
128
146
  source: { path: filePath },
129
147
  timeoutMs,
148
+ ...(redact ? { redact } : {}),
130
149
  };
131
150
  }
132
151
  const TASK_KEYS = new Set([
@@ -144,9 +163,22 @@ const TASK_KEYS = new Set([
144
163
  "engine",
145
164
  "model",
146
165
  "timeoutMs",
166
+ "maxSteps",
167
+ "maxRetries",
147
168
  "llm",
169
+ "redact",
170
+ ]);
171
+ const SHARED_KEYS = new Set([
172
+ "version",
173
+ "name",
174
+ "description",
175
+ "when_to_use",
176
+ "tags",
177
+ "schedule",
178
+ "enabled",
179
+ // `redact:` is target-agnostic: every kind funnels through the same log sink.
180
+ "redact",
148
181
  ]);
149
- const SHARED_KEYS = new Set(["version", "name", "description", "when_to_use", "tags", "schedule", "enabled"]);
150
182
  function requireVersion(data, id, filePath) {
151
183
  if (data.version === TASK_SCHEMA_VERSION)
152
184
  return;
@@ -258,9 +290,54 @@ function readTimeout(value, filePath) {
258
290
  return undefined;
259
291
  if (value === null)
260
292
  return null;
261
- if (typeof value === "number" && Number.isInteger(value) && value > 0)
293
+ // The ceiling is `setTimeout`'s, not a policy: a larger delay overflows and
294
+ // fires immediately, turning a generous timeout into an instant abort.
295
+ if (typeof value === "number" && Number.isInteger(value) && value > 0 && value <= TASK_MAX_TIMEOUT_MS)
262
296
  return value;
263
- throw new UsageError(`Key "timeoutMs" must be a positive integer or null. File: ${filePath}`, "INVALID_FLAG_VALUE");
297
+ throw new UsageError(`Key "timeoutMs" must be an integer from 1 through ${TASK_MAX_TIMEOUT_MS}, or null. File: ${filePath}`, "INVALID_FLAG_VALUE");
298
+ }
299
+ /**
300
+ * Read the `redact:` opt-in list — environment variable NAMES whose values are
301
+ * scrubbed from this task's persisted log (#755).
302
+ *
303
+ * A value that looks like a secret rather than a name is rejected outright, not
304
+ * silently accepted: a literal in a task file would be indexed, searchable and
305
+ * printed verbatim by `akm show`, so accepting one would leak the secret
306
+ * through a wider channel than the redaction closes.
307
+ */
308
+ function readRedactNames(value, filePath) {
309
+ if (value === undefined || value === null)
310
+ return undefined;
311
+ const invalid = (detail) => {
312
+ throw new UsageError(`Key "redact" ${detail}. File: ${filePath}`, "INVALID_FLAG_VALUE");
313
+ };
314
+ if (!Array.isArray(value))
315
+ return invalid("must be a list of environment variable names");
316
+ if (value.length > TASK_MAX_REDACT_NAMES) {
317
+ return invalid(`accepts at most ${TASK_MAX_REDACT_NAMES} names (got ${value.length})`);
318
+ }
319
+ const names = [];
320
+ for (const entry of value) {
321
+ if (typeof entry !== "string" || !WORKFLOW_ENV_VAR_NAME_PATTERN.test(entry)) {
322
+ return invalid(`takes environment variable NAMES only (matching ${WORKFLOW_ENV_VAR_NAME_PATTERN.source}), not values — ` +
323
+ `got ${JSON.stringify(entry)}. A secret written here would be indexed and printed by \`akm show\``);
324
+ }
325
+ if (!names.includes(entry))
326
+ names.push(entry);
327
+ }
328
+ return names.length > 0 ? names : undefined;
329
+ }
330
+ function readBoundedInteger(value, key, minimum, maximum, filePath) {
331
+ if (value === undefined || value === null)
332
+ return undefined;
333
+ if (typeof value === "number" &&
334
+ Number.isSafeInteger(value) &&
335
+ value >= minimum &&
336
+ (maximum === undefined || value <= maximum)) {
337
+ return value;
338
+ }
339
+ const range = maximum === undefined ? `at least ${minimum}` : `from ${minimum} through ${maximum}`;
340
+ throw new UsageError(`Key "${key}" must be an integer ${range}. File: ${filePath}`, "INVALID_FLAG_VALUE");
264
341
  }
265
342
  function readLlmOverrides(value, filePath) {
266
343
  if (value === undefined)
@@ -15,7 +15,10 @@
15
15
  * 3. Skip disabled tasks only when the invocation is scheduler-generated;
16
16
  * explicit manual runs are allowed for catch-up and testing.
17
17
  * 4. Dispatch by target kind:
18
- * • workflow → `runWorkflowSteps({ target: ref, params })`
18
+ * • workflow → `runWorkflowSteps({ target: ref, params, signal, … })`
19
+ * under a whole-run timeout (issue 11): an unattended run
20
+ * gets the same abort path `akm workflow run --timeout`
21
+ * gives an interactive one.
19
22
  * • prompt → `executeRunner(engine, prompt, { stdio: "captured" })`
20
23
  * 5. Capture stdout / stderr as structured rows in logs.db (task_logs) and,
21
24
  * transitionally, as a flat text tail at `<cacheDir>/tasks/logs/<id>/<ts>.log`
@@ -28,6 +31,7 @@
28
31
  import fs from "node:fs";
29
32
  import os from "node:os";
30
33
  import path from "node:path";
34
+ import { armAbortDeadline } from "../core/abort-deadline.js";
31
35
  import { shouldSkipUnactivatedTask } from "../core/activation-policy.js";
32
36
  import { assertNever } from "../core/assert.js";
33
37
  import { placementSpecFor } from "../core/asset/asset-placement.js";
@@ -36,7 +40,7 @@ import { loadConfig } from "../core/config/config.js";
36
40
  import { AkmError, NotFoundError, rethrowIfTestIsolationError } from "../core/errors.js";
37
41
  import { buildTaskRunId, insertTaskLogLines, openLogsDatabase, } from "../core/logs-db.js";
38
42
  import { getTaskLogDir } from "../core/paths.js";
39
- import { redactCredentialPatterns } from "../core/redaction.js";
43
+ import { redactCredentialPatterns, redactSensitiveText } from "../core/redaction.js";
40
44
  import { withStateDb } from "../core/state-db.js";
41
45
  import { runManagedSubprocess } from "../core/subprocess.js";
42
46
  import { fallbackAnnouncement, NO_ENGINE_MESSAGE_SUFFIX, NO_ENGINE_REMEDY, withEngineFallback, } from "../integrations/agent/engine-fallback.js";
@@ -48,6 +52,7 @@ import { resolveAssetPath } from "../sources/resolve.js";
48
52
  import { decodeTaskHistoryMetadata, finalizeTaskHistoryAttempt, getTaskHistory, queryTaskHistory, reserveTaskHistoryAttempt, upsertTaskHistory, } from "../storage/repositories/task-history-repository.js";
49
53
  import { runWorkflowSteps } from "../workflows/exec/run-workflow.js";
50
54
  import { findBareAkmExecutableIndex } from "./command-executable.js";
55
+ import { collectTaskLogSensitiveValues } from "./log-redaction.js";
51
56
  import { parseTaskDocument } from "./parser.js";
52
57
  import { resolveAkmInvocation } from "./resolve-akm-bin.js";
53
58
  import { validateTaskId } from "./task-id.js";
@@ -109,6 +114,7 @@ export async function runTask(id, options) {
109
114
  logPath,
110
115
  fileText: `${disabledLine}\n`,
111
116
  dbLines: [{ line: disabledLine }],
117
+ redactNames: task.redact,
112
118
  });
113
119
  appendHistory(result, attempt.historyReserved);
114
120
  return result;
@@ -121,6 +127,8 @@ export async function runTask(id, options) {
121
127
  now,
122
128
  runWorkflowStepsImpl,
123
129
  historyReserved: attempt.historyReserved,
130
+ ...(options.setTimeoutFn ? { setTimeoutFn: options.setTimeoutFn } : {}),
131
+ ...(options.clearTimeoutFn ? { clearTimeoutFn: options.clearTimeoutFn } : {}),
124
132
  });
125
133
  }
126
134
  if (task.target.kind === "command") {
@@ -225,6 +233,7 @@ async function runCommandTask(input) {
225
233
  logPath,
226
234
  fileText: `${logLines.join("\n")}\n`,
227
235
  dbLines,
236
+ redactNames: task.redact,
228
237
  });
229
238
  const status = exitCode === 0 ? "completed" : "failed";
230
239
  const result = {
@@ -248,14 +257,53 @@ function resolveNestedAkmCommand(cmd) {
248
257
  return [...cmd.slice(0, akmIndex), ...resolveAkmInvocation().argv, ...cmd.slice(akmIndex + 1)];
249
258
  }
250
259
  // ── workflow target ─────────────────────────────────────────────────────────
260
+ /**
261
+ * Whole-run timeout applied to a workflow-bound task that does not declare its
262
+ * own `timeoutMs` — six hours.
263
+ *
264
+ * `akm workflow run` deliberately has NO default `--timeout`: a human is
265
+ * watching, and Ctrl-C aborts the very same signal the flag's timer would.
266
+ * A scheduled task has nobody watching. Without a default, its only bound is
267
+ * the per-unit timeout — and a frozen plan may set `timeout: null` (unbounded),
268
+ * so one wedged agent unit hangs the run until the machine reboots, holding the
269
+ * run lease and silently skipping every later firing (issue 11).
270
+ *
271
+ * Six hours is deliberately generous rather than tight: the abort is graceful
272
+ * (the engine breaks at the next step boundary and the run stays resumable), so
273
+ * the cost of over-waiting is bounded while the cost of cutting a legitimate
274
+ * long run short is a lost step. It matches the 6h idle window `akm health`
275
+ * already uses to call a run stale (`commands/health/report-view-model.ts`),
276
+ * and it lands well inside a `@daily` cadence, so a wedged run can never still
277
+ * be holding the lease when the next day's firing arrives.
278
+ *
279
+ * An explicit `timeoutMs:` in the task file always wins; `timeoutMs: null` is
280
+ * the explicit opt-out back to unbounded.
281
+ */
282
+ export const DEFAULT_WORKFLOW_TASK_TIMEOUT_MS = 6 * 60 * 60 * 1000;
251
283
  async function runWorkflowTask(input) {
252
284
  const { task, logPath, startedAt, now, runWorkflowStepsImpl, historyReserved } = input;
253
285
  if (task.target.kind !== "workflow")
254
286
  throw new Error("invariant: workflow target");
255
- const ref = parseRefInput(task.target.ref);
287
+ const workflowTarget = task.target;
288
+ const ref = parseRefInput(workflowTarget.ref);
256
289
  if (ref.type !== "workflow") {
257
- throw new NotFoundError(`Task "${task.id}" workflow target must be a workflow ref (got "${task.target.ref}").`, "WORKFLOW_NOT_FOUND");
258
- }
290
+ throw new NotFoundError(`Task "${task.id}" workflow target must be a workflow ref (got "${workflowTarget.ref}").`, "WORKFLOW_NOT_FOUND");
291
+ }
292
+ // Unset → the unattended default; `null` → the explicit no-timeout opt-out.
293
+ const timeoutMs = workflowTarget.timeoutMs === undefined ? DEFAULT_WORKFLOW_TASK_TIMEOUT_MS : workflowTarget.timeoutMs;
294
+ // The shared deadline `akm workflow run --timeout` also arms
295
+ // ({@link armAbortDeadline}): one AbortController for the run's lifetime,
296
+ // aborted by a timer. The engine reads `options.signal` at every step
297
+ // boundary and breaks GRACEFULLY — in-flight units are cancelled, the journal
298
+ // and the run lease are retained, and the run is left `active`, i.e.
299
+ // resumable with `akm workflow resume`.
300
+ const controller = new AbortController();
301
+ const deadline = armAbortDeadline(controller, {
302
+ timeoutMs,
303
+ reason: `Workflow task "${task.id}" timed out after ${timeoutMs}ms.`,
304
+ ...(input.setTimeoutFn ? { setTimeoutFn: input.setTimeoutFn } : {}),
305
+ ...(input.clearTimeoutFn ? { clearTimeoutFn: input.clearTimeoutFn } : {}),
306
+ });
259
307
  let detail;
260
308
  let gateError;
261
309
  let error;
@@ -263,7 +311,13 @@ async function runWorkflowTask(input) {
263
311
  // task must leave the same trace rather than silently using a chosen engine.
264
312
  let runWarnings = [];
265
313
  try {
266
- const execution = await runWorkflowStepsImpl({ target: task.target.ref, params: task.target.params });
314
+ const execution = await runWorkflowStepsImpl({
315
+ target: workflowTarget.ref,
316
+ params: workflowTarget.params,
317
+ signal: controller.signal,
318
+ ...(workflowTarget.maxSteps !== undefined ? { maxSteps: workflowTarget.maxSteps } : {}),
319
+ ...(workflowTarget.maxRetries !== undefined ? { maxRetries: workflowTarget.maxRetries } : {}),
320
+ });
267
321
  detail = execution.run;
268
322
  runWarnings = execution.warnings ?? [];
269
323
  if (execution.gateRejection) {
@@ -275,13 +329,37 @@ async function runWorkflowTask(input) {
275
329
  throw e;
276
330
  error = e instanceof Error ? e : new Error(String(e));
277
331
  }
332
+ finally {
333
+ deadline.disarm();
334
+ }
335
+ // A timeout is a failed ATTEMPT even though the engine stopped cleanly: the
336
+ // aborted run comes back `active` (resumable), which on its own would map to
337
+ // task status "active" and a 0 exit code, telling the OS scheduler nothing
338
+ // went wrong. Surface it like the command target's `timed_out=true` instead.
339
+ //
340
+ // Unless the run COMPLETED anyway. The abort is observed between steps, so a
341
+ // deadline landing in the run's final bookkeeping can set the flag on a run
342
+ // that then finishes — and reporting that as a failure would tell an operator
343
+ // to resume a run with nothing left to resume.
344
+ const ranToCompletion = detail?.status === "completed";
345
+ const timedOutAfterMs = deadline.timedOut() && timeoutMs !== null && !ranToCompletion ? timeoutMs : undefined;
346
+ const timeoutError = timedOutAfterMs === undefined
347
+ ? undefined
348
+ : new Error(`Workflow run timed out after ${timedOutAfterMs}ms and was aborted at a step boundary` +
349
+ (detail?.id ? ` — resume it with \`akm workflow resume ${detail.id}\`.` : "."));
350
+ // One failure value for the three sinks below (status, log line, history
351
+ // detail): a thrown error outranks a gate rejection, which outranks the
352
+ // deadline. Re-laddering per sink is how a log line ends up naming a
353
+ // different cause than the history row it was written beside.
354
+ const failure = error ?? (gateError ? new Error(gateError) : timeoutError);
278
355
  const finishedAt = finishAttempt(startedAt, now());
279
- const status = error || gateError ? "failed" : mapWorkflowStatus(detail?.status);
356
+ const status = failure ? "failed" : mapWorkflowStatus(detail?.status);
280
357
  const log = renderWorkflowLog({
281
358
  task,
282
359
  detail,
283
- error: error ?? (gateError ? new Error(gateError) : undefined),
360
+ error: failure,
284
361
  warnings: runWarnings,
362
+ ...(timedOutAfterMs !== undefined ? { timedOutAfterMs } : {}),
285
363
  });
286
364
  persistRunLog({
287
365
  taskId: task.id,
@@ -290,6 +368,7 @@ async function runWorkflowTask(input) {
290
368
  logPath,
291
369
  fileText: log.fileText,
292
370
  dbLines: log.dbLines,
371
+ redactNames: task.redact,
293
372
  });
294
373
  const result = {
295
374
  id: task.id,
@@ -301,7 +380,7 @@ async function runWorkflowTask(input) {
301
380
  target: { kind: "workflow", ref: task.target.ref },
302
381
  detail: {
303
382
  runId: detail?.id,
304
- ...(error ? { error: error.message } : gateError ? { error: gateError } : {}),
383
+ ...(failure ? { error: failure.message } : {}),
305
384
  },
306
385
  };
307
386
  appendHistory(result, historyReserved);
@@ -345,6 +424,9 @@ function renderWorkflowLog(input) {
345
424
  ];
346
425
  for (const warning of input.warnings ?? [])
347
426
  dbLines.push({ level: "warn", line: warning });
427
+ if (input.timedOutAfterMs !== undefined) {
428
+ dbLines.push({ level: "error", line: `timed_out=true timeout_ms=${input.timedOutAfterMs}` });
429
+ }
348
430
  if (input.detail) {
349
431
  dbLines.push({ line: `run_id=${input.detail.id} status=${input.detail.status}` });
350
432
  dbLines.push({ line: `workflow_title=${input.detail.workflowTitle}` });
@@ -432,6 +514,7 @@ async function runPromptTask(input) {
432
514
  logPath,
433
515
  fileText: log.fileText,
434
516
  dbLines: log.dbLines,
517
+ redactNames: task.redact,
435
518
  });
436
519
  const status = result.ok ? "completed" : "failed";
437
520
  const out = {
@@ -535,11 +618,56 @@ function streamLines(text, stream, level) {
535
618
  * The DB write is best-effort, mirroring {@link appendHistory}: an unwritable
536
619
  * logs.db must never fail a task run.
537
620
  */
621
+ /**
622
+ * Exact secret values to scrub from this run's persisted output (#755).
623
+ *
624
+ * Best-effort by construction: this runs on the persistence path of a run that
625
+ * has already finished, so a config that will not load must degrade to
626
+ * "pattern-based redaction only" rather than fail the run. It does NOT degrade
627
+ * to "log it anyway with no redaction at all" — `redactCredentialPatterns`
628
+ * still runs unconditionally in the caller.
629
+ */
630
+ function taskLogSensitiveValues(redactNames) {
631
+ try {
632
+ return collectTaskLogSensitiveValues({
633
+ env: process.env,
634
+ config: loadConfig(),
635
+ declaredNames: redactNames,
636
+ });
637
+ }
638
+ catch (error) {
639
+ rethrowIfTestIsolationError(error);
640
+ // No config — the name heuristic and the task's own `redact:` list still apply.
641
+ try {
642
+ return collectTaskLogSensitiveValues({ env: process.env, declaredNames: redactNames });
643
+ }
644
+ catch (fallbackError) {
645
+ rethrowIfTestIsolationError(fallbackError);
646
+ return [];
647
+ }
648
+ }
649
+ }
538
650
  function persistRunLog(input) {
539
- const fileText = redactCredentialPatterns(input.fileText);
540
- const dbLines = input.dbLines.map((entry) => ({ ...entry, line: redactCredentialPatterns(entry.line) }));
651
+ // Two arms, and both are needed. `redactCredentialPatterns` catches
652
+ // credential SHAPES nobody listed; the exact-value pass catches configured
653
+ // secrets whose value is shaped like nothing in particular (#755). The
654
+ // command target had only the first, so a scheduled command that echoed an
655
+ // ordinary-looking secret persisted it verbatim to both sinks. Applying the
656
+ // exact pass here — the one sink all three target kinds funnel through —
657
+ // covers every arm once rather than per-arm; prompt/workflow runs already
658
+ // scrub upstream, and redaction is idempotent, so the overlap is free.
659
+ const sensitive = taskLogSensitiveValues(input.redactNames);
660
+ const scrub = (text) => sensitive.length > 0
661
+ ? redactSensitiveText(redactCredentialPatterns(text), sensitive)
662
+ : redactCredentialPatterns(text);
663
+ const fileText = scrub(input.fileText);
664
+ const dbLines = input.dbLines.map((entry) => ({ ...entry, line: scrub(entry.line) }));
541
665
  if (input.logPath) {
542
666
  try {
667
+ // Written at the process umask. #756 pinned 0600/0700 here; that went out
668
+ // with the rest of akm's permission enforcement (#791) — the operator owns
669
+ // the mode of their own data directory, and akm neither sets nor reports
670
+ // on it.
543
671
  fs.mkdirSync(path.dirname(input.logPath), { recursive: true });
544
672
  fs.writeFileSync(input.logPath, fileText);
545
673
  }
@@ -12,7 +12,35 @@
12
12
  * Tasks are stored as pure YAML files at `<stash>/tasks/<id>.yml`. Multi-line
13
13
  * inline prompts use a YAML block scalar (`prompt: |`).
14
14
  */
15
+ import { parse as parseYaml } from "yaml";
16
+ import { WORKFLOW_MAX_EXEC_PASS_ENV, WORKFLOW_MAX_TIMEOUT_MS } from "../workflows/resource-limits.js";
15
17
  export const TASK_SCHEMA_VERSION = 2;
18
+ /** The ONE recognized on-disk task extension (spec §6 task row). */
19
+ export const TASK_EXTENSION = ".yml";
20
+ /**
21
+ * The near-miss spelling. `.yaml` is NOT a task extension: the indexer's
22
+ * `tasks` matcher (`indexer/walk/matchers.ts`) gates on `.yml`, so a
23
+ * `tasks/<id>.yaml` file is never indexed, never scheduled, and never runs.
24
+ * It is recognized HERE only so lint can say so out loud instead of walking
25
+ * past it (issue #760).
26
+ */
27
+ export const TASK_NEAR_MISS_EXTENSION = ".yaml";
28
+ /**
29
+ * Largest expressible `timeoutMs` — `setTimeout`'s 32-bit signed ceiling
30
+ * (2^31-1, ~24.8 days). A larger delay overflows and fires almost immediately,
31
+ * which would silently abort a run seconds after it started instead of hours
32
+ * later. One definition with the workflow bound (`WORKFLOW_MAX_TIMEOUT_MS`) —
33
+ * it is a platform fact, not a per-surface policy. Mirrored as `maximum` on
34
+ * `timeoutMs` in `schemas/akm-task.json`.
35
+ */
36
+ export const TASK_MAX_TIMEOUT_MS = WORKFLOW_MAX_TIMEOUT_MS;
37
+ /**
38
+ * Most names a task's `redact:` list may carry. Shares its bound with exec
39
+ * units' `pass_env:` — both are "name the one or two the defaults miss", not a
40
+ * way to declare the whole environment secret. Mirrored as `maxItems` on
41
+ * `redact` in `schemas/akm-task.json`.
42
+ */
43
+ export const TASK_MAX_REDACT_NAMES = WORKFLOW_MAX_EXEC_PASS_ENV;
16
44
  /**
17
45
  * Lint-level shape problems for a parsed task YAML mapping: the field rules
18
46
  * `src/tasks/parser.ts` enforces at load time, phrased as diagnostics. The ONE
@@ -36,3 +64,40 @@ export function taskFieldProblems(data) {
36
64
  problems.push("enabled (must be a boolean when present)");
37
65
  return problems;
38
66
  }
67
+ /**
68
+ * Parse a task YAML document into a plain mapping. Non-mapping documents (a
69
+ * scalar, a sequence, an empty file) are NOT a parse failure — they parse fine
70
+ * and simply carry no fields, which the field rules above already describe.
71
+ *
72
+ * The ONE parse used by all three task-lint surfaces (`commands/lint/index.ts`'s
73
+ * akm sweep, the `akm` adapter's `validate`, and the `akm-task` adapter) so a
74
+ * malformed file cannot be a finding on one surface and silence on another.
75
+ */
76
+ export function parseTaskYaml(raw) {
77
+ try {
78
+ const doc = parseYaml(raw);
79
+ if (doc && typeof doc === "object" && !Array.isArray(doc))
80
+ return { ok: true, data: doc };
81
+ return { ok: true, data: {} };
82
+ }
83
+ catch (e) {
84
+ // yaml's errors carry a multi-line source excerpt; keep the first line so
85
+ // the diagnostic stays one readable finding.
86
+ const message = (e instanceof Error ? e.message : String(e)).split("\n")[0]?.trim() ?? "unknown parse error";
87
+ return { ok: false, data: {}, error: message };
88
+ }
89
+ }
90
+ /** The `invalid-task-yaml` detail for a file whose YAML could not be parsed. */
91
+ export function taskYamlParseDetail(error) {
92
+ return `task YAML does not parse: ${error}`;
93
+ }
94
+ /**
95
+ * The `invalid-task-yaml` detail for a task file using the `.yaml` near-miss
96
+ * spelling. See {@link TASK_NEAR_MISS_EXTENSION} for why this is an error and
97
+ * not a style nit.
98
+ */
99
+ export function taskExtensionDetail(relPath) {
100
+ const base = relPath.replace(/\.yaml$/i, "");
101
+ return (`task file uses the ${TASK_NEAR_MISS_EXTENSION} extension; akm recognizes tasks only as ` +
102
+ `${TASK_EXTENSION}, so this file is never indexed or scheduled — rename it to ${base}${TASK_EXTENSION}.`);
103
+ }
@@ -2,7 +2,16 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import os from "node:os";
5
- export const WORKFLOW_MAX_CONCURRENCY_CEILING = 64;
5
+ import { isLoopbackEndpoint } from "../core/loopback.js";
6
+ import { WORKFLOW_MAX_CONCURRENCY } from "./resource-limits.js";
7
+ /**
8
+ * Run-level ceiling on `workflow.maxConcurrency`. It is deliberately the SAME
9
+ * value the frozen-plan decoder enforces on `execution.maxConcurrency` and on
10
+ * per-step `map.concurrency` — a clamp above the decoder's bound would freeze
11
+ * plans the decoder then rejects — so it reads the single shared constant
12
+ * (`./resource-limits`) rather than repeating the literal.
13
+ */
14
+ export const WORKFLOW_MAX_CONCURRENCY_CEILING = WORKFLOW_MAX_CONCURRENCY;
6
15
  export function cpuDerivedUnitConcurrency(cpuCount = os.cpus()?.length ?? 4) {
7
16
  return Math.min(16, Math.max(1, cpuCount - 2));
8
17
  }
@@ -13,3 +22,88 @@ export function clampMaxConcurrency(value) {
13
22
  export function workflowMaxConcurrency(configured, cpuCount = os.cpus()?.length ?? 4) {
14
23
  return configured === undefined ? cpuDerivedUnitConcurrency(cpuCount) : clampMaxConcurrency(configured);
15
24
  }
25
+ // ── Fan-out defaults ─────────────────────────────────────────────────────────
26
+ //
27
+ // Four independent limits clamp a `map` step's real width, and the effective
28
+ // value is their minimum:
29
+ //
30
+ // 1. the step's own `map.concurrency` (this file's default below)
31
+ // 2. the run's frozen `execution.maxConcurrency` ({@link workflowMaxConcurrency})
32
+ // 3. the selected LLM engine's frozen concurrency ({@link defaultLlmEngineConcurrency})
33
+ // 4. the CURRENT host's CPU safety cap ({@link cpuDerivedUnitConcurrency})
34
+ //
35
+ // (1) and (3) both defaulted to 1 before 0.9.1, which made every fan-out serial
36
+ // unless the author opted in at BOTH layers — so (2) and (4), the limits that
37
+ // actually encode machine capacity, never bound anything. The defaults below
38
+ // replace those two 1s. They are deliberately modest rather than "as wide as
39
+ // the host allows": a `map` is independent by construction, but its units call
40
+ // out to rate-limited providers and RAM-hungry agent processes, so the value
41
+ // that a plan freezes should be one a laptop and a CI box can both survive.
42
+ /**
43
+ * Default width of a `map` step that declares no `concurrency:` (0.9.1+).
44
+ *
45
+ * 4 is chosen over the host cap on purpose. It is a real, predictable speedup
46
+ * (4× on any fan-out longer than four items) while staying below
47
+ * {@link cpuDerivedUnitConcurrency} on every machine with ≥6 cores, so the
48
+ * frozen number — not the host — is what an author reasons about, and a plan
49
+ * frozen on a 32-core CI box behaves the same when it resumes on a laptop.
50
+ *
51
+ * Overridable in both directions:
52
+ * - per step: `map.concurrency: <n>` (an explicit `1` still means serial),
53
+ * - per install: `workflow.defaultMapConcurrency` — set it to `1` to restore
54
+ * the pre-0.9.1 serial default for every workflow at once.
55
+ */
56
+ export const DEFAULT_MAP_CONCURRENCY = 4;
57
+ /**
58
+ * Default `engines.<name>.concurrency` for an LLM engine on a LOOPBACK
59
+ * endpoint. Stays at 1, matching `getDefaultLlmConcurrency`
60
+ * (`src/indexer/indexer.ts`) and AGENTS.md's "lowest common denominator — a
61
+ * slow local model on a single-threaded server" rule. A local model server
62
+ * (LM Studio, Ollama) holds ONE loaded model; parallel inference triggers
63
+ * reload thrash and HTTP 500s, which is a hard failure, not a slow one.
64
+ */
65
+ export const DEFAULT_LOCAL_LLM_ENGINE_CONCURRENCY = 1;
66
+ /**
67
+ * Default `engines.<name>.concurrency` for an LLM engine on a REMOTE endpoint.
68
+ *
69
+ * Deliberately equal to {@link DEFAULT_MAP_CONCURRENCY} so this limit does not
70
+ * silently re-serialize a fan-out the author already asked for: the step's own
71
+ * `concurrency:` stays the number that decides. Indexing's remote default is a
72
+ * lower 2 because indexing fans out implicitly over the whole stash; a
73
+ * workflow `map` is an explicit, bounded, author-declared fan-out, and four
74
+ * concurrent completions sit far inside any hosted provider's entry tier.
75
+ * Rate-limited installs set `engines.<name>.concurrency` to pin their own.
76
+ */
77
+ export const DEFAULT_REMOTE_LLM_ENGINE_CONCURRENCY = 4;
78
+ // ── Loopback classification ──────────────────────────────────────────────────
79
+ //
80
+ // Everything above turns on ONE question: does this endpoint name a model
81
+ // server running on THIS machine? The classifier lives in `core/loopback.ts`
82
+ // (shared with the indexer's LLM pool default); the re-export keeps this
83
+ // module the policy surface workflow callers and the boundary-case table in
84
+ // `tests/workflows/concurrency-defaults.test.ts` import from. The check is
85
+ // purely syntactic — no DNS, no interface list — so freeze produces the same
86
+ // plan on a laptop, on CI, and on a machine with no network.
87
+ export { isLoopbackEndpoint, isLoopbackHost } from "../core/loopback.js";
88
+ /**
89
+ * Concurrency to freeze for an LLM engine. An explicit
90
+ * `engines.<name>.concurrency` always wins (clamped into the decoder's
91
+ * `[1, 64]` range so a fat-fingered config cannot freeze an unloadable plan);
92
+ * otherwise the endpoint decides.
93
+ */
94
+ export function defaultLlmEngineConcurrency(endpoint, configured) {
95
+ if (typeof configured === "number" && Number.isFinite(configured))
96
+ return clampMaxConcurrency(configured);
97
+ return isLoopbackEndpoint(endpoint) ? DEFAULT_LOCAL_LLM_ENGINE_CONCURRENCY : DEFAULT_REMOTE_LLM_ENGINE_CONCURRENCY;
98
+ }
99
+ /**
100
+ * Width to freeze for a `map` step that declared no `concurrency:`. `configured`
101
+ * is `workflow.defaultMapConcurrency`; unset means {@link DEFAULT_MAP_CONCURRENCY}.
102
+ * An explicit `map.concurrency` never reaches this function — the caller keeps
103
+ * "author wrote 1" distinguishable from "author wrote nothing".
104
+ */
105
+ export function defaultMapConcurrency(configured) {
106
+ return configured === undefined || !Number.isFinite(configured)
107
+ ? DEFAULT_MAP_CONCURRENCY
108
+ : clampMaxConcurrency(configured);
109
+ }