akm-cli 0.9.2-alpha.4 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +493 -0
  2. package/STABILITY.md +23 -5
  3. package/dist/assets/hints/cli-hints-full.md +12 -7
  4. package/dist/assets/tasks/core/extract.yml +3 -5
  5. package/dist/assets/tasks/core/improve.yml +3 -5
  6. package/dist/assets/tasks/core/index-refresh.yml +3 -5
  7. package/dist/assets/tasks/core/sync.yml +3 -5
  8. package/dist/assets/tasks/core/version-check.yml +3 -5
  9. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +3 -5
  10. package/dist/assets/tasks/improve/akm-improve-catchup.yml +6 -6
  11. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +3 -5
  12. package/dist/assets/tasks/improve/akm-improve-frequent.yml +3 -5
  13. package/dist/assets/tasks/improve/akm-improve-nightly.yml +3 -5
  14. package/dist/cli/unknown-flags.js +12 -1
  15. package/dist/cli.js +8 -1
  16. package/dist/commands/command/command-execution.js +23 -2
  17. package/dist/commands/health/improve-metrics.js +38 -0
  18. package/dist/commands/health/windows.js +8 -4
  19. package/dist/commands/health.js +8 -4
  20. package/dist/commands/lint/index.js +1 -1
  21. package/dist/commands/migrate-cli.js +130 -24
  22. package/dist/commands/proposal/validators/proposal-validators.js +7 -2
  23. package/dist/commands/tasks/explain.js +304 -0
  24. package/dist/commands/tasks/tasks-cli.js +185 -3
  25. package/dist/commands/tasks/tasks.js +265 -52
  26. package/dist/commands/workflow/plan.js +159 -0
  27. package/dist/commands/workflow-cli.js +94 -2
  28. package/dist/core/activation-policy.js +2 -12
  29. package/dist/core/adapter/adapters/akm-lint.js +7 -4
  30. package/dist/core/adapter/adapters/akm-metadata.js +26 -14
  31. package/dist/core/adapter/adapters/akm-task-adapter.js +13 -10
  32. package/dist/core/errors.js +45 -0
  33. package/dist/core/json-schema.js +15 -5
  34. package/dist/core/state/migrations.js +57 -0
  35. package/dist/core/state-db.js +16 -14
  36. package/dist/core/subprocess.js +47 -13
  37. package/dist/execution/guarded-source.js +44 -0
  38. package/dist/execution/input-contract.js +250 -0
  39. package/dist/execution/target-ref.js +63 -0
  40. package/dist/indexer/usage/usage-events.js +14 -3
  41. package/dist/integrations/agent/execution-lowering.js +12 -1
  42. package/dist/output/shapes/passthrough.js +2 -0
  43. package/dist/output/text/helpers.js +1 -1
  44. package/dist/output/text/migrate.js +12 -3
  45. package/dist/output/text/workflow-format.js +192 -10
  46. package/dist/output/text/workflow.js +2 -1
  47. package/dist/runtime.js +1 -0
  48. package/dist/scripts/akm-migrate-node.js +11838 -10118
  49. package/dist/scripts/akm-migrate.js +11828 -10117
  50. package/dist/setup/steps/tasks.js +34 -17
  51. package/dist/storage/repositories/task-history-repository.js +5 -1
  52. package/dist/storage/repositories/workflow-runs-repository.js +144 -6
  53. package/dist/tasks/backends/launchd.js +31 -84
  54. package/dist/tasks/embedded.js +13 -7
  55. package/dist/tasks/model/invocation.js +4 -0
  56. package/dist/tasks/prepare/prepare-script-target.js +9 -0
  57. package/dist/tasks/prepare/prepare-support.js +154 -0
  58. package/dist/tasks/prepare/prepare.js +117 -0
  59. package/dist/tasks/prepare/prepared-execution.js +4 -0
  60. package/dist/tasks/prepare/script-capture.js +80 -0
  61. package/dist/tasks/run/attempt-lifecycle.js +165 -0
  62. package/dist/tasks/run/load-task.js +117 -0
  63. package/dist/tasks/run/provenance.js +20 -0
  64. package/dist/tasks/run/run-command-task.js +92 -0
  65. package/dist/tasks/run/run-native-task.js +222 -0
  66. package/dist/tasks/run/run-task.js +99 -0
  67. package/dist/tasks/run/run-workflow-task.js +222 -0
  68. package/dist/tasks/run/task-history.js +134 -0
  69. package/dist/tasks/run/task-log.js +179 -0
  70. package/dist/tasks/run/task-result.js +19 -0
  71. package/dist/tasks/scheduler-binding.js +66 -2
  72. package/dist/tasks/scheduler-invocation.js +63 -3
  73. package/dist/tasks/scheduler-sync.js +77 -14
  74. package/dist/tasks/source/bounded-document.js +455 -0
  75. package/dist/tasks/source/parse-task-source.js +59 -0
  76. package/dist/tasks/source/project-v4.js +62 -0
  77. package/dist/tasks/source/task-input-diagnostics.js +36 -0
  78. package/dist/tasks/source/task-source-v4.js +626 -0
  79. package/dist/tasks/source-v3.js +10 -733
  80. package/dist/tasks/task-run-reserved-flags.js +79 -0
  81. package/dist/workflows/authoring/authoring.js +17 -8
  82. package/dist/workflows/exec/child-invocation.js +34 -0
  83. package/dist/workflows/exec/child-workflow.js +370 -0
  84. package/dist/workflows/exec/exec-unit.js +50 -170
  85. package/dist/workflows/exec/frozen-judge.js +19 -2
  86. package/dist/workflows/exec/native-executor.js +49 -27
  87. package/dist/workflows/exec/param-secrets.js +12 -0
  88. package/dist/workflows/exec/run-workflow.js +48 -59
  89. package/dist/workflows/exec/step-work.js +222 -80
  90. package/dist/workflows/exec/unit-dispatch.js +72 -0
  91. package/dist/workflows/freeze/child-output-references.js +94 -0
  92. package/dist/workflows/freeze/environment.js +174 -0
  93. package/dist/workflows/freeze/identity.js +22 -0
  94. package/dist/workflows/freeze/resolve-steps.js +78 -0
  95. package/dist/workflows/freeze/source-freeze.js +57 -0
  96. package/dist/workflows/freeze/step-values.js +68 -0
  97. package/dist/workflows/freeze/targets/child-workflow.js +206 -0
  98. package/dist/workflows/freeze/targets/command.js +81 -0
  99. package/dist/workflows/freeze/targets/script.js +57 -0
  100. package/dist/workflows/freeze/targets/shell.js +31 -0
  101. package/dist/workflows/freeze/targets/task.js +179 -0
  102. package/dist/workflows/freeze/task-bindings.js +180 -0
  103. package/dist/workflows/ir/compile.js +59 -11
  104. package/dist/workflows/ir/environment-v4.js +3 -3
  105. package/dist/workflows/ir/freeze-v4.js +41 -7
  106. package/dist/workflows/ir/params.js +58 -131
  107. package/dist/workflows/ir/plan-hash.js +3 -3
  108. package/dist/workflows/ir/schema-v4.js +246 -17
  109. package/dist/workflows/parser.js +74 -2
  110. package/dist/workflows/program/schema.js +5 -2
  111. package/dist/workflows/resource-limits.js +20 -0
  112. package/dist/workflows/runtime/plan-classifier.js +24 -7
  113. package/dist/workflows/runtime/run-outputs.js +103 -0
  114. package/dist/workflows/runtime/runs.js +114 -9
  115. package/dist/workflows/runtime/workflow-asset-loader.js +14 -6
  116. package/dist/workflows/source-files.js +5 -5
  117. package/dist/workflows/source-ir/compare.js +17 -0
  118. package/dist/workflows/source-ir/compile.js +7 -3
  119. package/dist/workflows/source-ir/github-yaml.js +64 -17
  120. package/dist/workflows/source-ir/schema.js +69 -21
  121. package/dist/workflows/source-ir/semantics.js +7 -25
  122. package/dist/workflows/source-ir/triggers.js +79 -0
  123. package/dist/workflows/source-ir/uses.js +33 -7
  124. package/docs/migration/README.md +1 -1
  125. package/docs/migration/release-notes/0.9.2.md +87 -11
  126. package/docs/migration/release-notes/README.md +3 -2
  127. package/docs/migration/v0.8-to-v0.9.md +13 -11
  128. package/docs/migration/v0.9.0-troubleshooting.md +20 -13
  129. package/docs/migration/v0.9.1-to-v0.9.2.md +598 -49
  130. package/docs/reference/README.md +1 -1
  131. package/docs/reference/cli.md +140 -46
  132. package/docs/reference/configuration.md +6 -5
  133. package/docs/reference/supported-formats.md +9 -5
  134. package/docs/reference/tasks.md +338 -75
  135. package/docs/reference/workflow-schema.md +290 -16
  136. package/docs/reference/workflows.md +57 -7
  137. package/package.json +1 -1
  138. package/schemas/akm-task.json +173 -118
  139. package/schemas/akm-workflow.json +28 -0
  140. package/dist/tasks/runner.js +0 -941
  141. package/dist/tasks/runtime-v3.js +0 -281
  142. package/dist/workflows/ir/source-freeze-v4.js +0 -506
  143. package/dist/workflows/source-ir/ordering.js +0 -38
@@ -9,15 +9,18 @@ import { makeBundleRef } from "../core/asset/asset-ref.js";
9
9
  import { UsageError } from "../core/errors.js";
10
10
  import { canonicalizeWorkflowName, WORKFLOW_EXTENSIONS } from "../core/recognition-util.js";
11
11
  import { captureGuardedDirectoryManifest, captureGuardedExecutionSource, GuardedExecutionSourceCollector, } from "../execution/guarded-source.js";
12
+ import { applyInputDefaults, validateInputs } from "../execution/input-contract.js";
12
13
  import { compileWorkflowPlan } from "../workflows/ir/compile.js";
13
14
  import { compileResolveFreezeWorkflowV4 } from "../workflows/ir/freeze-v4.js";
14
15
  import { canonicalJson, computePlanHash } from "../workflows/ir/plan-hash.js";
15
16
  import { WorkflowSourceCollisionError, WorkflowSourceNameError, WorkflowSourceRejectionError, workflowNameForSourcePath, } from "../workflows/source-files.js";
16
17
  import { compileWorkflowSource } from "../workflows/source-ir/compile.js";
17
- import { prepareTaskV3Execution } from "./runtime-v3.js";
18
+ import { prepareTaskV3Execution } from "./prepare/prepare.js";
18
19
  import { parseSchedule } from "./schedule.js";
19
20
  import { assertSchedulerNativeArtifactCardinality, compileTaskSchedulerBindings, compileWorkflowSchedulerBindings, schedulerBindingNativeId, schedulerBindingOrdinal, schedulerNativeArtifactKey, schedulerNativeBindingId, } from "./scheduler-binding.js";
20
- import { parseTaskV3Yaml, taskV3SourceErrorDetail } from "./source-v3.js";
21
+ import { parseTaskSource } from "./source/parse-task-source.js";
22
+ import { projectTaskSourceV4 } from "./source/project-v4.js";
23
+ import { taskSourceErrorDetail } from "./source-v3.js";
21
24
  export function computeSchedulerExecutionEvidenceDigest(planHash, sourceReadSet) {
22
25
  const envelope = canonicalJson({
23
26
  version: 1,
@@ -249,7 +252,7 @@ async function compileDesiredSourceSet(input, collector) {
249
252
  await compileTaskSources(input, collector, bindings, failures);
250
253
  await compileWorkflowSources(input, collector, bindings, executableWorkflows, failures);
251
254
  if (failures.length > 0) {
252
- throw new UsageError(`Scheduler sync rejected the desired source set before mutation:\n${failures.map((failure) => `- ${failure}`).join("\n")}`, "INVALID_FLAG_VALUE");
255
+ throw new UsageError(`Scheduler sync rejected the desired source set before mutation:\n${failures.map((failure) => `- ${failure}`).join("\n")}`, "TASK_SOURCE_INVALID");
253
256
  }
254
257
  return Object.freeze({
255
258
  desired: Object.freeze(bindings),
@@ -272,12 +275,44 @@ async function compileTaskSources(input, collector, out, failures) {
272
275
  throw new UsageError(`Task sources ${JSON.stringify(priorOwner)} and ${JSON.stringify(sourcePath)} resolve to the same physical source identity; refusing canonical task identity collision.`, "RESOURCE_ALREADY_EXISTS");
273
276
  }
274
277
  physicalOwners.set(physicalIdentity, sourcePath);
275
- const document = parseTaskV3Yaml({
278
+ // Project BEFORE prepareTaskV3Execution so projectability is checked —
279
+ // but build the scheduler bindings from the ORIGINAL task source v4
280
+ // document, not the projection, which deliberately drops per-entry
281
+ // `enabled` and `schedule[i].inputs` (D2-N5, project-v4.ts) —
282
+ // schedule-supplied inputs are delivered through the scheduler
283
+ // binding's own compiled invocation tail (P2b Lane B, spec §4.4,
284
+ // B-N3), not through the prepare-seam projection. A task source v4
285
+ // document has no document-level `akm.enabled`, so `enabled: true` is
286
+ // passed at the document level and every entry's own `enabled`
287
+ // (always present, defaulted at parse time) decides.
288
+ const parsed = parseTaskSource({
276
289
  yaml: guarded.content,
277
290
  filePath: sourcePath,
278
291
  workspaceRoot: input.sourceRoot,
279
292
  });
293
+ const document = projectTaskSourceV4(parsed.v4);
280
294
  const qualifiedRef = makeBundleRef(input.bundleName, conceptId);
295
+ // P2b Lane B (spec docs/plans/specs/p2b-input-bindings.md §4.4, rows
296
+ // B-50/F-B2): validate each v4 schedule entry's inputs against the
297
+ // task's OWN declared contract WITH DEFAULTS APPLIED — the same
298
+ // applyInputDefaults + validateInputs pair akm task run uses
299
+ // (src/tasks/run/load-task.ts). parseTaskSource's own parse-time check
300
+ // (task-source-v4.ts's parseScheduleEntry) already rejects an
301
+ // unknown/malformed entry against the RAW supplied values; this is a
302
+ // deliberate second, independent gate over the DEFAULTED view — the
303
+ // exact set of values the compiled invocation below actually delivers
304
+ // — so a violation fails HERE, recorded as a task failure at sync,
305
+ // rather than surfacing for the first time when the scheduler fires
306
+ // the compiled invocation.
307
+ const contract = parsed.v4.inputs ?? {};
308
+ for (const scheduleEntry of parsed.v4.schedule) {
309
+ const defaultedInputs = applyInputDefaults(contract, { ...scheduleEntry.inputs });
310
+ const errors = validateInputs(contract, defaultedInputs);
311
+ if (errors.length > 0) {
312
+ throw new UsageError(`Task ${JSON.stringify(qualifiedRef)} schedule[${scheduleEntry.ordinal}].inputs does not satisfy ` +
313
+ `its declared inputs once defaults are applied: ${errors.join("; ")}`, "TASK_SOURCE_INVALID");
314
+ }
315
+ }
281
316
  await prepareTaskV3Execution(document, {
282
317
  taskId: id,
283
318
  taskRef: qualifiedRef,
@@ -296,14 +331,22 @@ async function compileTaskSources(input, collector, out, failures) {
296
331
  : loadAdapterExecutionSource(ref, "persona", guardedOptions);
297
332
  },
298
333
  });
334
+ const relSource = toPosix(path.relative(input.sourceRoot, sourcePath));
299
335
  const sourceBindings = compileTaskSchedulerBindings({
300
336
  id,
301
337
  qualifiedRef,
302
338
  ...(input.bundleTarget ? { bundleTarget: input.bundleTarget } : {}),
303
- enabled: document.akm?.enabled !== false,
304
- schedules: document.triggers.schedules.map((schedule) => ({
305
- ...schedule,
306
- source: `${toPosix(path.relative(input.sourceRoot, sourcePath))}:${schedule.source}`,
339
+ enabled: true,
340
+ schedules: parsed.v4.schedule.map((schedule) => ({
341
+ cron: schedule.cron,
342
+ ordinal: schedule.ordinal,
343
+ enabled: schedule.enabled,
344
+ source: `${relSource}:${schedule.source}`,
345
+ // P2b Lane B (spec §4.4, B-N3): delivered through the compiled
346
+ // binding's own invocation tail below — the F-B2 flip that closes
347
+ // the P2a B-38 "validated but not yet delivered" gap this comment
348
+ // used to describe.
349
+ inputs: schedule.inputs,
307
350
  })),
308
351
  });
309
352
  for (const binding of sourceBindings) {
@@ -335,11 +378,11 @@ async function compileWorkflowSources(input, collector, out, evidence, failures)
335
378
  if (!compiled.ok) {
336
379
  throw new UsageError(compiled.errors
337
380
  .map((error) => `${error.path}:${error.line ?? 1} [${error.code}] ${error.message}`)
338
- .join("; "), "INVALID_FLAG_VALUE");
381
+ .join("; "), "WORKFLOW_SOURCE_INVALID");
339
382
  }
340
383
  const planDraft = compileWorkflowPlan(compiled.ir, canonicalName);
341
384
  if (!planDraft.ok) {
342
- throw new UsageError(planDraft.errors.map((error) => `${guarded.relativePath}:${error.line} ${error.message}`).join("; "), "INVALID_FLAG_VALUE");
385
+ throw new UsageError(planDraft.errors.map((error) => `${guarded.relativePath}:${error.line} ${error.message}`).join("; "), "WORKFLOW_SOURCE_INVALID");
343
386
  }
344
387
  const conceptId = input.adapterId === "akm" ? `workflows/${canonicalName}` : canonicalName;
345
388
  const qualifiedRef = makeBundleRef(input.bundleName, conceptId);
@@ -359,7 +402,7 @@ async function compileWorkflowSources(input, collector, out, evidence, failures)
359
402
  const executionEvidenceDigest = computeSchedulerExecutionEvidenceDigest(planHash, frozen.plan.sourceReadSet);
360
403
  evidence.push(Object.freeze({
361
404
  ref: qualifiedRef,
362
- irVersion: 4,
405
+ irVersion: 5,
363
406
  planHash,
364
407
  sourceReadSet: frozen.plan.sourceReadSet,
365
408
  executionEvidenceDigest,
@@ -435,6 +478,21 @@ function installOptionsFor(input, current) {
435
478
  return input.installOptions ? Object.freeze({ ...input.installOptions }) : undefined;
436
479
  }
437
480
  function belongsToBundle(entry, input) {
481
+ if (input.bundlePath !== undefined && entry.target === input.bundleName) {
482
+ // Path-scoped (#846), primary/unconfigured-bundle sync only: the name
483
+ // already matches, but a display name derived from a directory
484
+ // basename is not an identity — two unrelated bundles can legitimately
485
+ // share one. Require the entry's own scheduler-context descriptor to
486
+ // additionally confirm the resolved path. An entry whose owning path
487
+ // cannot be established is never assumed to be ours — that silent
488
+ // assumption is exactly what let an isolated/foreign bundle's sync
489
+ // reach for another bundle's real scheduler entries. (`bundlePath` is
490
+ // only set for a primary sync — a `--bundle <target>` entry's
491
+ // descriptor reflects the invoking process's OWN primary directory,
492
+ // not the targeted bundle's, so it is not a meaningful signal there;
493
+ // that case keeps relying on config-name uniqueness below.)
494
+ return entry.ownerBundlePath !== undefined && entry.ownerBundlePath === input.bundlePath;
495
+ }
438
496
  if (entry.target === input.bundleName || entry.target === input.bundleTarget)
439
497
  return true;
440
498
  return false;
@@ -444,8 +502,13 @@ function assertNoForeignIds(desired, input) {
444
502
  const foreign = input.installed.find((entry) => wanted.has(entry.id) && !belongsToBundle(entry, input));
445
503
  if (!foreign)
446
504
  return;
447
- const where = foreign.target ? `bundle ${JSON.stringify(foreign.target)}` : "the default bundle";
448
- throw new UsageError(`Scheduler id ${JSON.stringify(foreign.id)} is already scheduled from ${where}; desired source ids must not collide across bundles.`, "RESOURCE_ALREADY_EXISTS");
505
+ const where = foreign.ownerBundlePath
506
+ ? `the bundle at ${JSON.stringify(foreign.ownerBundlePath)}`
507
+ : foreign.target
508
+ ? `bundle ${JSON.stringify(foreign.target)}`
509
+ : "the default bundle";
510
+ const mine = input.bundlePath ? ` (this sync is scoped to ${JSON.stringify(input.bundlePath)})` : "";
511
+ throw new UsageError(`Scheduler id ${JSON.stringify(foreign.id)} is already scheduled from ${where}${mine}; desired source ids must not collide across bundles.`, "RESOURCE_ALREADY_EXISTS");
449
512
  }
450
513
  function assertUniqueDesiredIds(desired) {
451
514
  const seen = new Set();
@@ -466,7 +529,7 @@ function assertUniqueInstalledIds(installed) {
466
529
  }
467
530
  }
468
531
  function taskFailure(file, cause) {
469
- const detail = taskV3SourceErrorDetail(cause);
532
+ const detail = taskSourceErrorDetail(cause);
470
533
  return detail === errorMessage(cause) ? `${file}: ${detail}` : detail;
471
534
  }
472
535
  function errorMessage(cause) {
@@ -0,0 +1,455 @@
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
+ * The bounded-document front end and field helpers both task source grammars
6
+ * share (spec docs/plans/specs/p2a-task-source-v4.md §1.5 D2-N4, §3.1).
7
+ *
8
+ * This is the actual D2-N4 move, not a parallel reimplementation: every
9
+ * helper below — including the `TASK_V3_MAX_*` resource bounds and
10
+ * `assertBoundedTaskYamlDocument` — is now OWNED here, body-intact from
11
+ * `src/tasks/source-v3.ts`. `source-v3.ts` imports every one of them (and
12
+ * re-exports `assertBoundedTaskYamlDocument` / the `TASK_V3_MAX_*` constants
13
+ * at their existing names, since those were already part of its public
14
+ * surface) instead of declaring its own copies. §9's acceptance criterion is
15
+ * structural: "src/tasks/source/bounded-document.ts exists and owns the
16
+ * D2-N4 helpers; src/tasks/source-v3.ts imports them and contains no copy of
17
+ * any of them" — `tests/tasks/bounded-document.test.ts`'s last describe
18
+ * block pins this with an AST scan of `source-v3.ts`, not just a runtime
19
+ * behavior check (a copy-instead-of-move implementation would pass every
20
+ * OTHER test in that file and in `tests/tasks/source-v3.test.ts`, because
21
+ * both files would still call functions they have in scope either way).
22
+ *
23
+ * This module deliberately imports NOTHING from `../source-v3` (not even a
24
+ * type) — `tests/architecture/import-cycle-ratchet.test.ts` is an ABSOLUTE
25
+ * no-cycle gate over all of `src/**` (its baseline emptied at chunk-8 and
26
+ * counts type-only imports as real graph edges), and `source-v3.ts` now
27
+ * imports from this file, so a reverse edge here would close a cycle. Where
28
+ * a moved helper's signature referenced a v3-only type
29
+ * (`TaskV3AkmOptions["tools"]`, `TaskV3Environment`) the return type is
30
+ * inlined structurally instead — invisible at runtime, and TypeScript's
31
+ * structural typing makes the inlined shape and the named alias
32
+ * interchangeable at every call site.
33
+ *
34
+ * `parseTimeout` and `parseTools` stay v3-only in the sense that they
35
+ * hardcode the `["akm", …]` field path (byte-identical to v3's existing
36
+ * behavior — that hardcoding is exactly what D2-N4's binding resolution asks
37
+ * to preserve). Task source v4's top-level `timeout:`/`tools:` fields have
38
+ * their own siblings in `src/tasks/source/task-source-v4.ts`
39
+ * (`parseTimeoutTopLevel`/`parseToolsTopLevel`) with the same accept/reject
40
+ * semantics at a different, un-prefixed field path — v3 imports the two
41
+ * below rather than declaring them itself, purely because D2-N4 homes every
42
+ * one of these named helpers here. `nullableSelector` is the same kind of
43
+ * `["akm", …]`-hardcoding helper but is NOT in D2-N4's named list and stays
44
+ * declared directly in `source-v3.ts`.
45
+ */
46
+ import fs from "node:fs";
47
+ import path from "node:path";
48
+ import { types as utilTypes } from "node:util";
49
+ import { isAlias, isMap, isScalar, isSeq, LineCounter, parseDocument } from "yaml";
50
+ import { UsageError } from "../../core/errors.js";
51
+ import { DURATION_UNITS, parseDuration } from "../../core/time.js";
52
+ import { EXECUTION_MAX_TIMEOUT_MS } from "../../execution/limits.js";
53
+ import { snapshotStrictRecord } from "../../execution/record.js";
54
+ import { WORKFLOW_ENV_VAR_NAME_PATTERN } from "../../workflows/resource-limits.js";
55
+ // ── Resource bounds (D2-N4: owned here, re-exported by source-v3.ts) ───────
56
+ export const TASK_V3_MAX_SOURCE_BYTES = 1024 * 1024;
57
+ export const TASK_V3_MAX_JSON_DEPTH = 64;
58
+ export const TASK_V3_MAX_JSON_NODES = 10_000;
59
+ export const TASK_V3_MAX_COLLECTION_ITEMS = 1024;
60
+ export const TASK_V3_MAX_OBJECT_KEYS = 256;
61
+ export const TASK_V3_MAX_STRING_BYTES = 256 * 1024;
62
+ export const TASK_V3_MAX_SCHEDULES = 64;
63
+ export function own(value, key) {
64
+ return Object.hasOwn(value, key);
65
+ }
66
+ export function utf8Bytes(value) {
67
+ return new TextEncoder().encode(value).byteLength;
68
+ }
69
+ export function wellFormedUnicode(value) {
70
+ for (let index = 0; index < value.length; index += 1) {
71
+ const code = value.charCodeAt(index);
72
+ if (code >= 0xd800 && code <= 0xdbff) {
73
+ const next = value.charCodeAt(index + 1);
74
+ if (!(next >= 0xdc00 && next <= 0xdfff))
75
+ return false;
76
+ index += 1;
77
+ }
78
+ else if (code >= 0xdc00 && code <= 0xdfff)
79
+ return false;
80
+ }
81
+ return true;
82
+ }
83
+ /** The one per-field error funnel both grammars render through, distinguished only by `ctx.sourceLabel` (D2-N4). */
84
+ export function sourceError(ctx, fieldPath, detail) {
85
+ const dotted = fieldPath.length === 0
86
+ ? "$"
87
+ : fieldPath.reduce((display, segment) => typeof segment === "number"
88
+ ? `${display}[${segment}]`
89
+ : display.length > 0
90
+ ? `${display}.${segment}`
91
+ : segment, "");
92
+ const line = ctx.lineAt?.(fieldPath);
93
+ const location = `${ctx.filePath}${line === undefined ? "" : `:${line}`}`;
94
+ throw new UsageError(`Invalid ${ctx.sourceLabel} at ${location}: ${dotted} ${detail}`, "TASK_SOURCE_INVALID");
95
+ }
96
+ export function cloneBoundedJson(value, ctx, fieldPath, state, depth = 0, ancestors = new Set()) {
97
+ state.nodes += 1;
98
+ if (state.nodes > TASK_V3_MAX_JSON_NODES)
99
+ sourceError(ctx, fieldPath, `exceeds the ${TASK_V3_MAX_JSON_NODES}-node limit.`);
100
+ if (depth > TASK_V3_MAX_JSON_DEPTH)
101
+ sourceError(ctx, fieldPath, `exceeds the nesting depth of ${TASK_V3_MAX_JSON_DEPTH}.`);
102
+ if (value === null || typeof value === "boolean")
103
+ return value;
104
+ if (typeof value === "number") {
105
+ if (!Number.isFinite(value))
106
+ sourceError(ctx, fieldPath, "must be a finite JSON number.");
107
+ return value;
108
+ }
109
+ if (typeof value === "string") {
110
+ if (!wellFormedUnicode(value))
111
+ sourceError(ctx, fieldPath, "must contain well-formed Unicode.");
112
+ if (utf8Bytes(value) > TASK_V3_MAX_STRING_BYTES) {
113
+ sourceError(ctx, fieldPath, `exceeds the ${TASK_V3_MAX_STRING_BYTES}-byte string limit.`);
114
+ }
115
+ return value;
116
+ }
117
+ if (value === undefined)
118
+ sourceError(ctx, fieldPath, "must be omitted instead of set to undefined.");
119
+ if (typeof value !== "object")
120
+ sourceError(ctx, fieldPath, "must be JSON-safe.");
121
+ if (utilTypes.isProxy(value))
122
+ sourceError(ctx, fieldPath, "must not be a Proxy object.");
123
+ if (ancestors.has(value))
124
+ sourceError(ctx, fieldPath, "must not contain a cycle.");
125
+ const nextAncestors = new Set(ancestors).add(value);
126
+ if (Array.isArray(value)) {
127
+ if (Object.getPrototypeOf(value) !== Array.prototype)
128
+ sourceError(ctx, fieldPath, "array must use the standard prototype.");
129
+ const rawLength = Reflect.getOwnPropertyDescriptor(value, "length")?.value;
130
+ if (typeof rawLength !== "number" ||
131
+ !Number.isInteger(rawLength) ||
132
+ rawLength < 0 ||
133
+ rawLength > TASK_V3_MAX_COLLECTION_ITEMS) {
134
+ sourceError(ctx, fieldPath, `array exceeds the ${TASK_V3_MAX_COLLECTION_ITEMS}-item limit.`);
135
+ }
136
+ const length = rawLength;
137
+ const keys = Reflect.ownKeys(value);
138
+ if (keys.length !== length + 1)
139
+ sourceError(ctx, fieldPath, "array must be dense and contain no extra fields.");
140
+ const result = [];
141
+ for (let index = 0; index < length; index += 1) {
142
+ const descriptor = Reflect.getOwnPropertyDescriptor(value, String(index));
143
+ if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
144
+ sourceError(ctx, [...fieldPath, index], "array item must be an enumerable data property in a dense array.");
145
+ }
146
+ result.push(cloneBoundedJson(descriptor.value, ctx, [...fieldPath, index], state, depth + 1, nextAncestors));
147
+ }
148
+ return Object.freeze(result);
149
+ }
150
+ let snapshot;
151
+ try {
152
+ snapshot = snapshotStrictRecord(value, fieldPath.map(String).join(".") || "task source");
153
+ }
154
+ catch (cause) {
155
+ sourceError(ctx, fieldPath, cause instanceof Error ? cause.message : String(cause));
156
+ }
157
+ const entries = Object.entries(snapshot);
158
+ if (entries.length > TASK_V3_MAX_OBJECT_KEYS) {
159
+ sourceError(ctx, fieldPath, `mapping exceeds the ${TASK_V3_MAX_OBJECT_KEYS}-key limit.`);
160
+ }
161
+ const result = Object.create(null);
162
+ for (const [key, child] of entries) {
163
+ if (!wellFormedUnicode(key))
164
+ sourceError(ctx, fieldPath, "contains a mapping key with malformed Unicode.");
165
+ if (utf8Bytes(key) > TASK_V3_MAX_STRING_BYTES) {
166
+ sourceError(ctx, fieldPath, `contains a mapping key exceeding the ${TASK_V3_MAX_STRING_BYTES}-byte string limit.`);
167
+ }
168
+ Object.defineProperty(result, key, {
169
+ value: cloneBoundedJson(child, ctx, [...fieldPath, key], state, depth + 1, nextAncestors),
170
+ enumerable: true,
171
+ configurable: false,
172
+ writable: false,
173
+ });
174
+ }
175
+ return Object.freeze(result);
176
+ }
177
+ export function asRecord(value, ctx, fieldPath) {
178
+ if (value === null || Array.isArray(value) || typeof value !== "object")
179
+ sourceError(ctx, fieldPath, "must be a mapping.");
180
+ return value;
181
+ }
182
+ export function checkKeys(value, allowed, ctx, fieldPath) {
183
+ const allow = new Set(allowed);
184
+ const firstUnknown = Object.keys(value).find((key) => !allow.has(key));
185
+ if (firstUnknown !== undefined)
186
+ sourceError(ctx, [...fieldPath, firstUnknown], "is an unsupported field.");
187
+ }
188
+ export function presentJsonValue(value, ctx, fieldPath) {
189
+ if (value === undefined)
190
+ sourceError(ctx, fieldPath, "must be omitted instead of set to undefined.");
191
+ return value;
192
+ }
193
+ export function stringField(value, ctx, fieldPath, options = {}) {
194
+ if (value === null && options.nullable)
195
+ return null;
196
+ if (typeof value !== "string")
197
+ sourceError(ctx, fieldPath, options.nullable ? "must be a string or null." : "must be a string.");
198
+ if (options.nonempty && value.trim().length === 0)
199
+ sourceError(ctx, fieldPath, "must be a non-empty string.");
200
+ return value;
201
+ }
202
+ export function noGithubExpression(value, ctx, fieldPath) {
203
+ if (value.includes("${{"))
204
+ sourceError(ctx, fieldPath, "contains an unsupported GitHub expression.");
205
+ }
206
+ /** `env:` is already a top-level field in both grammars — reused body-intact, field path unchanged. */
207
+ export function parseEnvironment(value, ctx) {
208
+ const environment = asRecord(value, ctx, ["env"]);
209
+ for (const [key, child] of Object.entries(environment)) {
210
+ if (!WORKFLOW_ENV_VAR_NAME_PATTERN.test(key))
211
+ sourceError(ctx, ["env", key], "has an invalid environment variable name.");
212
+ if (typeof child !== "string" && typeof child !== "number" && typeof child !== "boolean") {
213
+ sourceError(ctx, ["env", key], "must be a string, finite number, or boolean.");
214
+ }
215
+ }
216
+ return environment;
217
+ }
218
+ export function parseStringArray(value, ctx, fieldPath, options = {}) {
219
+ if (!Array.isArray(value))
220
+ sourceError(ctx, fieldPath, "must be an array of strings.");
221
+ if (options.max !== undefined && value.length > options.max)
222
+ sourceError(ctx, fieldPath, `accepts at most ${options.max} items.`);
223
+ const strings = [];
224
+ for (const [index, entry] of value.entries()) {
225
+ if (typeof entry !== "string" || entry.length === 0)
226
+ sourceError(ctx, [...fieldPath, index], "must be a non-empty string.");
227
+ if (options.pattern && !options.pattern.test(entry))
228
+ sourceError(ctx, [...fieldPath, index], "has an invalid value.");
229
+ strings.push(entry);
230
+ }
231
+ return Object.freeze(strings);
232
+ }
233
+ /**
234
+ * `akm.timeout` — v3-only (hardcodes the `["akm", "timeout"]` field path, see
235
+ * this module's header). Body-intact from `source-v3.ts`.
236
+ */
237
+ export function parseTimeout(value, ctx) {
238
+ if (value === null)
239
+ return null;
240
+ if (typeof value === "string" && value.trim() !== value) {
241
+ sourceError(ctx, ["akm", "timeout"], "must not contain surrounding whitespace.");
242
+ }
243
+ const milliseconds = typeof value === "string" ? parseDuration(value, DURATION_UNITS) : value;
244
+ if (milliseconds === null ||
245
+ typeof milliseconds !== "number" ||
246
+ !Number.isSafeInteger(milliseconds) ||
247
+ milliseconds < 0 ||
248
+ milliseconds > EXECUTION_MAX_TIMEOUT_MS) {
249
+ sourceError(ctx, ["akm", "timeout"], `must be null, 0 through ${EXECUTION_MAX_TIMEOUT_MS} milliseconds, or a common duration such as 20m.`);
250
+ }
251
+ return value;
252
+ }
253
+ /**
254
+ * `akm.tools` — v3-only (hardcodes the `["akm", "tools"]` field path, see
255
+ * this module's header). Body-intact from `source-v3.ts`; the return type is
256
+ * inlined (was `TaskV3AkmOptions["tools"]`) so this module imports nothing
257
+ * from `../source-v3` (see header — the import-cycle ratchet is absolute).
258
+ */
259
+ export function parseTools(value, ctx) {
260
+ if (value === null || typeof value === "string")
261
+ return value;
262
+ if (Array.isArray(value)) {
263
+ if (value.some((entry) => typeof entry !== "string"))
264
+ sourceError(ctx, ["akm", "tools"], "array values must be strings.");
265
+ return value;
266
+ }
267
+ if (typeof value === "object")
268
+ return value;
269
+ sourceError(ctx, ["akm", "tools"], "must be a string, string array, mapping, or null.");
270
+ }
271
+ /**
272
+ * `working-directory:` is already a top-level field in both grammars —
273
+ * reused body-intact, field path unchanged (`source-v3.ts:677-714`).
274
+ */
275
+ export function validateWorkingDirectory(value, ctx) {
276
+ if (value.trim().length === 0 ||
277
+ value.includes("\0") ||
278
+ path.posix.isAbsolute(value.replaceAll("\\", "/")) ||
279
+ /^[A-Za-z]:[\\/]/.test(value) ||
280
+ value.startsWith("\\\\")) {
281
+ sourceError(ctx, ["working-directory"], "must be a non-empty relative path contained by the workspace root.");
282
+ }
283
+ const segments = value.replaceAll("\\", "/").split("/");
284
+ if (segments.some((segment) => segment === ".." || segment.length === 0)) {
285
+ sourceError(ctx, ["working-directory"], "must not contain empty or escaping path segments.");
286
+ }
287
+ if (!ctx.workspaceRoot) {
288
+ sourceError(ctx, ["working-directory"], "requires a workspace root so physical containment can be verified.");
289
+ }
290
+ let realRoot;
291
+ let realCandidate;
292
+ try {
293
+ realRoot = fs.realpathSync(ctx.workspaceRoot);
294
+ const candidate = path.resolve(realRoot, value);
295
+ const stat = fs.statSync(candidate);
296
+ if (!stat.isDirectory())
297
+ sourceError(ctx, ["working-directory"], "must resolve to a directory.");
298
+ realCandidate = fs.realpathSync(candidate);
299
+ }
300
+ catch (cause) {
301
+ if (cause instanceof UsageError)
302
+ throw cause;
303
+ sourceError(ctx, ["working-directory"], `cannot be physically verified: ${cause instanceof Error ? cause.message : String(cause)}.`);
304
+ }
305
+ const relative = path.relative(realRoot, realCandidate);
306
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
307
+ sourceError(ctx, ["working-directory"], "resolves outside the workspace root and is not physically contained.");
308
+ }
309
+ }
310
+ export function yamlProblem(message) {
311
+ return message.split("\n")[0]?.trim() || "invalid YAML";
312
+ }
313
+ export function yamlAstError(options, node, detail) {
314
+ const range = node?.range;
315
+ const line = range && options.lineCounter ? options.lineCounter.linePos(range[0] ?? 0).line : undefined;
316
+ throw new UsageError(`Invalid ${options.sourceLabel} at ${options.filePath}${line === undefined ? "" : `:${line}`}: ${detail}`, "TASK_SOURCE_INVALID");
317
+ }
318
+ /**
319
+ * Bound and close the YAML AST before `toJS` can allocate or recurse through
320
+ * it. This is shared by the v3 parser, task source v4, and the explicit v2
321
+ * migration reader.
322
+ */
323
+ export function assertBoundedTaskYamlDocument(document, options) {
324
+ const stack = [{ node: document.contents, depth: 0 }];
325
+ let nodes = 0;
326
+ while (stack.length > 0) {
327
+ const current = stack.pop();
328
+ if (!current)
329
+ break;
330
+ const node = current.node;
331
+ if (node === null || node === undefined)
332
+ continue;
333
+ nodes += 1;
334
+ if (nodes > TASK_V3_MAX_JSON_NODES) {
335
+ yamlAstError(options, node, `YAML exceeds the ${TASK_V3_MAX_JSON_NODES}-node limit.`);
336
+ }
337
+ if (current.depth > TASK_V3_MAX_JSON_DEPTH) {
338
+ yamlAstError(options, node, `YAML exceeds the nesting depth of ${TASK_V3_MAX_JSON_DEPTH}.`);
339
+ }
340
+ if (isAlias(node))
341
+ yamlAstError(options, node, "YAML aliases are unsupported.");
342
+ if (node.anchor !== undefined) {
343
+ yamlAstError(options, node, "YAML anchors are unsupported.");
344
+ }
345
+ if (node.tag) {
346
+ yamlAstError(options, node, "custom or explicit YAML tags are unsupported.");
347
+ }
348
+ if (isScalar(node))
349
+ continue;
350
+ if (isSeq(node)) {
351
+ if (node.items.length > TASK_V3_MAX_COLLECTION_ITEMS) {
352
+ yamlAstError(options, node, `YAML sequence exceeds the ${TASK_V3_MAX_COLLECTION_ITEMS}-item limit.`);
353
+ }
354
+ for (let index = node.items.length - 1; index >= 0; index -= 1) {
355
+ stack.push({ node: node.items[index], depth: current.depth + 1 });
356
+ }
357
+ continue;
358
+ }
359
+ if (isMap(node)) {
360
+ if (node.items.length > TASK_V3_MAX_OBJECT_KEYS) {
361
+ yamlAstError(options, node, `YAML mapping exceeds the ${TASK_V3_MAX_OBJECT_KEYS}-key limit.`);
362
+ }
363
+ for (let index = node.items.length - 1; index >= 0; index -= 1) {
364
+ const pair = node.items[index];
365
+ if (!pair)
366
+ yamlAstError(options, node, "sparse YAML mappings are unsupported.");
367
+ if (!isScalar(pair.key) || typeof pair.key.value !== "string") {
368
+ yamlAstError(options, pair.key, "non-string YAML mapping keys are unsupported.");
369
+ }
370
+ if (!wellFormedUnicode(pair.key.value)) {
371
+ yamlAstError(options, pair.key, "YAML mapping key must contain well-formed Unicode.");
372
+ }
373
+ if (utf8Bytes(pair.key.value) > TASK_V3_MAX_STRING_BYTES) {
374
+ yamlAstError(options, pair.key, `YAML mapping key exceeds the ${TASK_V3_MAX_STRING_BYTES}-byte string limit.`);
375
+ }
376
+ if (pair.key.value === "<<")
377
+ yamlAstError(options, pair.key, "YAML merge keys are unsupported.");
378
+ stack.push({ node: pair.value, depth: current.depth + 1 });
379
+ stack.push({ node: pair.key, depth: current.depth + 1 });
380
+ }
381
+ continue;
382
+ }
383
+ yamlAstError(options, node, "unsupported YAML node kind.");
384
+ }
385
+ }
386
+ /**
387
+ * Parse hostile YAML without aliases/tags/merges, then hand back the bounded
388
+ * `{root, lineAt}` pair both grammars that read a task document consume —
389
+ * originally lifted from the now-deleted `parseTaskV3Yaml`
390
+ * (`source-v3.ts:894-952`), generalized only by `sourceLabel`.
391
+ *
392
+ * P4 (docs/plans/specs/p4-deletions-closeout.md §3.2) deleted task v3
393
+ * acceptance from `src` entirely — `parseTaskV3Yaml` and its `"task v3
394
+ * source"` label no longer exist here (the grammar survives only in the
395
+ * vendored, frozen `scripts/akm-migrate/migrate/task-source-v3-frozen.ts`
396
+ * copy, which does not call this function). The two live `src` callers
397
+ * today: `parseTaskSourceV4`'s standalone YAML-string entry
398
+ * (`task-source-v4.ts:790`) passes `sourceLabel: "task source v4"`; the
399
+ * version router (`parse-task-source.ts:61`) calls this directly with
400
+ * `sourceLabel: "task source"` — not "task v3 source" — because its front
401
+ * end runs before `root.version` is even read, so it cannot yet know which
402
+ * schema version the document will turn out to be.
403
+ */
404
+ export function readBoundedTaskSourceYaml(input, options) {
405
+ const sourceLabel = options.sourceLabel;
406
+ // P4 (docs/plans/specs/p4-deletions-closeout.md §5.2, row R-R8): these six
407
+ // throws used to omit their `code` argument and rely on the constructor's
408
+ // `INVALID_FLAG_VALUE` default (a pre-P4 ratchet-gaming trick that kept the
409
+ // literal string out of the grep-style count while the effective code
410
+ // stayed generic). §5.2's target table closes that gap: every pre-version
411
+ // failure in this bounded YAML front end (not a string, too large, YAML
412
+ // parse/expansion) is a task-source defect, not a flag-parsing one, so each
413
+ // now carries `TASK_SOURCE_INVALID` explicitly.
414
+ if (typeof input.yaml !== "string") {
415
+ throw new UsageError(`Invalid ${sourceLabel} at ${input.filePath}: source must be a string.`, "TASK_SOURCE_INVALID");
416
+ }
417
+ if (utf8Bytes(input.yaml) > TASK_V3_MAX_SOURCE_BYTES) {
418
+ throw new UsageError(`Invalid ${sourceLabel} at ${input.filePath}: source exceeds the 1 MiB (${TASK_V3_MAX_SOURCE_BYTES}-byte) resource limit.`, "TASK_SOURCE_INVALID");
419
+ }
420
+ const lineCounter = new LineCounter();
421
+ let document;
422
+ try {
423
+ document = parseDocument(input.yaml, { lineCounter, uniqueKeys: true });
424
+ }
425
+ catch (cause) {
426
+ throw new UsageError(`Invalid ${sourceLabel} at ${input.filePath}: YAML parsing failed: ${cause instanceof Error ? cause.message : String(cause)}`, "TASK_SOURCE_INVALID");
427
+ }
428
+ const [problem] = document.errors;
429
+ if (problem) {
430
+ const offset = Array.isArray(problem.pos) ? problem.pos[0] : 0;
431
+ throw new UsageError(`Invalid ${sourceLabel} at ${input.filePath}:${lineCounter.linePos(offset).line}: ${yamlProblem(problem.message)}`, "TASK_SOURCE_INVALID");
432
+ }
433
+ const [warning] = document.warnings;
434
+ if (warning) {
435
+ throw new UsageError(`Invalid ${sourceLabel} at ${input.filePath}: unsupported YAML construct: ${yamlProblem(warning.message)}`, "TASK_SOURCE_INVALID");
436
+ }
437
+ assertBoundedTaskYamlDocument(document, { filePath: input.filePath, sourceLabel, lineCounter });
438
+ let root;
439
+ try {
440
+ root = document.toJS({ maxAliasCount: 0 });
441
+ }
442
+ catch (cause) {
443
+ throw new UsageError(`Invalid ${sourceLabel} at ${input.filePath}: YAML expansion failed: ${cause instanceof Error ? cause.message : String(cause)}`, "TASK_SOURCE_INVALID");
444
+ }
445
+ const lineAt = (fieldPath) => {
446
+ for (let depth = fieldPath.length; depth >= 0; depth -= 1) {
447
+ const node = depth === 0 ? document.contents : document.getIn(fieldPath.slice(0, depth), true);
448
+ const range = node?.range;
449
+ if (range)
450
+ return lineCounter.linePos(range[0]).line;
451
+ }
452
+ return undefined;
453
+ };
454
+ return { root, lineAt };
455
+ }