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
@@ -14,7 +14,7 @@ import { commitWriteTargetBoundary, deleteAssetFromSource, prepareWriteTargetFor
14
14
  import { backendNameForPlatform } from "../../tasks/backends/index.js";
15
15
  import { listEmbeddedTasks } from "../../tasks/embedded.js";
16
16
  import { parseSchedule } from "../../tasks/schedule.js";
17
- import { parseTaskV3Yaml } from "../../tasks/source-v3.js";
17
+ import { parseTaskSource } from "../../tasks/source/parse-task-source.js";
18
18
  import { prompt } from "../prompt.js";
19
19
  /**
20
20
  * A scheduled server-only nightly full sweep exists among the embedded
@@ -71,13 +71,28 @@ export function detectServerDefault() {
71
71
  function normaliseTaskIdForMatch(raw) {
72
72
  return raw.trim().replace(/\.(yml|md)$/, "");
73
73
  }
74
- function setTaskV3EnabledInYaml(yaml, enabled) {
74
+ /**
75
+ * Toggle a task source v4 file's enabled state via a full parse/render
76
+ * round-trip (setup's own edits are infrequent and not comment-preservation-
77
+ * sensitive, unlike `commands/tasks/tasks.ts`'s `setEnabledInYaml` line
78
+ * splice). Broadcasts `enabled` across every `schedule[]` entry — the
79
+ * closest v4 equivalent of v3's single document-level flag. `src` no longer
80
+ * accepts a task v3 file at all (P4 §3.2) — `listSetupTaskDefinitions` below
81
+ * already fails closed on one before this function is ever reached, so
82
+ * there is no legacy `akm.enabled` shape left to handle here.
83
+ */
84
+ function setTaskEnabledInYaml(yaml, enabled) {
75
85
  const document = yamlParse(yaml);
76
- const akm = document.akm;
77
- if (!akm || typeof akm !== "object" || Array.isArray(akm)) {
78
- throw new UsageError("Task v3 source must declare an akm mapping before setup can change enabled state.");
86
+ const schedule = document.schedule;
87
+ if (typeof schedule === "string") {
88
+ document.schedule = [{ cron: schedule, enabled }];
89
+ }
90
+ else if (Array.isArray(schedule) && schedule.length > 0) {
91
+ document.schedule = schedule.map((entry) => entry && typeof entry === "object" && !Array.isArray(entry) ? { ...entry, enabled } : entry);
92
+ }
93
+ else {
94
+ throw new UsageError("Task source v4 must declare a schedule before setup can change enabled state.");
79
95
  }
80
- akm.enabled = enabled;
81
96
  return yamlStringify(document);
82
97
  }
83
98
  export function listSetupTaskDefinitions() {
@@ -93,20 +108,24 @@ export function listSetupTaskDefinitions() {
93
108
  const id = file.slice(0, -4);
94
109
  const filePath = path.join(taskDir, file);
95
110
  try {
96
- const task = parseTaskV3Yaml({
111
+ const parsed = parseTaskSource({
97
112
  yaml: fs.readFileSync(filePath, "utf8"),
98
113
  filePath,
99
114
  workspaceRoot: target.source.path,
100
115
  });
101
- if (task.triggers.schedules.length === 0)
116
+ const document = parsed.v4;
117
+ if (document.schedule.length === 0)
102
118
  continue;
103
- const schedules = task.triggers.schedules.map((schedule) => schedule.cron);
119
+ const schedules = document.schedule.map((entry) => entry.cron);
104
120
  tasks.push({
105
121
  id,
106
122
  schedule: schedules[0],
107
123
  schedules: Object.freeze(schedules),
108
- enabled: task.akm?.enabled !== false,
109
- ...(task.akm?.description !== undefined ? { description: task.akm.description } : {}),
124
+ // task source v4 has no document-level enabled (P4-N6) — a task is
125
+ // considered enabled for review purposes when at least one of its
126
+ // schedule bindings will actually fire.
127
+ enabled: document.schedule.some((entry) => entry.enabled),
128
+ ...(document.description !== undefined ? { description: document.description } : {}),
110
129
  });
111
130
  }
112
131
  catch (error) {
@@ -128,17 +147,15 @@ export async function prepareSetupTaskDefinitions(tasks, deps = {}) {
128
147
  const original = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : undefined;
129
148
  let yaml;
130
149
  if (original !== undefined) {
131
- yaml = setTaskV3EnabledInYaml(original, plan.enabled);
150
+ yaml = setTaskEnabledInYaml(original, plan.enabled);
132
151
  }
133
152
  else {
134
153
  const document = yamlParse(plan.task.yaml);
135
- const akm = document.akm;
136
- akm.schedule = plan.schedule;
137
- akm.enabled = plan.enabled;
154
+ document.schedule = plan.enabled ? plan.schedule : [{ cron: plan.schedule, enabled: false }];
138
155
  yaml = yamlStringify(document);
139
156
  }
140
- const parsed = parseTaskV3Yaml({ yaml, filePath, workspaceRoot: target.source.path });
141
- for (const schedule of parsed.triggers.schedules) {
157
+ const parsed = parseTaskSource({ yaml, filePath, workspaceRoot: target.source.path });
158
+ for (const schedule of parsed.v4.schedule) {
142
159
  parseSchedule(schedule.cron, backendNameForPlatform());
143
160
  }
144
161
  return { filePath, original, yaml, ref: { type: "task", name: plan.task.id } };
@@ -39,7 +39,7 @@ export function decodeTaskHistoryMetadata(input) {
39
39
  metadataError("root must be an object");
40
40
  if (parsed.metadataVersion !== 2)
41
41
  metadataError(`unsupported metadataVersion: ${String(parsed.metadataVersion)}`);
42
- const allowed = new Set(["metadataVersion", "durationMs", "detail", "engine"]);
42
+ const allowed = new Set(["metadataVersion", "durationMs", "detail", "engine", "targetVocab"]);
43
43
  const unknown = Object.keys(parsed).filter((key) => !allowed.has(key));
44
44
  if (unknown.length > 0)
45
45
  metadataError(`unknown fields: ${unknown.sort().join(", ")}`);
@@ -50,12 +50,16 @@ export function decodeTaskHistoryMetadata(input) {
50
50
  if (parsed.engine !== undefined && parsed.engine !== null && typeof parsed.engine !== "string") {
51
51
  metadataError("engine must be a string or null");
52
52
  }
53
+ if (parsed.targetVocab !== undefined && parsed.targetVocab !== 2) {
54
+ metadataError("targetVocab must be 2 when present");
55
+ }
53
56
  validateDetail(parsed.detail);
54
57
  return {
55
58
  metadataVersion: 2,
56
59
  durationMs: parsed.durationMs,
57
60
  detail: parsed.detail ?? null,
58
61
  ...(parsed.engine !== undefined ? { engine: parsed.engine } : {}),
62
+ ...(parsed.targetVocab === 2 ? { targetVocab: 2 } : {}),
59
63
  };
60
64
  }
61
65
  /**
@@ -49,25 +49,48 @@ export class WorkflowRunsRepository {
49
49
  return withImmediateTransaction(this.db, () => fn(this.db));
50
50
  }
51
51
  // ── reads (fully materialised) ─────────────────────────────────────────────
52
+ /**
53
+ * The top-level start guard `publishWorkflowRunV4` uses to refuse starting
54
+ * a SECOND active run of the same ref in the same scope. `AND
55
+ * parent_run_id IS NULL` (B-N10's FOURTH site, code-review round 4 finding
56
+ * 2 / Review log R2): a child run carries the PARENT's `scope_key` (P3a
57
+ * §5.2), so once a parent publishes a child of some ref, starting a
58
+ * TOP-LEVEL run of that same ref in that scope must never resolve to the
59
+ * child — the child is not "already an active run in this scope" from a
60
+ * fresh top-level invocation's point of view; the PARENT, if anything, is.
61
+ * Before this filter, `publishWorkflowRunV4` refused with
62
+ * `RESOURCE_ALREADY_EXISTS` naming the CHILD's own run id, instructing the
63
+ * operator to `akm workflow abandon` a child a parent is actively driving.
64
+ * For any database with no child rows the result is byte-identical, same
65
+ * as the other three B-N10 sites below.
66
+ */
52
67
  findActiveRunForScope(workflowRefs, scopeKey) {
53
68
  const refs = typeof workflowRefs === "string" ? [workflowRefs] : [...workflowRefs];
54
69
  if (refs.length === 0)
55
70
  return undefined;
56
71
  return this.db
57
- .prepare(`SELECT id, current_step_id FROM workflow_runs WHERE workflow_ref IN (${refs.map(() => "?").join(", ")}) AND scope_key = ? AND status = 'active' ORDER BY updated_at DESC, created_at DESC LIMIT 1`)
72
+ .prepare(`SELECT id, current_step_id FROM workflow_runs WHERE workflow_ref IN (${refs.map(() => "?").join(", ")}) AND scope_key = ? AND status = 'active' AND parent_run_id IS NULL ORDER BY updated_at DESC, created_at DESC LIMIT 1`)
58
73
  .get(...refs, scopeKey);
59
74
  }
60
75
  getRunById(runId) {
61
76
  return (this.db.prepare("SELECT * FROM workflow_runs WHERE id = ?").get(runId) ??
62
77
  undefined);
63
78
  }
79
+ /**
80
+ * The scope-attach lookup `akm workflow run <ref>` uses to find an
81
+ * in-progress top-level run to resume instead of starting a new one.
82
+ * `AND parent_run_id IS NULL` (B-N10): a child run must never be attached
83
+ * to directly through this path — a parent-driven child would then have
84
+ * TWO drivers. For any database with no child rows the result is
85
+ * byte-identical.
86
+ */
64
87
  getActiveRunRowForScope(workflowRefs, scopeKey) {
65
88
  const refs = typeof workflowRefs === "string" ? [workflowRefs] : [...workflowRefs];
66
89
  if (refs.length === 0)
67
90
  return undefined;
68
- return this.db
69
- .prepare(`SELECT * FROM workflow_runs WHERE workflow_ref IN (${refs.map(() => "?").join(", ")}) AND scope_key = ? AND status = 'active' ORDER BY updated_at DESC, created_at DESC LIMIT 1`)
70
- .get(...refs, scopeKey);
91
+ return (this.db
92
+ .prepare(`SELECT * FROM workflow_runs WHERE workflow_ref IN (${refs.map(() => "?").join(", ")}) AND scope_key = ? AND status = 'active' AND parent_run_id IS NULL ORDER BY updated_at DESC, created_at DESC LIMIT 1`)
93
+ .get(...refs, scopeKey) ?? undefined);
71
94
  }
72
95
  hasRun(runId) {
73
96
  const row = this.db.prepare("SELECT 1 FROM workflow_runs WHERE id = ? LIMIT 1").get(runId);
@@ -99,6 +122,12 @@ export class WorkflowRunsRepository {
99
122
  // into this shared list filter.
100
123
  filters.push("status = 'active'");
101
124
  }
125
+ // B-N10: a child run is invisible to this scope query unless the caller
126
+ // explicitly opts in (`akm workflow list --children`). For any database
127
+ // with no child rows the result set is byte-identical either way.
128
+ if (!filter.includeChildren) {
129
+ filters.push("parent_run_id IS NULL");
130
+ }
102
131
  const where = filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : "";
103
132
  return this.db
104
133
  .prepare(`SELECT * FROM workflow_runs ${where} ORDER BY updated_at DESC, created_at DESC`)
@@ -112,13 +141,26 @@ export class WorkflowRunsRepository {
112
141
  getStep(runId, stepId) {
113
142
  return this.db.prepare("SELECT * FROM workflow_run_steps WHERE run_id = ? AND step_id = ?").get(runId, stepId);
114
143
  }
144
+ /**
145
+ * The `akm show` active-run guard: which run (if any) currently occupies
146
+ * this scope. `AND parent_run_id IS NULL` (B-N10) — a child a parent is
147
+ * driving must never be reported as "the" active run; the PARENT is. For
148
+ * any database with no child rows the result is byte-identical.
149
+ */
115
150
  findActiveOrBlockedRunForScope(scopeKey) {
116
151
  return (this.db
117
- .prepare("SELECT id, current_step_id, workflow_ref FROM workflow_runs WHERE scope_key = ? AND status IN ('active', 'blocked') ORDER BY updated_at DESC LIMIT 1")
152
+ .prepare("SELECT id, current_step_id, workflow_ref FROM workflow_runs WHERE scope_key = ? AND status IN ('active', 'blocked') AND parent_run_id IS NULL ORDER BY updated_at DESC LIMIT 1")
118
153
  .get(scopeKey) ?? null);
119
154
  }
120
155
  // ── writes ─────────────────────────────────────────────────────────────────
121
156
  insertRun(input) {
157
+ // R-R3 (P3a Review log; docs/plans/specs/p4-deletions-closeout.md §8):
158
+ // this 13-column list is hand-duplicated by publishChildWorkflowRun's own
159
+ // INSERT below, which extends it with parent_run_id/parent_unit_id/
160
+ // invocation_key. A signature refactor to share one INSERT builder was
161
+ // considered and deliberately deferred — see
162
+ // docs/architecture/decisions/0009-child-run-publication-column-parity.md.
163
+ // Keep both column lists in sync by hand.
122
164
  this.db
123
165
  .prepare(`INSERT INTO workflow_runs (
124
166
  id, workflow_ref, scope_key, workflow_entry_id, workflow_title, status, params_json, current_step_id, created_at, updated_at,
@@ -190,7 +232,7 @@ export class WorkflowRunsRepository {
190
232
  }
191
233
  this.insertRun(input.run);
192
234
  this.insertSteps(input.steps);
193
- db.prepare("UPDATE workflow_runs SET plan_json = ?, plan_hash = ?, plan_ir_version = 4 WHERE id = ?").run(input.planJson, input.planHash, input.run.id);
235
+ db.prepare("UPDATE workflow_runs SET plan_json = ?, plan_hash = ?, plan_ir_version = 5 WHERE id = ?").run(input.planJson, input.planHash, input.run.id);
194
236
  insertEventOnce(db, {
195
237
  eventType: "workflow_started",
196
238
  ts: input.run.createdAt,
@@ -201,6 +243,102 @@ export class WorkflowRunsRepository {
201
243
  });
202
244
  });
203
245
  }
246
+ // ── child workflow run publication (migration 023, P3a §5.2-§5.3) ─────────
247
+ //
248
+ // No production caller exists in P3a (§5.5) — dispatch is P3b's. These
249
+ // three methods are reachable only from tests until then.
250
+ /**
251
+ * Idempotently publish a child workflow run underneath the parent unit
252
+ * that spawned it. ONE `immediateTransaction`: SELECT by
253
+ * `(parent_run_id, invocation_key)` and return the existing child if
254
+ * present; otherwise INSERT the child run row (parentage columns +
255
+ * `invocation_key`), its step rows, attach the embedded frozen child plan
256
+ * (`plan_ir_version = 5`), and append its `workflow_started` event — then
257
+ * return the freshly-inserted row.
258
+ *
259
+ * Deliberately does NOT: call {@link findActiveRunForScope} or raise
260
+ * `RESOURCE_ALREADY_EXISTS` (top-level scope-conflict rules do not apply to
261
+ * a child, C-10); call `revalidateSources` or read the filesystem in any
262
+ * way (the child plan was frozen and CAS'd into the PARENT's read set at
263
+ * parent freeze, C-11); or touch {@link publishWorkflowRunV4} /
264
+ * `startWorkflowRun` (untouched, C-12).
265
+ *
266
+ * This method's serialization guarantee holds only when it is the
267
+ * OUTERMOST transaction on the connection (spec Review log R10,
268
+ * docs/plans/specs/p3a-plan-v5-child-freeze.md). `withImmediateTransaction`
269
+ * (src/core/state-db.ts) has a re-entrancy guard: if a transaction is
270
+ * already open on the connection, it SILENTLY JOINS that transaction
271
+ * instead of issuing its own `BEGIN IMMEDIATE`.
272
+ * `WorkflowRunsRepository.transaction()` is DEFERRED (`db.transaction(fn)()`)
273
+ * and is already used in production at `resumeWorkflowRun`
274
+ * (src/workflows/runtime/runs.ts:506) and `completeWorkflowStep` (:782) —
275
+ * a caller that wires this call inside one of those outer transactions
276
+ * loses the guarantee below: the SELECT can read a stale snapshot, both
277
+ * publishers can miss the existing row, and the loser's INSERT hits
278
+ * `idx_workflow_runs_invocation_key` with a raw `SQLiteError` instead of
279
+ * returning the winner's row.
280
+ *
281
+ * As the outermost transaction, the whole SELECT-else-INSERT sequence runs
282
+ * inside one `BEGIN IMMEDIATE`, so two concurrent callers racing on the
283
+ * same `(parentRunId, invocationKey)` serialize on SQLite's write lock: the
284
+ * first to acquire it inserts and commits, and the second's own SELECT —
285
+ * which can only run once it has acquired the lock in turn — finds and
286
+ * returns the first's row rather than inserting a duplicate or throwing
287
+ * (C-09). Calling this twice with the same key, including across a crash
288
+ * between publish and parent-side recording, is therefore safe and returns
289
+ * the same child both times, with exactly one event and one step set
290
+ * (C-08).
291
+ */
292
+ publishChildWorkflowRun(input) {
293
+ return this.immediateTransaction((db) => {
294
+ const existing = db
295
+ .prepare("SELECT * FROM workflow_runs WHERE parent_run_id = ? AND invocation_key = ?")
296
+ .get(input.parentRunId, input.invocationKey);
297
+ if (existing)
298
+ return existing;
299
+ // R-R3 (P3a Review log; docs/plans/specs/p4-deletions-closeout.md §8):
300
+ // the first 13 columns here must stay byte-identical to insertRun's own
301
+ // column list above (hand-duplicated, not shared, by deliberate choice
302
+ // — see docs/architecture/decisions/0009-child-run-publication-column-parity.md).
303
+ // Keep both column lists in sync by hand.
304
+ db.prepare(`INSERT INTO workflow_runs (
305
+ id, workflow_ref, scope_key, workflow_entry_id, workflow_title, status, params_json, current_step_id, created_at, updated_at,
306
+ agent_harness, agent_session_id, checkin_armed_at, parent_run_id, parent_unit_id, invocation_key
307
+ ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.run.id, input.run.workflowRef, input.run.scopeKey, input.run.workflowEntryId, input.run.workflowTitle, input.run.paramsJson, input.run.currentStepId, input.run.createdAt, input.run.updatedAt, input.run.agentHarness, input.run.agentSessionId, input.run.checkinArmedAt, input.parentRunId, input.spawnedByUnitId, input.invocationKey);
308
+ this.insertSteps(input.steps);
309
+ db.prepare("UPDATE workflow_runs SET plan_json = ?, plan_hash = ?, plan_ir_version = 5 WHERE id = ?").run(input.planJson, input.planHash, input.run.id);
310
+ insertEventOnce(db, {
311
+ eventType: "workflow_started",
312
+ ts: input.run.createdAt,
313
+ ref: input.run.workflowRef,
314
+ metadata: { runId: input.run.id, status: "active" },
315
+ idempotencyKey: input.run.id,
316
+ idempotencyMetadataKey: "runId",
317
+ });
318
+ return db.prepare("SELECT * FROM workflow_runs WHERE id = ?").get(input.run.id);
319
+ });
320
+ }
321
+ /**
322
+ * Persist a run's resolved declared `outputs:` (migration 024, P3b §4.3).
323
+ * Called from INSIDE `completeWorkflowStep`'s own write transaction — this
324
+ * method opens none of its own, matching `updateStepCompletion` /
325
+ * `updateRunState` immediately above.
326
+ */
327
+ setRunOutputs(runId, outputsJson) {
328
+ this.db.prepare("UPDATE workflow_runs SET outputs_json = ? WHERE id = ?").run(outputsJson, runId);
329
+ }
330
+ /** Every child run published under `parentRunId`, oldest first. `[]` when none. */
331
+ childRunsOf(parentRunId) {
332
+ return this.db
333
+ .prepare("SELECT * FROM workflow_runs WHERE parent_run_id = ? ORDER BY created_at ASC, id ASC")
334
+ .all(parentRunId);
335
+ }
336
+ /** The child run published under `(parentRunId, key)`, or undefined if none has been published yet. */
337
+ getRunByInvocationKey(parentRunId, key) {
338
+ return (this.db
339
+ .prepare("SELECT * FROM workflow_runs WHERE parent_run_id = ? AND invocation_key = ?")
340
+ .get(parentRunId, key) ?? undefined);
341
+ }
204
342
  // ── engine run lease (migration 006 columns, R2 enforcement) ──────────────
205
343
  //
206
344
  // Single-driver invariant: at most one `akm workflow run` invocation drives
@@ -817,97 +817,44 @@ function renderCalendar(calendar, indent) {
817
817
  function normalizeSignature(xml) {
818
818
  return xml.replace(/\r\n/g, "\n").trim();
819
819
  }
820
- /** Parse only proven loaded-service labels from bounded launchctl domain/list output. */
820
+ /**
821
+ * Collect the akm-owned service labels present in `launchctl` output.
822
+ *
823
+ * This reads the CURRENT USER'S OWN scheduler inventory — the tasks they asked
824
+ * akm to schedule, on their own machine. It is not an attacker-controlled
825
+ * document, so it is parsed permissively: scan for labels in our own
826
+ * `com.akm.task.` namespace and ignore everything else.
827
+ *
828
+ * It previously enforced a rigid grammar over the whole document and returned
829
+ * `undefined` — surfacing as a hard INVALID_CONFIG_FILE that refused to inspect
830
+ * scheduler state at all — if ANY line failed to match. Real
831
+ * `launchctl print gui/<uid>` output on macOS is far richer than that grammar
832
+ * allowed, so on a real Mac this rejected the user's own inventory and broke
833
+ * every launchd operation. Caught by the gated native-scheduler suite, which
834
+ * had never been dispatched before.
835
+ *
836
+ * The two bounds that remain are real resource bounds, not structural ones: a
837
+ * cap on how much output we will read, and a cap on how many distinct akm
838
+ * labels we will track. Exceeding either still returns `undefined`.
839
+ */
821
840
  export function parseLaunchdLoadedLabels(output) {
822
- if (Buffer.byteLength(output, "utf8") > MAX_LAUNCHD_DOMAIN_OUTPUT_BYTES || hasUnsafeLaunchdControlCharacter(output)) {
841
+ if (Buffer.byteLength(output, "utf8") > MAX_LAUNCHD_DOMAIN_OUTPUT_BYTES)
823
842
  return undefined;
824
- }
825
- const lines = output.replace(/\r\n?/gu, "\n").split("\n");
826
843
  const labels = new Set();
827
- let akmEntryCount = 0;
828
- const add = (label) => {
844
+ // Our own namespace is the only thing we look for. `[^\s"{}=,()]` stops the
845
+ // token at whatever punctuation the surrounding launchctl syntax uses, so a
846
+ // label works whether it appears as a bare table cell, a quoted string, or a
847
+ // dictionary key.
848
+ const labelPattern = /com\.akm\.task\.[^\s"{}=,()]+/gu;
849
+ for (const match of output.matchAll(labelPattern)) {
850
+ const label = match[0];
829
851
  if (!LAUNCHD_AKM_LABEL_RE.test(label))
830
- return false;
831
- akmEntryCount += 1;
832
- if (akmEntryCount > MAX_LAUNCHD_AKM_NAMESPACE_ENTRIES || labels.has(label))
833
- return false;
834
- labels.add(label);
835
- return true;
836
- };
837
- const firstContent = lines.findIndex((line) => line.trim() !== "");
838
- if (firstContent >= 0 && /^PID\s+Status\s+Label$/iu.test(lines[firstContent].trim())) {
839
- for (const line of lines.slice(firstContent + 1)) {
840
- if (!line.trim())
841
- continue;
842
- const row = /^\s*(?:-|\d+)\s+-?\d+\s+(\S+)\s*$/u.exec(line);
843
- if (!row?.[1])
844
- return undefined;
845
- if (row[1].startsWith(LAUNCHD_LABEL_PREFIX) && !add(row[1]))
846
- return undefined;
847
- }
848
- return labels;
849
- }
850
- const firstLine = firstContent < 0 ? undefined : lines[firstContent];
851
- if (firstLine === undefined || !/^\s*(?:gui|user)\/\d+\s*=\s*\{\s*$/u.test(firstLine))
852
- return undefined;
853
- let lastContent = lines.length - 1;
854
- while (lastContent >= 0 && !lines[lastContent]?.trim())
855
- lastContent -= 1;
856
- const lastLine = lines[lastContent];
857
- if (lastContent <= firstContent || lastLine === undefined || !/^\s*\}\s*$/u.test(lastLine))
858
- return undefined;
859
- let depth = 1;
860
- let servicesSeen = false;
861
- let insideServices = false;
862
- for (let index = firstContent + 1; index <= lastContent; index += 1) {
863
- const line = lines[index];
864
- if (index === lastContent) {
865
- if (insideServices || depth !== 1)
866
- return undefined;
867
- depth = 0;
868
852
  continue;
869
- }
870
- const servicesBlock = /^\s*services\s*=\s*\{\s*$/u.test(line);
871
- const emptyServicesBlock = /^\s*services\s*=\s*\{\s*\}\s*$/u.test(line);
872
- if (servicesBlock || emptyServicesBlock) {
873
- if (servicesSeen || insideServices || depth !== 1)
874
- return undefined;
875
- servicesSeen = true;
876
- if (servicesBlock) {
877
- insideServices = true;
878
- depth += 1;
879
- }
880
- continue;
881
- }
882
- if (/^\s*services\s*=/u.test(line))
883
- return undefined;
884
- if (insideServices && depth === 2) {
885
- if (!line.trim())
886
- continue;
887
- if (/^\s*\}\s*$/u.test(line)) {
888
- insideServices = false;
889
- depth -= 1;
890
- continue;
891
- }
892
- const assignment = /^\s*-?\d+\s*=\s*"?([^"\s{}=]+)"?\s*$/u.exec(line);
893
- const domainTable = /^\s*(?:-|\d+)\s+(?:-|-?\d+)\s+(\S+)\s*$/u.exec(line);
894
- const dictionary = /^\s*"?([^"\s{}=]+)"?\s*=\s*\{\s*$/u.exec(line);
895
- const candidate = assignment?.[1] ?? domainTable?.[1] ?? dictionary?.[1];
896
- if (!candidate)
897
- return undefined;
898
- if (candidate.startsWith(LAUNCHD_LABEL_PREFIX) && !add(candidate))
899
- return undefined;
900
- if (dictionary)
901
- depth += 1;
902
- continue;
903
- }
904
- if (line.includes(LAUNCHD_LABEL_PREFIX))
905
- return undefined;
906
- depth += countCharacter(line, "{") - countCharacter(line, "}");
907
- if (depth < 1 || (insideServices && depth < 2))
853
+ labels.add(label);
854
+ if (labels.size > MAX_LAUNCHD_AKM_NAMESPACE_ENTRIES)
908
855
  return undefined;
909
856
  }
910
- return servicesSeen && depth === 0 ? labels : undefined;
857
+ return labels;
911
858
  }
912
859
  function countCharacter(value, needle) {
913
860
  let count = 0;
@@ -22,7 +22,7 @@
22
22
  import fs from "node:fs";
23
23
  import path from "node:path";
24
24
  import { getDirname } from "../runtime.js";
25
- import { parseTaskV3Yaml } from "./source-v3.js";
25
+ import { parseTaskSource } from "./source/parse-task-source.js";
26
26
  /** Directory holding the bundled task template categories. */
27
27
  const TASKS_ASSETS_DIR = path.join(getDirname(import.meta.url), "../assets/tasks");
28
28
  /**
@@ -65,22 +65,28 @@ export function listEmbeddedTasks() {
65
65
  catch {
66
66
  continue;
67
67
  }
68
- let task;
68
+ let parsed;
69
69
  try {
70
- task = parseTaskV3Yaml({ yaml, filePath, workspaceRoot: TASKS_ASSETS_DIR });
70
+ parsed = parseTaskSource({ yaml, filePath, workspaceRoot: TASKS_ASSETS_DIR });
71
71
  }
72
72
  catch {
73
73
  continue;
74
74
  }
75
- if (task.target.kind !== "run" || task.akm?.schedule === undefined)
75
+ // Shipped templates are task source v4 (spec docs/plans/specs/p4-deletions-closeout.md
76
+ // §3.2.6, row B-24): a template's `enabled` is per schedule-binding, so
77
+ // the single-cron display shape here reads the FIRST schedule entry —
78
+ // every shipped template authors exactly one.
79
+ const task = parsed.v4;
80
+ const [firstSchedule] = task.schedule;
81
+ if (task.target.kind !== "run" || !firstSchedule)
76
82
  continue;
77
83
  tasks.push({
78
84
  id,
79
85
  label: `${category}/${id}`,
80
86
  command: task.target.run,
81
- schedule: task.akm.schedule,
82
- description: task.akm.description ?? "",
83
- enabled: task.akm.enabled !== false,
87
+ schedule: firstSchedule.cron,
88
+ description: task.description ?? "",
89
+ enabled: firstSchedule.enabled,
84
90
  yaml,
85
91
  });
86
92
  }
@@ -0,0 +1,4 @@
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
+ export {};
@@ -0,0 +1,9 @@
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
+ import { captureScriptTarget } from "./script-capture.js";
5
+ /** Project a script asset's own identity into the frozen shape a workflow script step dispatches. */
6
+ export function prepareScriptTarget(input) {
7
+ const captured = captureScriptTarget(input.ref, input.file, input.bundleRoot, input.readFile);
8
+ return Object.freeze({ ref: input.ref, ...captured });
9
+ }