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.
- package/CHANGELOG.md +233 -1
- package/README.md +1 -1
- package/SECURITY.md +1 -1
- package/STABILITY.md +1 -1
- package/dist/akm +2 -2
- package/dist/akm-migrate +2 -2
- package/dist/cli.js +5 -5
- package/dist/commands/health/improve-metrics.js +17 -0
- package/dist/commands/health/windows.js +2 -2
- package/dist/commands/health.js +2 -2
- package/dist/commands/improve/anti-collapse.js +4 -91
- package/dist/commands/improve/preparation.js +8 -1
- package/dist/commands/lint/index.js +3 -7
- package/dist/commands/proposal/validators/proposal-validators.js +12 -0
- package/dist/commands/read/search.js +14 -24
- package/dist/commands/tasks/tasks-cli.js +81 -3
- package/dist/commands/tasks/tasks.js +117 -2
- package/dist/core/adapter/adapters/akm-adapter.js +23 -14
- package/dist/core/adapter/adapters/akm-lint.js +3 -2
- package/dist/core/adapter/adapters/akm-task-adapter.js +9 -6
- package/dist/core/adapter/adapters/dotenv-adapter.js +13 -11
- package/dist/core/adapter/recognize-match.js +1 -20
- package/dist/core/asset/asset-placement.js +21 -2
- package/dist/core/common.js +21 -1
- package/dist/core/config/config-version-shim.js +101 -0
- package/dist/core/config/config.js +6 -6
- package/dist/core/improve-result.js +35 -14
- package/dist/execution/guarded-source.js +0 -10
- package/dist/indexer/lookup/adapter-concept-owner.js +6 -89
- package/dist/indexer/passes/metadata.js +12 -4
- package/dist/indexer/scan/doc-to-entry.js +2 -0
- package/dist/indexer/search/db-search.js +6 -0
- package/dist/indexer/search/search-fields.js +16 -1
- package/dist/indexer/walk/matchers.js +0 -22
- package/dist/output/shapes/helpers.js +19 -1
- package/dist/output/shapes/passthrough.js +18 -5
- package/dist/output/text/command-format.js +4 -0
- package/dist/registry/pinned-request-helper.js +2 -2
- package/dist/registry/pinned-transport.js +6 -6
- package/dist/scripts/akm-migrate-node.js +12678 -12601
- package/dist/scripts/akm-migrate.js +12678 -12601
- package/dist/storage/repositories/proposals-repository.js +65 -7
- package/dist/storage/repositories/task-history-repository.js +22 -10
- package/dist/tasks/run/task-history.js +23 -3
- package/dist/tasks/scheduler-binding.js +15 -5
- package/dist/tasks/scheduler-sync-preview.js +45 -0
- package/dist/tasks/scheduler-sync.js +77 -41
- package/dist/tasks/source/bounded-document.js +1 -1
- package/dist/tasks/source/parse-task-source.js +77 -11
- package/dist/tasks/source/task-source-v3-frozen.js +428 -0
- package/dist/tasks/source/task-to-v3.js +512 -0
- package/dist/tasks/source/task-to-v4.js +457 -0
- package/docs/reference/cli.md +33 -6
- package/docs/reference/configuration.md +27 -6
- package/docs/reference/tasks.md +10 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
68
|
+
validateDetail(detail);
|
|
57
69
|
return {
|
|
58
70
|
metadataVersion: 2,
|
|
59
71
|
durationMs: parsed.durationMs,
|
|
60
|
-
detail:
|
|
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)
|
|
82
|
+
return decodeTaskHistoryRows(getTaskHistoryRuns(db, options.id, options.limit));
|
|
83
83
|
}
|
|
84
84
|
const row = getTaskHistory(db, options.id);
|
|
85
|
-
return row ? [
|
|
85
|
+
return row ? decodeTaskHistoryRows([row]) : [];
|
|
86
86
|
}
|
|
87
|
-
return queryTaskHistory(db, options.limit !== undefined && options.limit > 0 ? { limit: options.limit } : {})
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 `
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
|
|
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,
|