akm-cli 0.9.3 → 0.9.5

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 (56) hide show
  1. package/CHANGELOG.md +233 -1
  2. package/README.md +1 -1
  3. package/SECURITY.md +1 -1
  4. package/STABILITY.md +1 -1
  5. package/dist/akm +2 -2
  6. package/dist/akm-migrate +2 -2
  7. package/dist/cli.js +5 -5
  8. package/dist/commands/health/improve-metrics.js +17 -0
  9. package/dist/commands/health/windows.js +2 -2
  10. package/dist/commands/health.js +2 -2
  11. package/dist/commands/improve/anti-collapse.js +4 -91
  12. package/dist/commands/improve/preparation.js +8 -1
  13. package/dist/commands/lint/index.js +3 -7
  14. package/dist/commands/proposal/validators/proposal-validators.js +12 -0
  15. package/dist/commands/read/search.js +14 -24
  16. package/dist/commands/tasks/tasks-cli.js +81 -3
  17. package/dist/commands/tasks/tasks.js +117 -2
  18. package/dist/core/adapter/adapters/akm-adapter.js +23 -14
  19. package/dist/core/adapter/adapters/akm-lint.js +3 -2
  20. package/dist/core/adapter/adapters/akm-task-adapter.js +9 -6
  21. package/dist/core/adapter/adapters/dotenv-adapter.js +13 -11
  22. package/dist/core/adapter/recognize-match.js +1 -20
  23. package/dist/core/asset/asset-placement.js +21 -2
  24. package/dist/core/common.js +21 -1
  25. package/dist/core/config/config-version-shim.js +101 -0
  26. package/dist/core/config/config.js +6 -6
  27. package/dist/core/improve-result.js +35 -14
  28. package/dist/execution/guarded-source.js +0 -10
  29. package/dist/indexer/lookup/adapter-concept-owner.js +6 -89
  30. package/dist/indexer/passes/metadata.js +12 -4
  31. package/dist/indexer/scan/doc-to-entry.js +2 -0
  32. package/dist/indexer/search/db-search.js +6 -0
  33. package/dist/indexer/search/search-fields.js +16 -1
  34. package/dist/indexer/walk/matchers.js +0 -22
  35. package/dist/output/shapes/helpers.js +19 -1
  36. package/dist/output/shapes/passthrough.js +18 -5
  37. package/dist/output/text/command-format.js +4 -0
  38. package/dist/registry/pinned-request-helper.js +2 -2
  39. package/dist/registry/pinned-transport.js +6 -6
  40. package/dist/scripts/akm-migrate-node.js +12678 -12601
  41. package/dist/scripts/akm-migrate.js +12678 -12601
  42. package/dist/storage/repositories/proposals-repository.js +65 -7
  43. package/dist/storage/repositories/task-history-repository.js +22 -10
  44. package/dist/tasks/run/task-history.js +23 -3
  45. package/dist/tasks/scheduler-binding.js +15 -5
  46. package/dist/tasks/scheduler-sync-preview.js +45 -0
  47. package/dist/tasks/scheduler-sync.js +77 -41
  48. package/dist/tasks/source/bounded-document.js +1 -1
  49. package/dist/tasks/source/parse-task-source.js +77 -11
  50. package/dist/tasks/source/task-source-v3-frozen.js +428 -0
  51. package/dist/tasks/source/task-to-v3.js +512 -0
  52. package/dist/tasks/source/task-to-v4.js +457 -0
  53. package/docs/reference/cli.md +33 -6
  54. package/docs/reference/configuration.md +27 -6
  55. package/docs/reference/tasks.md +10 -0
  56. package/package.json +4 -4
@@ -23,8 +23,24 @@ function changesToStored(changes) {
23
23
  /**
24
24
  * Reconstruct `Proposal.changes` from `metadata_json.changes` + the `content`
25
25
  * column.
26
+ *
27
+ * Read-time compatibility shim (#858/#859): proposals created before this
28
+ * field existed have no `changes` key in `metadata_json` at all (~89% of the
29
+ * archived accepted/rejected rows on real installs) and that history cannot
30
+ * be reconstructed from `content` alone. Treat a completely absent `changes`
31
+ * key as a known legacy gap — return an empty change list rather than
32
+ * throwing — so these rows still round-trip as real proposals (and count
33
+ * toward accepted/rejected totals) instead of being dropped or crashing
34
+ * every reader. A `changes` value that *is* present but malformed (wrong
35
+ * type, invalid entries) is still corruption and throws, same as before; the
36
+ * write path (`proposalToRowValues`) still refuses to persist an empty or
37
+ * missing change list for new proposals, so this leniency only ever applies
38
+ * to pre-existing rows.
26
39
  */
27
40
  function storedToChanges(stored, content) {
41
+ if (stored === undefined) {
42
+ return [];
43
+ }
28
44
  if (!Array.isArray(stored) || stored.length === 0) {
29
45
  throw new Error("Proposal metadata is missing changes.");
30
46
  }
@@ -69,7 +85,7 @@ function currentProposalRef(ref, requireQualified = false) {
69
85
  }
70
86
  function currentProposalTarget(value) {
71
87
  if (typeof value !== "object" || value === null)
72
- throw new Error("Proposal metadata is missing proposedTarget.");
88
+ throw new Error("Proposal metadata has an invalid proposedTarget.");
73
89
  const target = value;
74
90
  if (typeof target.source !== "string" ||
75
91
  !isBundleSlug(target.source) ||
@@ -184,7 +200,13 @@ export function proposalRowToProposal(row) {
184
200
  }
185
201
  validatePresentMetadata(meta);
186
202
  const changes = storedToChanges(meta.changes, row.content);
187
- const proposedTarget = currentProposalTarget(meta.proposedTarget);
203
+ // #859: absent proposedTarget (~93% of the real archive's accepted/rejected
204
+ // rows) is envelope metadata that predates this field, not corruption — an
205
+ // already-decided proposal is counted/displayed, never re-applied, so
206
+ // decode tolerates its absence the same way it already tolerates absent
207
+ // `changes`. A *present but malformed* value is still corruption and
208
+ // throws via currentProposalTarget, same as before.
209
+ const proposedTarget = meta.proposedTarget !== undefined ? currentProposalTarget(meta.proposedTarget) : undefined;
188
210
  return {
189
211
  id: row.id,
190
212
  ref: currentProposalRef(row.ref),
@@ -198,7 +220,7 @@ export function proposalRowToProposal(row) {
198
220
  ...(frontmatter !== undefined ? { frontmatter } : {}),
199
221
  },
200
222
  changes,
201
- proposedTarget,
223
+ ...(proposedTarget !== undefined ? { proposedTarget } : {}),
202
224
  ...(typeof meta.beforeHash === "string" ? { beforeHash: meta.beforeHash } : {}),
203
225
  ...(meta.review !== undefined ? { review: meta.review } : {}),
204
226
  ...(typeof meta.confidence === "number" ? { confidence: meta.confidence } : {}),
@@ -217,8 +239,27 @@ export function proposalRowToProposal(row) {
217
239
  export function proposalToRowValues(proposal, stashDir) {
218
240
  // Fields that have no dedicated column live in metadata_json.
219
241
  const metaObj = {};
220
- if (!Array.isArray(proposal.changes) || proposal.changes.length === 0) {
221
- throw new Error(`Proposal ${proposal.id} has no file changes.`);
242
+ // #859: `changes` and `proposedTarget` are only enforced as REQUIRED when
243
+ // minting/updating a `pending` proposal — the one status createProposal
244
+ // ever writes, and the one status every real accept/reject transition
245
+ // writes FROM (a pending row always carries the full envelope; see
246
+ // storedToChanges / currentProposalTarget doc comments). Writing a
247
+ // non-pending status (accepted/rejected/reverted) is always a status
248
+ // TRANSITION of an already-persisted proposal, spread-copied from the row
249
+ // this same repository just decoded — for a legacy archived row that
250
+ // never had `changes`/`proposedTarget` to begin with (the pre-envelope
251
+ // shape), re-persisting it unchanged (e.g. `proposal revert` on an old
252
+ // accepted row) must not be blocked by a requirement the row never met.
253
+ // This does NOT weaken validation of a genuinely new proposal: every
254
+ // pending-status write still requires the full envelope, exactly as
255
+ // before.
256
+ if (proposal.status === "pending") {
257
+ if (!Array.isArray(proposal.changes) || proposal.changes.length === 0) {
258
+ throw new Error(`Proposal ${proposal.id} has no file changes.`);
259
+ }
260
+ if (proposal.proposedTarget === undefined) {
261
+ throw new Error(`Proposal ${proposal.id} is missing proposedTarget.`);
262
+ }
222
263
  }
223
264
  if (proposal.changes.some((change) => typeof change.path !== "string" ||
224
265
  change.path.length === 0 ||
@@ -227,7 +268,8 @@ export function proposalToRowValues(proposal, stashDir) {
227
268
  throw new Error(`Proposal ${proposal.id} has invalid file changes.`);
228
269
  }
229
270
  metaObj.changes = changesToStored(proposal.changes);
230
- metaObj.proposedTarget = currentProposalTarget(proposal.proposedTarget);
271
+ if (proposal.proposedTarget !== undefined)
272
+ metaObj.proposedTarget = currentProposalTarget(proposal.proposedTarget);
231
273
  if (proposal.beforeHash !== undefined)
232
274
  metaObj.beforeHash = proposal.beforeHash;
233
275
  if (proposal.sourceRun !== undefined)
@@ -307,7 +349,23 @@ export function listStateProposals(db, options = {}) {
307
349
  content, frontmatter_json, metadata_json
308
350
  FROM proposals ${where} ORDER BY created_at ASC, rowid ASC`)
309
351
  .all(...params);
310
- return rows.map(proposalRowToProposal);
352
+ // Per-row skip-and-warn (#858/#859): a single row that fails to parse
353
+ // (invalid ref/status/metadata shape — genuine corruption, distinct from
354
+ // the tolerated legacy-missing-`changes` case in `storedToChanges` above)
355
+ // must not abort the entire list. Every caller of this function reads a
356
+ // multi-row archive; one bad row hiding the rest behind a thrown error is
357
+ // strictly worse than surfacing the well-formed rows plus a warning.
358
+ const proposals = [];
359
+ for (const row of rows) {
360
+ try {
361
+ proposals.push(proposalRowToProposal(row));
362
+ }
363
+ catch (error) {
364
+ const message = error instanceof Error ? error.message : String(error);
365
+ console.warn(`[akm] Skipping unparseable proposal row (id=${row.id}, ref=${row.ref}): ${message}`);
366
+ }
367
+ }
368
+ return proposals;
311
369
  }
312
370
  /**
313
371
  * Look up a single proposal by id, optionally scoped to one stash root.
@@ -24,7 +24,23 @@ function validateDetail(value) {
24
24
  metadataError("detail.exitCode must be a number or null");
25
25
  }
26
26
  }
27
- /** Decode the current task-history metadata shape. */
27
+ /**
28
+ * Decode the task-history metadata shape.
29
+ *
30
+ * Read-time compatibility shim (mirrors proposals-repository's
31
+ * `storedToChanges`): `metadataVersion` was added in a later release. 58% of
32
+ * a real install's `task_history` rows (8,596/14,801) predate the field
33
+ * entirely, and 88 more carry additive fields (`profile`, `repairReason`)
34
+ * written by prior releases. An absent `metadataVersion` is a LEGACY row, not
35
+ * corruption — decode what's present (defaulting the absent `detail` key to
36
+ * `null`) instead of throwing. Unknown keys are ignored rather than
37
+ * hard-rejected, so the next additive field never regresses this again;
38
+ * neither `profile` nor `repairReason` is read anywhere downstream, so they
39
+ * are dropped harmlessly rather than round-tripped. A `metadataVersion` that
40
+ * IS present but not `2` is a genuinely unknown/future shape and still
41
+ * rejected, as is a non-number `durationMs` or a `detail` that fails
42
+ * {@link validateDetail} — real corruption, not version skew.
43
+ */
28
44
  export function decodeTaskHistoryMetadata(input) {
29
45
  let parsed = input;
30
46
  if (typeof input === "string") {
@@ -37,27 +53,23 @@ export function decodeTaskHistoryMetadata(input) {
37
53
  }
38
54
  if (!isRecord(parsed))
39
55
  metadataError("root must be an object");
40
- if (parsed.metadataVersion !== 2)
56
+ if (parsed.metadataVersion !== undefined && parsed.metadataVersion !== 2) {
41
57
  metadataError(`unsupported metadataVersion: ${String(parsed.metadataVersion)}`);
42
- const allowed = new Set(["metadataVersion", "durationMs", "detail", "engine", "targetVocab"]);
43
- const unknown = Object.keys(parsed).filter((key) => !allowed.has(key));
44
- if (unknown.length > 0)
45
- metadataError(`unknown fields: ${unknown.sort().join(", ")}`);
58
+ }
46
59
  if (typeof parsed.durationMs !== "number")
47
60
  metadataError("durationMs must be a number");
48
- if (!("detail" in parsed))
49
- metadataError("detail is required");
61
+ const detail = "detail" in parsed ? parsed.detail : null;
50
62
  if (parsed.engine !== undefined && parsed.engine !== null && typeof parsed.engine !== "string") {
51
63
  metadataError("engine must be a string or null");
52
64
  }
53
65
  if (parsed.targetVocab !== undefined && parsed.targetVocab !== 2) {
54
66
  metadataError("targetVocab must be 2 when present");
55
67
  }
56
- validateDetail(parsed.detail);
68
+ validateDetail(detail);
57
69
  return {
58
70
  metadataVersion: 2,
59
71
  durationMs: parsed.durationMs,
60
- detail: parsed.detail ?? null,
72
+ detail: detail ?? null,
61
73
  ...(parsed.engine !== undefined ? { engine: parsed.engine } : {}),
62
74
  ...(parsed.targetVocab === 2 ? { targetVocab: 2 } : {}),
63
75
  };
@@ -79,14 +79,34 @@ export function readTaskHistory(options = {}) {
79
79
  // discarded and `akm task history --id X --limit 20` always returned one
80
80
  // run. The CLI documents --limit as "Maximum rows to return"; honour it.
81
81
  if (options.limit !== undefined && options.limit > 0) {
82
- return getTaskHistoryRuns(db, options.id, options.limit).map(taskHistoryRowToResult);
82
+ return decodeTaskHistoryRows(getTaskHistoryRuns(db, options.id, options.limit));
83
83
  }
84
84
  const row = getTaskHistory(db, options.id);
85
- return row ? [taskHistoryRowToResult(row)] : [];
85
+ return row ? decodeTaskHistoryRows([row]) : [];
86
86
  }
87
- return queryTaskHistory(db, options.limit !== undefined && options.limit > 0 ? { limit: options.limit } : {}).map(taskHistoryRowToResult);
87
+ return decodeTaskHistoryRows(queryTaskHistory(db, options.limit !== undefined && options.limit > 0 ? { limit: options.limit } : {}));
88
88
  });
89
89
  }
90
+ /**
91
+ * Per-row skip-and-warn (mirrors `listStateProposals` in
92
+ * proposals-repository.ts): a single genuinely-corrupt `metadata_json` row
93
+ * must not abort the entire history read. `decodeTaskHistoryMetadata` (via
94
+ * `taskHistoryRowToResult`) already tolerates legacy/additive shapes; only
95
+ * real corruption reaches this catch.
96
+ */
97
+ function decodeTaskHistoryRows(rows) {
98
+ const results = [];
99
+ for (const row of rows) {
100
+ try {
101
+ results.push(taskHistoryRowToResult(row));
102
+ }
103
+ catch (error) {
104
+ const message = error instanceof Error ? error.message : String(error);
105
+ console.warn(`[akm] Skipping unparseable task_history row (task_id=${row.task_id}, started_at=${row.started_at}): ${message}`);
106
+ }
107
+ }
108
+ return results;
109
+ }
90
110
  /**
91
111
  * Convert a `TaskHistoryRow` from state.db back to a `TaskRunResult` shape
92
112
  * that callers of `readTaskHistory()` expect.
@@ -159,12 +159,15 @@ export function schedulerLogicalBindingOwner(nativeId, invocation) {
159
159
  return schedulerNativeArtifactOwner(nativeId, invocation)?.logicalId;
160
160
  }
161
161
  export function schedulerNativeArtifactOwner(nativeId, invocation) {
162
- if (invocation[0] === "workflow" && invocation[1] === "run") {
163
- return { logicalId: nativeId, logicalKind: "workflow" };
162
+ if (invocation[0] === "workflow" && invocation[1] === "run" && invocation.length === 3) {
163
+ const ref = invocation[2];
164
+ if (ref && workflowRefDigestsToNativeId(ref, nativeId)) {
165
+ return { logicalId: nativeId, logicalKind: "workflow" };
166
+ }
167
+ return undefined;
164
168
  }
165
169
  const taskId = invocation[0] === "task" && invocation[1] === "run" ? invocation[2] : undefined;
166
- const bundleIndex = invocation.indexOf("--bundle", 3);
167
- if (!taskId || bundleIndex === -1 || !invocation[bundleIndex + 1])
170
+ if (!taskId)
168
171
  return undefined;
169
172
  return {
170
173
  logicalId: schedulerNativeBindingId(taskId) === nativeId ? taskId : nativeId,
@@ -225,7 +228,6 @@ export function assertSchedulerMutationArtifact(artifact, expected) {
225
228
  artifact.nativeId === expected.nativeId &&
226
229
  artifact.bindingId === expected.bindingId &&
227
230
  artifact.invocation !== undefined &&
228
- sameInvocation(artifact.invocation, expected.invocation) &&
229
231
  expected.fingerprint !== undefined &&
230
232
  artifact.fingerprint === expected.fingerprint) {
231
233
  return;
@@ -306,6 +308,14 @@ export function schedulerBindingOrdinal(bindingId, logicalSource, invocation) {
306
308
  }
307
309
  return undefined;
308
310
  }
311
+ /** Whether some schedule ordinal on `ref` would digest to `nativeId` (mirrors schedulerBindingOrdinal). */
312
+ function workflowRefDigestsToNativeId(ref, nativeId) {
313
+ for (let ordinal = 0; ordinal < 4096; ordinal += 1) {
314
+ if (digestBindingId("workflow", ref, ordinal) === nativeId)
315
+ return true;
316
+ }
317
+ return false;
318
+ }
309
319
  function digestBindingId(kind, ref, ordinal) {
310
320
  const prefix = kind === "workflow" ? "wf" : "task";
311
321
  const digest = createHash("sha256")
@@ -0,0 +1,45 @@
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
+ * Project a set of scheduler sync operations into a preview report. Pure:
6
+ * no I/O, no backend calls. Accepts a bare operation array (not the whole
7
+ * `SchedulerSyncPlan`) so a future `task prune` can build a preview from
8
+ * its own remove-only operation set without needing a full sync plan.
9
+ */
10
+ export function renderSchedulerPlanPreview(backend, operations, unchanged = [], failures = []) {
11
+ const adds = [];
12
+ const updates = [];
13
+ const removes = [];
14
+ for (const operation of operations) {
15
+ if (operation.kind === "remove") {
16
+ removes.push({
17
+ id: operation.id,
18
+ kind: "remove",
19
+ nativeId: operation.nativeId,
20
+ ...(operation.ownerBundlePath !== undefined ? { ownerBundlePath: operation.ownerBundlePath } : {}),
21
+ ...(operation.reason !== undefined ? { reason: operation.reason } : {}),
22
+ });
23
+ }
24
+ else if (operation.kind === "install") {
25
+ adds.push({ id: operation.binding.id, kind: "install" });
26
+ }
27
+ else {
28
+ updates.push({ id: operation.binding.id, kind: "update" });
29
+ }
30
+ }
31
+ return Object.freeze({
32
+ backend,
33
+ dryRun: true,
34
+ adds: Object.freeze(adds),
35
+ updates: Object.freeze(updates),
36
+ removes: Object.freeze(removes),
37
+ unchanged: Object.freeze([...unchanged]),
38
+ hasRemovals: removes.length > 0,
39
+ failures: Object.freeze([...failures]),
40
+ });
41
+ }
42
+ /** Convenience wrapper for the common case: previewing a whole {@link SchedulerSyncPlan}. */
43
+ export function renderSchedulerSyncPlanPreview(backend, plan) {
44
+ return renderSchedulerPlanPreview(backend, plan.operations, plan.unchanged, plan.failures);
45
+ }
@@ -17,7 +17,7 @@ import { WorkflowSourceCollisionError, WorkflowSourceNameError, WorkflowSourceRe
17
17
  import { compileWorkflowSource } from "../workflows/source-ir/compile.js";
18
18
  import { prepareTaskV3Execution } from "./prepare/prepare.js";
19
19
  import { parseSchedule } from "./schedule.js";
20
- import { assertSchedulerNativeArtifactCardinality, compileTaskSchedulerBindings, compileWorkflowSchedulerBindings, schedulerBindingNativeId, schedulerBindingOrdinal, schedulerNativeArtifactKey, schedulerNativeBindingId, } from "./scheduler-binding.js";
20
+ import { assertSchedulerNativeArtifactCardinality, compileTaskSchedulerBindings, compileWorkflowSchedulerBindings, schedulerBindingNativeId, schedulerBindingOrdinal, schedulerNativeArtifactKey, schedulerNativeArtifactOwner, schedulerNativeBindingId, } from "./scheduler-binding.js";
21
21
  import { parseTaskSource } from "./source/parse-task-source.js";
22
22
  import { projectTaskSourceV4 } from "./source/project-v4.js";
23
23
  import { taskSourceErrorDetail } from "./source-v3.js";
@@ -47,6 +47,7 @@ export async function prepareSchedulerSyncSourceSet(input) {
47
47
  desired: compiled.desired,
48
48
  sourceSnapshot,
49
49
  executableWorkflows: compiled.executableWorkflows,
50
+ failures: compiled.failures,
50
51
  });
51
52
  }
52
53
  export function finalizeSchedulerSyncPlan(input, prepared) {
@@ -108,34 +109,7 @@ export function finalizeSchedulerSyncPlan(input, prepared) {
108
109
  .sort(compareCodePoints);
109
110
  for (const id of removed) {
110
111
  const current = present.get(id);
111
- if (!current?.invocation) {
112
- throw nativeArtifactCollision({ nativeId: current?.nativeId ?? schedulerNativeBindingId(id), bindingId: id }, { nativeId: current?.nativeId ?? schedulerNativeBindingId(id) });
113
- }
114
- const nativeId = exactInstalledNativeId(id, current, inspection.artifacts);
115
- const artifact = inspection.artifacts.find((candidate) => candidate.nativeId === nativeId && candidate.bindingId === id);
116
- const priorFingerprint = current.signature ?? artifact?.fingerprint;
117
- if (!artifact || priorFingerprint === undefined) {
118
- throw new UsageError(`Installed scheduler binding ${JSON.stringify(id)} has no exact native fingerprint; refusing removal.`, "RESOURCE_ALREADY_EXISTS");
119
- }
120
- const logicalSource = installedLogicalSource(current.invocation, coherentInput);
121
- const ordinal = schedulerBindingOrdinal(id, logicalSource, current.invocation);
122
- if (ordinal === undefined) {
123
- throw new UsageError(`Installed scheduler binding ${JSON.stringify(id)} cannot be attributed to an exact schedule ordinal; refusing removal.`, "RESOURCE_ALREADY_EXISTS");
124
- }
125
- operations.push(Object.freeze({
126
- kind: "remove",
127
- id,
128
- nativeId,
129
- expected: freezeRemovalExpectation({
130
- state: "present",
131
- bindingId: id,
132
- nativeId,
133
- logicalSource,
134
- ordinal,
135
- invocation: current.invocation,
136
- fingerprint: priorFingerprint,
137
- }),
138
- }));
112
+ operations.push(buildSchedulerRemoveOperation(id, current, inspection.artifacts, coherentInput));
139
113
  }
140
114
  return Object.freeze({
141
115
  desired,
@@ -145,6 +119,49 @@ export function finalizeSchedulerSyncPlan(input, prepared) {
145
119
  unchanged: Object.freeze(unchanged),
146
120
  operations: Object.freeze(operations),
147
121
  sourceSnapshot: prepared.sourceSnapshot,
122
+ failures: prepared.failures,
123
+ });
124
+ }
125
+ /**
126
+ * Build the exact removal operation for one installed binding: same
127
+ * exact-native-fingerprint / ordinal-attribution safety checks
128
+ * `finalizeSchedulerSyncPlan`'s remove loop always applied, factored out so
129
+ * `akm task prune` (#851) can build removal operations for entries
130
+ * `belongsToBundle` structurally can't see (unresolvable ownership) without
131
+ * re-deriving — or weakening — this logic. Throws the same `UsageError`s a
132
+ * sync removal would on an inexact match; callers computing prune candidates
133
+ * should only pass entries they've already independently confirmed are safe
134
+ * to remove.
135
+ */
136
+ export function buildSchedulerRemoveOperation(id, current, artifacts, input) {
137
+ if (!current?.invocation) {
138
+ throw nativeArtifactCollision({ nativeId: current?.nativeId ?? schedulerNativeBindingId(id), bindingId: id }, { nativeId: current?.nativeId ?? schedulerNativeBindingId(id) });
139
+ }
140
+ const nativeId = exactInstalledNativeId(id, current, artifacts);
141
+ const artifact = artifacts.find((candidate) => candidate.nativeId === nativeId && candidate.bindingId === id);
142
+ const priorFingerprint = current.signature ?? artifact?.fingerprint;
143
+ if (!artifact || priorFingerprint === undefined) {
144
+ throw new UsageError(`Installed scheduler binding ${JSON.stringify(id)} has no exact native fingerprint; refusing removal.`, "RESOURCE_ALREADY_EXISTS");
145
+ }
146
+ const logicalSource = installedLogicalSource(current.invocation, input);
147
+ const ordinal = schedulerBindingOrdinal(id, logicalSource, current.invocation);
148
+ if (ordinal === undefined) {
149
+ throw new UsageError(`Installed scheduler binding ${JSON.stringify(id)} cannot be attributed to an exact schedule ordinal; refusing removal.`, "RESOURCE_ALREADY_EXISTS");
150
+ }
151
+ return Object.freeze({
152
+ kind: "remove",
153
+ id,
154
+ nativeId,
155
+ expected: freezeRemovalExpectation({
156
+ state: "present",
157
+ bindingId: id,
158
+ nativeId,
159
+ logicalSource,
160
+ ordinal,
161
+ invocation: current.invocation,
162
+ fingerprint: priorFingerprint,
163
+ }),
164
+ ...(current.ownerBundlePath !== undefined ? { ownerBundlePath: current.ownerBundlePath } : {}),
148
165
  });
149
166
  }
150
167
  function exactInstalledNativeId(logicalId, current, artifacts) {
@@ -189,10 +206,17 @@ export function assertSchedulerNativeArtifactOwnership(desired, installed) {
189
206
  const wanted = desiredByKey.get(key);
190
207
  if (!wanted)
191
208
  continue;
192
- if (artifact.nativeId !== schedulerBindingNativeId(wanted) ||
193
- artifact.bindingId !== wanted.id ||
194
- artifact.invocation === undefined ||
195
- !sameInvocation(artifact.invocation, wanted.invocation)) {
209
+ // Re-derive ownership from the artifact's own invocation content (not the
210
+ // caller-supplied `bindingId` label) so a proven owner whose invocation
211
+ // no longer matches the desired shape is an UPDATE, not a refusal — that
212
+ // reconciliation happens below in finalizeSchedulerSyncPlan. An artifact
213
+ // whose invocation content does not actually prove it belongs to
214
+ // `wanted` (unproven, malformed, or a different logical owner) is still
215
+ // a genuine collision.
216
+ const provenBindingId = artifact.invocation !== undefined
217
+ ? schedulerNativeArtifactOwner(artifact.nativeId, artifact.invocation)?.logicalId
218
+ : undefined;
219
+ if (artifact.nativeId !== schedulerBindingNativeId(wanted) || provenBindingId !== wanted.id) {
196
220
  throw nativeArtifactCollision(desiredArtifact(wanted), artifact);
197
221
  }
198
222
  }
@@ -251,12 +275,17 @@ async function compileDesiredSourceSet(input, collector) {
251
275
  const failures = [];
252
276
  await compileTaskSources(input, collector, bindings, failures);
253
277
  await compileWorkflowSources(input, collector, bindings, executableWorkflows, failures);
254
- if (failures.length > 0) {
255
- throw new UsageError(`Scheduler sync rejected the desired source set before mutation:\n${failures.map((failure) => `- ${failure}`).join("\n")}`, "TASK_SOURCE_INVALID");
256
- }
278
+ // Degrade, don't reject (#867): one source that fails to parse/prepare no
279
+ // longer poisons the whole desired set — it is dropped from `desired` and
280
+ // reported here instead, so every OTHER task/workflow still reconciles.
281
+ // Genuinely cross-cutting integrity violations (duplicate ids, native
282
+ // artifact ownership conflicts, an incoherent backend inspection) are
283
+ // asserted separately in `finalizeSchedulerSyncPlan` and still hard-fail
284
+ // the whole sync — this only relaxes the per-source parse/prepare gate.
257
285
  return Object.freeze({
258
286
  desired: Object.freeze(bindings),
259
287
  executableWorkflows: Object.freeze(executableWorkflows.sort((left, right) => compareCodePoints(left.ref, right.ref))),
288
+ failures: Object.freeze(failures.sort((left, right) => compareCodePoints(left.path, right.path))),
260
289
  });
261
290
  }
262
291
  async function compileTaskSources(input, collector, out, failures) {
@@ -268,6 +297,7 @@ async function compileTaskSources(input, collector, out, failures) {
268
297
  const relative = guarded.relativePath;
269
298
  const conceptId = relative.slice(0, -4);
270
299
  const id = input.adapterId === "akm-task" ? conceptId : path.basename(sourcePath, ".yml");
300
+ const qualifiedRefForFailure = makeBundleRef(input.bundleName, conceptId);
271
301
  try {
272
302
  const physicalIdentity = guarded.physicalIdentity;
273
303
  const priorOwner = physicalOwners.get(physicalIdentity);
@@ -355,7 +385,7 @@ async function compileTaskSources(input, collector, out, failures) {
355
385
  }
356
386
  }
357
387
  catch (cause) {
358
- failures.push(taskFailure(sourcePath, cause));
388
+ failures.push(taskFailure(sourcePath, qualifiedRefForFailure, cause));
359
389
  }
360
390
  }
361
391
  }
@@ -364,6 +394,8 @@ async function compileWorkflowSources(input, collector, out, evidence, failures)
364
394
  return;
365
395
  const lookups = enumerateWorkflowLookups(input, collector, failures);
366
396
  for (const [canonicalName, sources] of lookups) {
397
+ const failurePath = sources[0]?.sourcePath ?? canonicalName;
398
+ const failureRef = makeBundleRef(input.bundleName, input.adapterId === "akm" ? `workflows/${canonicalName}` : canonicalName);
367
399
  try {
368
400
  if (sources.length > 1) {
369
401
  throw new WorkflowSourceCollisionError(input.adapterId === "akm" ? `workflows/${canonicalName}` : canonicalName, sources.map((source) => source.relativePath));
@@ -426,7 +458,7 @@ async function compileWorkflowSources(input, collector, out, evidence, failures)
426
458
  }
427
459
  }
428
460
  catch (cause) {
429
- failures.push(errorMessage(cause));
461
+ failures.push(workflowFailure(failurePath, failureRef, cause));
430
462
  }
431
463
  }
432
464
  }
@@ -455,7 +487,7 @@ function enumerateWorkflowLookups(input, collector, failures) {
455
487
  const stem = authoredName.slice(0, -extension.length).toLowerCase();
456
488
  const nestedSuffix = WORKFLOW_EXTENSIONS.find((suffix) => stem.endsWith(suffix));
457
489
  if (nestedSuffix) {
458
- failures.push(errorMessage(new WorkflowSourceNameError(guarded.relativePath, nestedSuffix)));
490
+ failures.push(workflowFailure(sourcePath, undefined, new WorkflowSourceNameError(guarded.relativePath, nestedSuffix)));
459
491
  continue;
460
492
  }
461
493
  const canonicalName = canonicalizeWorkflowName(authoredName);
@@ -528,9 +560,13 @@ function assertUniqueInstalledIds(installed) {
528
560
  seen.add(binding.id);
529
561
  }
530
562
  }
531
- function taskFailure(file, cause) {
563
+ function taskFailure(file, ref, cause) {
532
564
  const detail = taskSourceErrorDetail(cause);
533
- return detail === errorMessage(cause) ? `${file}: ${detail}` : detail;
565
+ const reason = detail === errorMessage(cause) ? `${file}: ${detail}` : detail;
566
+ return Object.freeze({ path: file, ref, reason });
567
+ }
568
+ function workflowFailure(file, ref, cause) {
569
+ return Object.freeze({ path: file, ...(ref ? { ref } : {}), reason: errorMessage(cause) });
534
570
  }
535
571
  function errorMessage(cause) {
536
572
  return cause instanceof Error ? cause.message : String(cause);
@@ -392,7 +392,7 @@ export function assertBoundedTaskYamlDocument(document, options) {
392
392
  * P4 (docs/plans/specs/p4-deletions-closeout.md §3.2) deleted task v3
393
393
  * acceptance from `src` entirely — `parseTaskV3Yaml` and its `"task v3
394
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`
395
+ * vendored, frozen `src/tasks/source/task-source-v3-frozen.ts`
396
396
  * copy, which does not call this function). The two live `src` callers
397
397
  * today: `parseTaskSourceV4`'s standalone YAML-string entry
398
398
  * (`task-source-v4.ts:790`) passes `sourceLabel: "task source v4"`; the
@@ -7,14 +7,17 @@
7
7
  *
8
8
  * Runs the bounded YAML front end ONCE (`readBoundedTaskSourceYaml`), reads
9
9
  * `root.version`, and either dispatches into `parseTaskSourceV4Document` or
10
- * fails closed — no second parse, no re-serialization, no synthetic document
11
- * (the P1b §4.3 invariant this phase carries forward).
10
+ * routes through the in-memory read shim below — no second parse of the v4
11
+ * grammar itself, no re-serialization back to disk, no synthetic document
12
+ * (the P1b §4.3 invariant this phase carries forward: the shim adds a pure
13
+ * bytes-in/bytes-out detour, never a disk write).
12
14
  *
13
15
  * The terminal routing table:
14
16
  *
15
17
  * | root `version` | outcome |
16
18
  * |------------------------|-----------------------------------------------------------------|
17
19
  * | `4` | `parseTaskSourceV4Document` — the new grammar (row B-13) |
20
+ * | `2` or `3` | in-memory read shim (below): the SAME pure planners `akm migrate apply` uses (`./task-to-v3.ts`, `./task-to-v4.ts`) convert the bytes already in hand to v4 in memory; the result is parsed and returned with a one-line stderr deprecation warning. If the deterministic conversion itself fails (an unmigratable shape), falls back to `TASK_SCHEMA_VERSION_UNSUPPORTED`, naming the migrator (B-14/B-15) — the shim removes friction for the deterministic case, it never hides a real problem |
18
21
  * | any other number | `TASK_SCHEMA_VERSION_UNSUPPORTED`, naming the migrator (B-14/B-15) |
19
22
  * | absent / not a number | `parseTaskSourceV4Document` — its own `TASK_SOURCE_INVALID` "version is required and must be 4" / "must be exactly 4" wording (row B-16) |
20
23
  *
@@ -24,17 +27,28 @@
24
27
  * message that would send the user to the migrator for a document that was
25
28
  * never task v2 or v3 in the first place.
26
29
  *
27
- * task v2 and task v3 sources are no longer read by `src` at all — the only
28
- * surviving reader is the frozen, vendored copy in
29
- * `scripts/akm-migrate/migrate/task-source-v3-frozen.ts`, reachable only
30
- * through `akm migrate apply` / `akm-migrate`. The front end's own
31
- * pre-version failures (source not a string, source too large, YAML
32
- * parse/warning/expansion) render with the label `task source` (row B-17,
33
- * closing the "task v3 source" label wart P2a's §3.4 recorded).
30
+ * task v2 and task v3 sources are no longer read as their own standing
31
+ * grammar anywhere else in `src` — the only readers of that grammar are the
32
+ * pure, byte-producing planners (`./task-to-v3.ts`, `./task-to-v4.ts`, and
33
+ * the frozen v3 reader `./task-source-v3-frozen.ts`), reached either through
34
+ * this shim (bytes in, bytes out, never touches disk) or through
35
+ * `akm migrate apply` / `akm-migrate` (`scripts/akm-migrate`, which
36
+ * additionally rewrites the file on disk once the user asks for that).
37
+ * Policy: a deterministic byte transform is the tool's job, not the user's —
38
+ * upgrading past a schema bump must not silently break a scheduled task, so
39
+ * v2/v3 files keep reading successfully at the cost of a one-line
40
+ * deprecation warning, and `akm migrate apply` remains available to rewrite
41
+ * the file and silence it. The front end's own pre-version failures (source
42
+ * not a string, source too large, YAML parse/warning/expansion) render with
43
+ * the label `task source` (row B-17, closing the "task v3 source" label
44
+ * wart P2a's §3.4 recorded).
34
45
  */
35
46
  import { UsageError } from "../../core/errors.js";
47
+ import { warn } from "../../core/warn.js";
36
48
  import { readBoundedTaskSourceYaml } from "./bounded-document.js";
37
- import { parseTaskSourceV4Document, TASK_SOURCE_V4_VERSION } from "./task-source-v4.js";
49
+ import { parseTaskSourceV4, parseTaskSourceV4Document, TASK_SOURCE_V4_VERSION, } from "./task-source-v4.js";
50
+ import { planTaskToV3File } from "./task-to-v3.js";
51
+ import { planTaskToV4File } from "./task-to-v4.js";
38
52
  /** Read the root `version` field without over-accepting non-number values (e.g. the string `"4"`). */
39
53
  export function peekTaskSourceVersion(root) {
40
54
  if (root === null || typeof root !== "object" || Array.isArray(root))
@@ -43,12 +57,64 @@ export function peekTaskSourceVersion(root) {
43
57
  return typeof value === "number" ? value : undefined;
44
58
  }
45
59
  const TASK_MIGRATE_HINT = "Run `akm migrate apply --dry-run` to preview the task-v3 to task-source-v4 conversion, then run `akm migrate apply`.";
60
+ function unsupportedVersionError(filePath, version) {
61
+ return new UsageError(`TASK_SCHEMA_VERSION_UNSUPPORTED: Task at ${filePath} uses task schema version ${version}, which this release does not accept.`, "TASK_SCHEMA_VERSION_UNSUPPORTED", TASK_MIGRATE_HINT);
62
+ }
63
+ /**
64
+ * Plan the SAME bytes already in hand through the pure v3->v4 (and, for v2,
65
+ * chained v2->v3->v4) migration planner(s) — never touches disk, never
66
+ * writes the file, never re-reads it from disk. Returns the produced v4
67
+ * YAML text, or `undefined` when the deterministic conversion cannot
68
+ * proceed (an unmigratable v2/v3 shape) — the caller falls back to the same
69
+ * hard error this gate threw before the shim existed.
70
+ */
71
+ function planInMemoryV4Bytes(version, yaml, filePath, workspaceRoot) {
72
+ const bytes = Buffer.from(yaml, "utf8");
73
+ // `writable`/`onDiskWritable` gate the DISK apply path's "don't touch a
74
+ // read-only file" check inside the planners; this shim never writes
75
+ // anything to disk, so that check does not apply here and must not block
76
+ // an otherwise-legal read of a task file that happens to be read-only.
77
+ const baseInput = {
78
+ filePath,
79
+ bytes,
80
+ mode: 0o644,
81
+ writable: true,
82
+ onDiskWritable: true,
83
+ ...(workspaceRoot ? { containmentRoot: workspaceRoot } : {}),
84
+ };
85
+ let v3Bytes;
86
+ if (version === 3) {
87
+ v3Bytes = bytes;
88
+ }
89
+ else {
90
+ const v3Outcome = planTaskToV3File(baseInput);
91
+ if (v3Outcome.status !== "changed")
92
+ return undefined;
93
+ v3Bytes = v3Outcome.after;
94
+ }
95
+ const v4Outcome = planTaskToV4File({ ...baseInput, bytes: v3Bytes });
96
+ if (v4Outcome.status !== "changed")
97
+ return undefined;
98
+ return v4Outcome.after.toString("utf8");
99
+ }
46
100
  /** Parse task source YAML, routing per the terminal table above. */
47
101
  export function parseTaskSource(input) {
48
102
  const { root, lineAt } = readBoundedTaskSourceYaml(input, { sourceLabel: "task source" });
49
103
  const version = peekTaskSourceVersion(root);
50
104
  if (version !== undefined && version !== TASK_SOURCE_V4_VERSION) {
51
- throw new UsageError(`TASK_SCHEMA_VERSION_UNSUPPORTED: Task at ${input.filePath} uses task schema version ${version}, which this release does not accept.`, "TASK_SCHEMA_VERSION_UNSUPPORTED", TASK_MIGRATE_HINT);
105
+ if (version === 2 || version === 3) {
106
+ const v4Yaml = planInMemoryV4Bytes(version, input.yaml, input.filePath, input.workspaceRoot);
107
+ if (v4Yaml !== undefined) {
108
+ const v4 = parseTaskSourceV4({
109
+ yaml: v4Yaml,
110
+ filePath: input.filePath,
111
+ ...(input.workspaceRoot ? { workspaceRoot: input.workspaceRoot } : {}),
112
+ });
113
+ warn(`akm: task ${input.filePath} uses schema v${version} — auto-read as v4; run \`akm migrate apply\` to rewrite it and silence this`);
114
+ return Object.freeze({ version: 4, v4 });
115
+ }
116
+ }
117
+ throw unsupportedVersionError(input.filePath, version);
52
118
  }
53
119
  const documentOptions = {
54
120
  filePath: input.filePath,