nx 23.2.1 → 23.3.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/command-line/format/format.js +2 -18
- package/dist/src/command-line/graph/graph.js +42 -3
- package/dist/src/command-line/init/implementation/format.js +2 -3
- package/dist/src/command-line/migrate/agentic/definitions.js +75 -19
- package/dist/src/command-line/migrate/agentic/format-command.d.ts +14 -0
- package/dist/src/command-line/migrate/agentic/format-command.js +44 -0
- package/dist/src/command-line/migrate/agentic/handoff-gitignore.d.ts +3 -3
- package/dist/src/command-line/migrate/agentic/handoff-gitignore.js +3 -3
- package/dist/src/command-line/migrate/agentic/handoff.d.ts +27 -17
- package/dist/src/command-line/migrate/agentic/handoff.js +107 -24
- package/dist/src/command-line/migrate/agentic/instruction-files.d.ts +21 -0
- package/dist/src/command-line/migrate/agentic/instruction-files.js +36 -0
- package/dist/src/command-line/migrate/agentic/prompts/fragments.d.ts +16 -0
- package/dist/src/command-line/migrate/agentic/prompts/fragments.js +70 -0
- package/dist/src/command-line/migrate/agentic/prompts/generic-validation.js +4 -3
- package/dist/src/command-line/migrate/agentic/prompts/system-prompt.d.ts +25 -1
- package/dist/src/command-line/migrate/agentic/prompts/system-prompt.js +57 -34
- package/dist/src/command-line/migrate/agentic/run-step.js +28 -4
- package/dist/src/command-line/migrate/agentic/runner.d.ts +17 -8
- package/dist/src/command-line/migrate/agentic/runner.js +110 -55
- package/dist/src/command-line/migrate/agentic/types.d.ts +23 -8
- package/dist/src/command-line/migrate/agentic/types.js +10 -7
- package/dist/src/command-line/migrate/migrate-commits.d.ts +1 -0
- package/dist/src/command-line/migrate/migrate-commits.js +9 -0
- package/dist/src/command-line/migrate/migrate-output.d.ts +3 -7
- package/dist/src/command-line/migrate/migrate-output.js +3 -7
- package/dist/src/command-line/migrate/migrate.js +21 -8
- package/dist/src/command-line/migrate/run/agent-output.d.ts +10 -0
- package/dist/src/command-line/migrate/run/agent-output.js +16 -2
- package/dist/src/command-line/migrate/run/agent-work-payload.d.ts +37 -0
- package/dist/src/command-line/migrate/run/agent-work-payload.js +142 -0
- package/dist/src/command-line/migrate/run/atomic-write.d.ts +8 -0
- package/dist/src/command-line/migrate/run/atomic-write.js +17 -0
- package/dist/src/command-line/migrate/run/index.d.ts +1 -1
- package/dist/src/command-line/migrate/run/issues.d.ts +106 -0
- package/dist/src/command-line/migrate/run/issues.js +980 -0
- package/dist/src/command-line/migrate/run/orchestrator.d.ts +1 -0
- package/dist/src/command-line/migrate/run/orchestrator.js +662 -105
- package/dist/src/command-line/migrate/run/run-state.d.ts +43 -8
- package/dist/src/command-line/migrate/run/run-state.js +184 -20
- package/dist/src/command-line/migrate/run/runbook.d.ts +17 -0
- package/dist/src/command-line/migrate/run/runbook.js +97 -0
- package/dist/src/command-line/migrate/run/state-lock.d.ts +2 -2
- package/dist/src/command-line/migrate/run/state-lock.js +2 -2
- package/dist/src/command-line/migrate/run/state-machine.d.ts +5 -1
- package/dist/src/command-line/migrate/run/state-machine.js +27 -1
- package/dist/src/command-line/migrate/run/worker.js +206 -56
- package/dist/src/command-line/nx-cloud/connect/connect-to-nx-cloud.js +5 -1
- package/dist/src/command-line/release/publish.js +11 -4
- package/dist/src/command-line/release/utils/remote-release-clients/github.js +3 -3
- package/dist/src/command-line/release/utils/remote-release-clients/gitlab.js +3 -3
- package/dist/src/command-line/release/utils/remote-release-clients/remote-release-client.d.ts +2 -0
- package/dist/src/command-line/release/utils/remote-release-clients/remote-release-client.js +35 -0
- package/dist/src/command-line/yargs-utils/shared-options.d.ts +1 -1
- package/dist/src/command-line/yargs-utils/shared-options.js +22 -4
- package/dist/src/config/workspace-json-project-json.d.ts +2 -0
- package/dist/src/core/graph/main.js +1 -1
- package/dist/src/daemon/client/client.d.ts +4 -2
- package/dist/src/daemon/client/client.js +20 -11
- package/dist/src/daemon/message-types/run-tasks-execution-hooks.d.ts +3 -2
- package/dist/src/daemon/server/handle-hash-tasks.d.ts +8 -2
- package/dist/src/daemon/server/handle-hash-tasks.js +16 -3
- package/dist/src/daemon/server/handle-outputs-changes.d.ts +2 -2
- package/dist/src/daemon/server/handle-outputs-changes.js +21 -6
- package/dist/src/daemon/server/handle-tasks-execution-hooks.d.ts +3 -2
- package/dist/src/daemon/server/outputs-tracking.d.ts +6 -0
- package/dist/src/daemon/server/outputs-tracking.js +18 -0
- package/dist/src/daemon/server/project-graph-incremental-recomputation.d.ts +22 -7
- package/dist/src/daemon/server/project-graph-incremental-recomputation.js +99 -69
- package/dist/src/daemon/server/restart-checks.d.ts +22 -0
- package/dist/src/daemon/server/restart-checks.js +77 -0
- package/dist/src/daemon/server/server.js +28 -18
- package/dist/src/daemon/server/shutdown-utils.d.ts +0 -5
- package/dist/src/daemon/server/shutdown-utils.js +3 -26
- package/dist/src/hasher/hash-task.js +20 -16
- package/dist/src/hasher/native-task-hasher-impl.d.ts +8 -0
- package/dist/src/hasher/native-task-hasher-impl.js +46 -3
- package/dist/src/hasher/task-hasher.d.ts +9 -0
- package/dist/src/hasher/task-hasher.js +14 -0
- package/dist/src/native/index.d.ts +202 -21
- package/dist/src/native/index.js +3 -3
- package/dist/src/native/native-bindings.js +3 -1
- package/dist/src/native/nx.wasm32-wasi.debug.wasm +0 -0
- package/dist/src/native/nx.wasm32-wasi.wasm +0 -0
- package/dist/src/plugins/js/lock-file/pnpm-parser.js +1 -1
- package/dist/src/plugins/js/lock-file/utils/pnpm-normalizer.d.ts +2 -0
- package/dist/src/plugins/js/lock-file/utils/pnpm-normalizer.js +22 -0
- package/dist/src/plugins/js/utils/register.js +2 -1
- package/dist/src/project-graph/plugins/isolation/isolated-plugin.js +2 -1
- package/dist/src/project-graph/plugins/isolation/messaging.d.ts +3 -2
- package/dist/src/project-graph/plugins/isolation/plugin-worker.js +5 -1
- package/dist/src/project-graph/plugins/loaded-nx-plugin.d.ts +3 -2
- package/dist/src/project-graph/plugins/loaded-nx-plugin.js +5 -1
- package/dist/src/project-graph/plugins/task-results-stub.d.ts +50 -0
- package/dist/src/project-graph/plugins/task-results-stub.js +111 -0
- package/dist/src/project-graph/plugins/tasks-execution-hooks.d.ts +3 -2
- package/dist/src/project-graph/plugins/tasks-execution-hooks.js +2 -1
- package/dist/src/project-graph/project-graph.js +2 -0
- package/dist/src/tasks-runner/batch/run-batch.js +15 -0
- package/dist/src/tasks-runner/cache.d.ts +35 -0
- package/dist/src/tasks-runner/cache.js +40 -0
- package/dist/src/tasks-runner/create-task-graph.js +1 -0
- package/dist/src/tasks-runner/default-tasks-runner.js +1 -1
- package/dist/src/tasks-runner/forked-process-task-runner.d.ts +1 -1
- package/dist/src/tasks-runner/forked-process-task-runner.js +8 -7
- package/dist/src/tasks-runner/init-tasks-runner.js +5 -1
- package/dist/src/tasks-runner/is-tui-enabled.js +4 -3
- package/dist/src/tasks-runner/life-cycle.d.ts +7 -0
- package/dist/src/tasks-runner/life-cycle.js +7 -0
- package/dist/src/tasks-runner/life-cycles/summary-terminal-output-life-cycle.d.ts +35 -0
- package/dist/src/tasks-runner/life-cycles/summary-terminal-output-life-cycle.js +146 -0
- package/dist/src/tasks-runner/prune-task-graph.d.ts +17 -0
- package/dist/src/tasks-runner/prune-task-graph.js +45 -0
- package/dist/src/tasks-runner/run-command.js +14 -3
- package/dist/src/tasks-runner/running-tasks/batch-process.d.ts +93 -19
- package/dist/src/tasks-runner/running-tasks/batch-process.js +184 -88
- package/dist/src/tasks-runner/task-orchestrator.d.ts +64 -2
- package/dist/src/tasks-runner/task-orchestrator.js +232 -46
- package/dist/src/tasks-runner/terminal-output-path.d.ts +11 -0
- package/dist/src/tasks-runner/terminal-output-path.js +18 -0
- package/dist/src/tasks-runner/utils.js +5 -2
- package/dist/src/utils/ab-testing.d.ts +3 -0
- package/dist/src/utils/ab-testing.js +11 -3
- package/dist/src/utils/chunkify.d.ts +1 -7
- package/dist/src/utils/chunkify.js +2 -8
- package/dist/src/utils/command-line-utils.d.ts +19 -0
- package/dist/src/utils/command-line-utils.js +10 -3
- package/dist/src/utils/formatters/index.d.ts +6 -0
- package/dist/src/utils/formatters/index.js +5 -0
- package/dist/src/utils/formatters/prettier.d.ts +0 -8
- package/dist/src/utils/formatters/prettier.js +4 -26
- package/dist/src/utils/installed-nx-version.js +2 -48
- package/dist/src/utils/min-release-age/packument.js +1 -1
- package/dist/src/utils/output.d.ts +22 -3
- package/dist/src/utils/output.js +28 -5
- package/dist/src/utils/package-json.d.ts +25 -0
- package/dist/src/utils/package-json.js +41 -6
- package/dist/src/utils/package-manager.d.ts +6 -0
- package/dist/src/utils/package-manager.js +10 -0
- package/dist/src/utils/provenance.js +20 -4
- package/dist/src/utils/workspace-context.d.ts +47 -3
- package/dist/src/utils/workspace-context.js +162 -10
- package/package.json +12 -12
- package/schemas/nx-schema.json +4 -0
- package/schemas/project-schema.json +4 -0
- package/dist/src/daemon/server/file-watching/route-workspace-changes.d.ts +0 -9
- package/dist/src/daemon/server/file-watching/route-workspace-changes.js +0 -76
- package/dist/src/daemon/server/watcher.d.ts +0 -18
- package/dist/src/daemon/server/watcher.js +0 -126
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The run's issue ledger. Agents supply only the handoff's `issues` /
|
|
3
|
+
// `issueUpdates`; ids, fingerprints, routing, claims and archiving are nx's.
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.issueFingerprint = void 0;
|
|
6
|
+
exports.parseHandoffIssues = parseHandoffIssues;
|
|
7
|
+
exports.applyReportedIssues = applyReportedIssues;
|
|
8
|
+
exports.issueIdsForCommit = issueIdsForCommit;
|
|
9
|
+
exports.attachIssueIdsToCommitEntry = attachIssueIdsToCommitEntry;
|
|
10
|
+
exports.reopenResolutionsForStep = reopenResolutionsForStep;
|
|
11
|
+
exports.claimIssuesForStep = claimIssuesForStep;
|
|
12
|
+
exports.settleUnclaimableIssues = settleUnclaimableIssues;
|
|
13
|
+
exports.renderIssueDigestLines = renderIssueDigestLines;
|
|
14
|
+
exports.renderUnresolvedIssueLines = renderUnresolvedIssueLines;
|
|
15
|
+
exports.issueArchivePath = issueArchivePath;
|
|
16
|
+
exports.archiveIssues = archiveIssues;
|
|
17
|
+
exports.applicationArchivesIntact = applicationArchivesIntact;
|
|
18
|
+
const fs_1 = require("fs");
|
|
19
|
+
const path_1 = require("path");
|
|
20
|
+
const fileutils_1 = require("../../../utils/fileutils");
|
|
21
|
+
const atomic_write_1 = require("./atomic-write");
|
|
22
|
+
const types_1 = require("../agentic/types");
|
|
23
|
+
const text_1 = require("../text");
|
|
24
|
+
const run_state_1 = require("./run-state");
|
|
25
|
+
Object.defineProperty(exports, "issueFingerprint", { enumerable: true, get: function () { return run_state_1.issueFingerprint; } });
|
|
26
|
+
const state_machine_1 = require("./state-machine");
|
|
27
|
+
const ISSUES_DIR_NAME = 'issues';
|
|
28
|
+
// Bounds on one agent-written handoff's issue report. A report over a bound is
|
|
29
|
+
// rejected whole for the agent to fix, never trimmed.
|
|
30
|
+
const MAX_ISSUES_PER_HANDOFF = 20;
|
|
31
|
+
const MAX_ISSUE_UPDATES_PER_HANDOFF = 20;
|
|
32
|
+
const MAX_SUMMARY_CHARS = 500;
|
|
33
|
+
const MAX_DETAIL_CHARS = 8192;
|
|
34
|
+
const MAX_NOTE_CHARS = 1000;
|
|
35
|
+
const MAX_APPLICABLE_MIGRATIONS = 50;
|
|
36
|
+
// Bounds on the digest rendered into dispensed steps. Overflow here is
|
|
37
|
+
// truncated or counted, never rejected the way an over-bound report is. The
|
|
38
|
+
// byte bound covers the whole serialized digest, fixed lines included.
|
|
39
|
+
const MAX_DIGEST_ENTRIES = 20;
|
|
40
|
+
const MAX_DIGEST_BYTES = 8192;
|
|
41
|
+
const DIGEST_SUMMARY_CHARS = 200;
|
|
42
|
+
function invalid(reason) {
|
|
43
|
+
return { ok: false, reason };
|
|
44
|
+
}
|
|
45
|
+
function isPlainObject(value) {
|
|
46
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
47
|
+
}
|
|
48
|
+
// Unknown keys reject rather than strip: a typoed optional ("dispositon")
|
|
49
|
+
// would otherwise fall back to its default and mis-route the issue silently.
|
|
50
|
+
function unknownKey(value, allowed) {
|
|
51
|
+
return Object.keys(value).find((key) => !allowed.includes(key));
|
|
52
|
+
}
|
|
53
|
+
// Collapsed and truncated because the value is agent-written and the rejection
|
|
54
|
+
// reason is rendered into dispensed step content.
|
|
55
|
+
function quoted(value) {
|
|
56
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
57
|
+
const collapsed = (0, text_1.singleLine)(String(text));
|
|
58
|
+
return JSON.stringify(collapsed.length > 100 ? `${collapsed.slice(0, 100)}...` : collapsed);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Validates a handoff's `issues` / `issueUpdates` against the run's current
|
|
62
|
+
* state. Absent fields parse as empty; any invalid entry rejects the whole
|
|
63
|
+
* report with a reason the awaiting dispense hands back to the agent. Also
|
|
64
|
+
* rejects unrecognized top-level handoff fields: a misspelled "issueUpates"
|
|
65
|
+
* would otherwise read as an absent report and be discarded with the handoff.
|
|
66
|
+
*/
|
|
67
|
+
function parseHandoffIssues(extras, state, reportingStep) {
|
|
68
|
+
if (extras !== undefined) {
|
|
69
|
+
const unknown = unknownKey(extras, ['outcome', 'issues', 'issueUpdates']);
|
|
70
|
+
if (unknown !== undefined) {
|
|
71
|
+
return invalid(`the handoff has an unrecognized field ${quoted(unknown)}; the fields accepted next to "status" and "summary" are "outcome", "issues" and "issueUpdates"`);
|
|
72
|
+
}
|
|
73
|
+
if (extras['outcome'] !== undefined && extras['outcome'] !== 'skipped') {
|
|
74
|
+
return invalid(`the handoff has an unrecognized "outcome" value ${quoted(extras['outcome'])}; the only accepted value is "skipped"`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const rawIssues = extras?.['issues'];
|
|
78
|
+
const rawUpdates = extras?.['issueUpdates'];
|
|
79
|
+
const issues = [];
|
|
80
|
+
const updates = [];
|
|
81
|
+
if (rawIssues !== undefined) {
|
|
82
|
+
if (!Array.isArray(rawIssues)) {
|
|
83
|
+
return invalid(`the "issues" field must be an array`);
|
|
84
|
+
}
|
|
85
|
+
if (rawIssues.length > MAX_ISSUES_PER_HANDOFF) {
|
|
86
|
+
return invalid(`the "issues" field lists ${rawIssues.length} entries; at most ${MAX_ISSUES_PER_HANDOFF} are accepted per handoff`);
|
|
87
|
+
}
|
|
88
|
+
const seenFingerprints = new Set();
|
|
89
|
+
for (let i = 0; i < rawIssues.length; i++) {
|
|
90
|
+
const parsed = parseReportedIssue(rawIssues[i], i, state);
|
|
91
|
+
if (typeof parsed === 'string')
|
|
92
|
+
return invalid(parsed);
|
|
93
|
+
// Rejected rather than folded: array order would decide which disposition
|
|
94
|
+
// lands, since the second entry folds into the first's just-minted one.
|
|
95
|
+
const fingerprint = (0, run_state_1.issueFingerprint)(parsed.summary);
|
|
96
|
+
if (seenFingerprints.has(fingerprint)) {
|
|
97
|
+
return invalid(`"issues"[${i}] repeats an earlier entry's summary; report each problem once, with its final disposition`);
|
|
98
|
+
}
|
|
99
|
+
seenFingerprints.add(fingerprint);
|
|
100
|
+
issues.push(parsed);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (rawUpdates !== undefined) {
|
|
104
|
+
if (!Array.isArray(rawUpdates)) {
|
|
105
|
+
return invalid(`the "issueUpdates" field must be an array`);
|
|
106
|
+
}
|
|
107
|
+
if (rawUpdates.length > MAX_ISSUE_UPDATES_PER_HANDOFF) {
|
|
108
|
+
return invalid(`the "issueUpdates" field lists ${rawUpdates.length} entries; at most ${MAX_ISSUE_UPDATES_PER_HANDOFF} are accepted per handoff`);
|
|
109
|
+
}
|
|
110
|
+
const seenIds = new Set();
|
|
111
|
+
for (let i = 0; i < rawUpdates.length; i++) {
|
|
112
|
+
const parsed = parseIssueUpdate(rawUpdates[i], i, state, reportingStep);
|
|
113
|
+
if (typeof parsed === 'string')
|
|
114
|
+
return invalid(parsed);
|
|
115
|
+
// Rejected rather than applied in order: the ledger records only the final
|
|
116
|
+
// disposition, so sequence would carry meaning nothing persists.
|
|
117
|
+
if (seenIds.has(parsed.id)) {
|
|
118
|
+
return invalid(`"issueUpdates" lists ${parsed.id} more than once; report one final disposition per issue`);
|
|
119
|
+
}
|
|
120
|
+
seenIds.add(parsed.id);
|
|
121
|
+
updates.push(parsed);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Rejected rather than ordered: applyReportedIssues applies reports before
|
|
125
|
+
// updates, so the fixed order would silently decide the final disposition.
|
|
126
|
+
if (issues.length > 0 && updates.length > 0) {
|
|
127
|
+
const updatedIds = new Set(updates.map((u) => u.id));
|
|
128
|
+
for (let i = 0; i < issues.length; i++) {
|
|
129
|
+
const existing = (state.issues ?? []).find((s) => s.fingerprint === (0, run_state_1.issueFingerprint)(issues[i].summary));
|
|
130
|
+
if (existing !== undefined && updatedIds.has(existing.id)) {
|
|
131
|
+
return invalid(`"issues"[${i}] describes ${existing.id}, which "issueUpdates" also references; report the problem or update the assignment, not both`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { ok: true, issues, updates };
|
|
136
|
+
}
|
|
137
|
+
function parseReportedIssue(value, index, state) {
|
|
138
|
+
const label = `"issues"[${index}]`;
|
|
139
|
+
if (!isPlainObject(value)) {
|
|
140
|
+
return `${label} must be an object`;
|
|
141
|
+
}
|
|
142
|
+
const unknown = unknownKey(value, [
|
|
143
|
+
'summary',
|
|
144
|
+
'detail',
|
|
145
|
+
'applicableMigrations',
|
|
146
|
+
'disposition',
|
|
147
|
+
]);
|
|
148
|
+
if (unknown !== undefined) {
|
|
149
|
+
return `${label} has an unrecognized field ${quoted(unknown)}; allowed fields are "summary", "detail", "applicableMigrations" and "disposition"`;
|
|
150
|
+
}
|
|
151
|
+
// Validated after singleLine: U+0085 is a terminator to singleLine but not
|
|
152
|
+
// to trim, so a raw check would accept a summary that normalizes empty.
|
|
153
|
+
const summary = typeof value.summary === 'string'
|
|
154
|
+
? (0, text_1.singleLine)(value.summary).trim()
|
|
155
|
+
: undefined;
|
|
156
|
+
if (summary === undefined ||
|
|
157
|
+
summary.length === 0 ||
|
|
158
|
+
summary.length > MAX_SUMMARY_CHARS) {
|
|
159
|
+
return `${label} needs a non-empty "summary" string of at most ${MAX_SUMMARY_CHARS} characters`;
|
|
160
|
+
}
|
|
161
|
+
const detail = value.detail;
|
|
162
|
+
if (detail !== undefined &&
|
|
163
|
+
(typeof detail !== 'string' || detail.length > MAX_DETAIL_CHARS)) {
|
|
164
|
+
return `${label} has a "detail" that is not a string of at most ${MAX_DETAIL_CHARS} characters`;
|
|
165
|
+
}
|
|
166
|
+
const applicable = value.applicableMigrations;
|
|
167
|
+
if (applicable !== 'unknown') {
|
|
168
|
+
if (!Array.isArray(applicable) ||
|
|
169
|
+
applicable.length === 0 ||
|
|
170
|
+
applicable.length > MAX_APPLICABLE_MIGRATIONS) {
|
|
171
|
+
return `${label} needs an "applicableMigrations" that is either the string "unknown" or an array of 1 to ${MAX_APPLICABLE_MIGRATIONS} migration identifiers`;
|
|
172
|
+
}
|
|
173
|
+
for (const identifier of applicable) {
|
|
174
|
+
if (typeof identifier !== 'string') {
|
|
175
|
+
return `${label}.applicableMigrations entries must be strings`;
|
|
176
|
+
}
|
|
177
|
+
if (mappedStepIds(identifier, state).length === 0) {
|
|
178
|
+
return `${label}.applicableMigrations names ${quoted(identifier)}, which is not in this run's plan; use an exact "<package>:<name>" id or a bare package name from the plan, or "unknown"`;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const disposition = value.disposition;
|
|
183
|
+
if (disposition !== undefined &&
|
|
184
|
+
!run_state_1.MIGRATE_ISSUE_DISPOSITIONS.includes(disposition)) {
|
|
185
|
+
return `${label} has a "disposition" that is not one of ${run_state_1.MIGRATE_ISSUE_DISPOSITIONS.map((d) => `"${d}"`).join(', ')}`;
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
summary,
|
|
189
|
+
...(detail !== undefined ? { detail: detail } : {}),
|
|
190
|
+
applicableMigrations: applicable === 'unknown' ? 'unknown' : applicable,
|
|
191
|
+
...(disposition !== undefined
|
|
192
|
+
? { disposition: disposition }
|
|
193
|
+
: {}),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function parseIssueUpdate(value, index, state, reportingStep) {
|
|
197
|
+
const label = `"issueUpdates"[${index}]`;
|
|
198
|
+
if (!isPlainObject(value)) {
|
|
199
|
+
return `${label} must be an object`;
|
|
200
|
+
}
|
|
201
|
+
const unknown = unknownKey(value, ['id', 'disposition', 'note']);
|
|
202
|
+
if (unknown !== undefined) {
|
|
203
|
+
return `${label} has an unrecognized field ${quoted(unknown)}; allowed fields are "id", "disposition" and "note"`;
|
|
204
|
+
}
|
|
205
|
+
const issue = typeof value.id === 'string'
|
|
206
|
+
? (state.issues ?? []).find((i) => i.id === value.id)
|
|
207
|
+
: undefined;
|
|
208
|
+
if (!issue) {
|
|
209
|
+
return `${label} references ${quoted(value.id)}, which is not an issue nx has recorded for this run`;
|
|
210
|
+
}
|
|
211
|
+
if (issue.claimedByStepId !== reportingStep.id) {
|
|
212
|
+
return `${label} references ${issue.id}, which is not assigned to this step; only issues the dispensed digest marks assigned to the current step can be updated`;
|
|
213
|
+
}
|
|
214
|
+
const disposition = value.disposition;
|
|
215
|
+
if (disposition !== 'resolved' && disposition !== 'deferred-final') {
|
|
216
|
+
return `${label} needs a "disposition" of "resolved" or "deferred-final"`;
|
|
217
|
+
}
|
|
218
|
+
const note = value.note;
|
|
219
|
+
if (note !== undefined &&
|
|
220
|
+
(typeof note !== 'string' || note.length > MAX_NOTE_CHARS)) {
|
|
221
|
+
return `${label} has a "note" that is not a string of at most ${MAX_NOTE_CHARS} characters`;
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
id: issue.id,
|
|
225
|
+
disposition,
|
|
226
|
+
...(note !== undefined ? { note: note } : {}),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
// A bare identifier matches the whole package name, scoped names included:
|
|
230
|
+
// nothing splits on '/'. Step ids are unique, so the result needs no dedup.
|
|
231
|
+
function mappedStepIds(identifier, state) {
|
|
232
|
+
if (identifier.includes(':')) {
|
|
233
|
+
return state.steps
|
|
234
|
+
.filter((s) => s.migrationId === identifier)
|
|
235
|
+
.map((s) => s.id);
|
|
236
|
+
}
|
|
237
|
+
return state.steps
|
|
238
|
+
.filter((s) => (0, state_machine_1.splitMigrationId)(s.migrationId).package === identifier)
|
|
239
|
+
.map((s) => s.id);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Pure. Must run on state read fresh inside the fold's locked write, or ids
|
|
243
|
+
* collide and the claim checks test state that is no longer on disk.
|
|
244
|
+
*/
|
|
245
|
+
function applyReportedIssues(state, reportingStep, issues, updates) {
|
|
246
|
+
const ledger = [...(state.issues ?? [])];
|
|
247
|
+
const newIssues = [];
|
|
248
|
+
const archiveUpdates = [];
|
|
249
|
+
// Allocate past the highest existing suffix, not from the ledger length: the
|
|
250
|
+
// reader accepts sparse ids, and a re-minted id would overwrite another
|
|
251
|
+
// issue's archive file. BigInt because a number suffix stalls at 2^53.
|
|
252
|
+
let nextIdNumber = ledger.reduce((max, i) => {
|
|
253
|
+
const suffix = BigInt(i.id.slice('issue-'.length));
|
|
254
|
+
return suffix > max ? suffix : max;
|
|
255
|
+
}, 0n) + 1n;
|
|
256
|
+
for (const report of issues) {
|
|
257
|
+
const fingerprint = (0, run_state_1.issueFingerprint)(report.summary);
|
|
258
|
+
const existingIndex = ledger.findIndex((i) => i.fingerprint === fingerprint);
|
|
259
|
+
if (existingIndex !== -1) {
|
|
260
|
+
const existing = ledger[existingIndex];
|
|
261
|
+
const outcome = applyDuplicateReport(existing, report, reportingStep, state);
|
|
262
|
+
if (outcome.entry !== existing) {
|
|
263
|
+
ledger[existingIndex] = outcome.entry;
|
|
264
|
+
}
|
|
265
|
+
if (outcome.archive) {
|
|
266
|
+
archiveUpdates.push({
|
|
267
|
+
issueId: existing.id,
|
|
268
|
+
stepId: reportingStep.id,
|
|
269
|
+
disposition: outcome.entry.disposition,
|
|
270
|
+
...(report.applicableMigrations !== 'unknown'
|
|
271
|
+
? { applicableMigrations: report.applicableMigrations }
|
|
272
|
+
: {}),
|
|
273
|
+
...(report.detail !== undefined ? { detail: report.detail } : {}),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const applicableStepIds = report.applicableMigrations === 'unknown'
|
|
279
|
+
? 'unknown'
|
|
280
|
+
: mapApplicableSteps(report.applicableMigrations, state);
|
|
281
|
+
const disposition = resolveDisposition(report, applicableStepIds, state);
|
|
282
|
+
// Reachable only at the reader's 18-digit bound. Throwing hands the caller's
|
|
283
|
+
// archive error handling the failure instead of writing a state it rejects.
|
|
284
|
+
const id = `issue-${nextIdNumber++}`;
|
|
285
|
+
if (!run_state_1.ISSUE_ID.test(id)) {
|
|
286
|
+
throw new Error(`the issue ledger cannot allocate "${id}": the id suffix is out of range`);
|
|
287
|
+
}
|
|
288
|
+
const entry = {
|
|
289
|
+
id,
|
|
290
|
+
fingerprint,
|
|
291
|
+
summary: report.summary,
|
|
292
|
+
reportedByStepId: reportingStep.id,
|
|
293
|
+
applicableStepIds,
|
|
294
|
+
disposition,
|
|
295
|
+
...(disposition === 'resolved'
|
|
296
|
+
? {
|
|
297
|
+
resolvedByStepId: reportingStep.id,
|
|
298
|
+
resolvedAtCommitCount: state.commits.length,
|
|
299
|
+
}
|
|
300
|
+
: {}),
|
|
301
|
+
};
|
|
302
|
+
ledger.push(entry);
|
|
303
|
+
newIssues.push({ entry, report });
|
|
304
|
+
}
|
|
305
|
+
for (const update of updates) {
|
|
306
|
+
const index = ledger.findIndex((i) => i.id === update.id);
|
|
307
|
+
// Ids were validated against this same state by the caller.
|
|
308
|
+
if (index === -1)
|
|
309
|
+
continue;
|
|
310
|
+
const existing = ledger[index];
|
|
311
|
+
if (existing.disposition !== update.disposition) {
|
|
312
|
+
// The claim and the resolution credit both die with the move: only 'recorded'
|
|
313
|
+
// issues carry an assignment, and the stamp belongs to this resolution alone.
|
|
314
|
+
const { resolvedByStepId: _dropped, resolvedAtCommitCount: _fence, claimedByStepId: _released, ...rest } = existing;
|
|
315
|
+
ledger[index] =
|
|
316
|
+
update.disposition === 'resolved'
|
|
317
|
+
? {
|
|
318
|
+
...rest,
|
|
319
|
+
disposition: 'resolved',
|
|
320
|
+
resolvedByStepId: reportingStep.id,
|
|
321
|
+
resolvedAtCommitCount: state.commits.length,
|
|
322
|
+
}
|
|
323
|
+
: { ...rest, disposition: update.disposition };
|
|
324
|
+
}
|
|
325
|
+
// Archived even when the disposition did not move: the note may carry
|
|
326
|
+
// context the ledger has no field for.
|
|
327
|
+
archiveUpdates.push({
|
|
328
|
+
issueId: update.id,
|
|
329
|
+
stepId: reportingStep.id,
|
|
330
|
+
disposition: update.disposition,
|
|
331
|
+
...(update.note !== undefined ? { note: update.note } : {}),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
state: { ...state, issues: ledger },
|
|
336
|
+
newIssues,
|
|
337
|
+
updates: archiveUpdates,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function reportIntent(report) {
|
|
341
|
+
switch (report.disposition) {
|
|
342
|
+
case 'resolved':
|
|
343
|
+
return 'resolved';
|
|
344
|
+
case 'deferred-final':
|
|
345
|
+
return 'deferred-final';
|
|
346
|
+
case 'recorded':
|
|
347
|
+
case undefined:
|
|
348
|
+
return 'unresolved';
|
|
349
|
+
default:
|
|
350
|
+
return unreachable(report.disposition);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Folds a duplicate report into its existing entry, one cell per (disposition x
|
|
355
|
+
* intent) pair. A recorded entry another live step holds blocks a resolve or
|
|
356
|
+
* defer down to a refinement: an assignment made in that step's digest cannot
|
|
357
|
+
* be revoked from outside it. Every cell that changed the entry, and every
|
|
358
|
+
* report carrying detail, must archive: the fold removes the handoff, so
|
|
359
|
+
* unarchived detail is lost.
|
|
360
|
+
*/
|
|
361
|
+
function applyDuplicateReport(existing, report, reportingStep, state) {
|
|
362
|
+
const intent = reportIntent(report);
|
|
363
|
+
const merged = mergedApplicableStepIds(existing, report, state);
|
|
364
|
+
// Set inclusion, not a length compare: a corrupt entry with duplicate scope
|
|
365
|
+
// ids would otherwise read the normalizing merge as new routing.
|
|
366
|
+
const addedStepIds = !Array.isArray(merged)
|
|
367
|
+
? []
|
|
368
|
+
: !Array.isArray(existing.applicableStepIds)
|
|
369
|
+
? merged
|
|
370
|
+
: merged.filter((id) => !existing.applicableStepIds.includes(id));
|
|
371
|
+
const widened = addedStepIds.length > 0;
|
|
372
|
+
// Only a live assignment blocks: a terminal claimant can never hand back
|
|
373
|
+
// another handoff, so its claim is stale bookkeeping, not ownership.
|
|
374
|
+
const claimant = existing.disposition === 'recorded' &&
|
|
375
|
+
existing.claimedByStepId !== undefined
|
|
376
|
+
? state.steps.find((s) => s.id === existing.claimedByStepId)
|
|
377
|
+
: undefined;
|
|
378
|
+
const blocked = claimant !== undefined &&
|
|
379
|
+
claimant.id !== reportingStep.id &&
|
|
380
|
+
!run_state_1.TERMINAL_STEP_STATUSES.has(claimant.status);
|
|
381
|
+
const refine = () => ({
|
|
382
|
+
entry: widened ? { ...existing, applicableStepIds: merged } : existing,
|
|
383
|
+
archive: widened || report.detail !== undefined,
|
|
384
|
+
});
|
|
385
|
+
const transition = (disposition) => {
|
|
386
|
+
const { resolvedByStepId: _credit, resolvedAtCommitCount: _fence, claimedByStepId: _claim, ...rest } = existing;
|
|
387
|
+
return {
|
|
388
|
+
entry: {
|
|
389
|
+
...rest,
|
|
390
|
+
applicableStepIds: merged,
|
|
391
|
+
disposition,
|
|
392
|
+
...(disposition === 'resolved'
|
|
393
|
+
? {
|
|
394
|
+
resolvedByStepId: reportingStep.id,
|
|
395
|
+
resolvedAtCommitCount: state.commits.length,
|
|
396
|
+
}
|
|
397
|
+
: {}),
|
|
398
|
+
},
|
|
399
|
+
archive: true,
|
|
400
|
+
};
|
|
401
|
+
};
|
|
402
|
+
switch (existing.disposition) {
|
|
403
|
+
case 'recorded':
|
|
404
|
+
switch (intent) {
|
|
405
|
+
case 'resolved':
|
|
406
|
+
return blocked ? refine() : transition('resolved');
|
|
407
|
+
case 'deferred-final':
|
|
408
|
+
return blocked ? refine() : transition('deferred-final');
|
|
409
|
+
case 'unresolved':
|
|
410
|
+
return refine();
|
|
411
|
+
default:
|
|
412
|
+
return unreachable(intent);
|
|
413
|
+
}
|
|
414
|
+
case 'resolved':
|
|
415
|
+
// A duplicate report over a resolved entry is a recurrence: resolved issues
|
|
416
|
+
// leave the digest, so the reporter hit the problem again, not echoed it.
|
|
417
|
+
switch (intent) {
|
|
418
|
+
case 'resolved':
|
|
419
|
+
// Not a no-op: the recurrence re-credits the new resolver and re-stamps.
|
|
420
|
+
return transition('resolved');
|
|
421
|
+
case 'deferred-final':
|
|
422
|
+
case 'unresolved':
|
|
423
|
+
return transition(resolveDisposition(report, merged, state));
|
|
424
|
+
default:
|
|
425
|
+
return unreachable(intent);
|
|
426
|
+
}
|
|
427
|
+
case 'deferred-final':
|
|
428
|
+
switch (intent) {
|
|
429
|
+
case 'resolved':
|
|
430
|
+
// Deferred entries have no assignment, so any step that actually
|
|
431
|
+
// fixed the problem may resolve them.
|
|
432
|
+
return transition('resolved');
|
|
433
|
+
case 'deferred-final':
|
|
434
|
+
return refine();
|
|
435
|
+
case 'unresolved':
|
|
436
|
+
// Revive only on newly ADDED routing that can still claim it: deferred entries
|
|
437
|
+
// stay digest-visible, so a repeat with no new scope may just be echoing one,
|
|
438
|
+
// and testing `merged` instead would let a route to finished work reopen it.
|
|
439
|
+
return hasClaimableStep(addedStepIds, stepStatusIndex(state))
|
|
440
|
+
? transition('recorded')
|
|
441
|
+
: refine();
|
|
442
|
+
default:
|
|
443
|
+
return unreachable(intent);
|
|
444
|
+
}
|
|
445
|
+
default:
|
|
446
|
+
return unreachable(existing.disposition);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function unreachable(value) {
|
|
450
|
+
throw new Error(`unexpected value: ${JSON.stringify(value)}`);
|
|
451
|
+
}
|
|
452
|
+
// A commit carries the resolution only if it names the resolving step AND
|
|
453
|
+
// postdates the stamp: the same step's earlier commits must not vouch for it.
|
|
454
|
+
function resolutionCarried(state, issue) {
|
|
455
|
+
return state.commits
|
|
456
|
+
.slice(issue.resolvedAtCommitCount ?? 0)
|
|
457
|
+
.some((c) => c.kind === 'landed' &&
|
|
458
|
+
(c.issueIds ?? []).includes(issue.id) &&
|
|
459
|
+
c.stepIds.includes(issue.resolvedByStepId));
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* The issue ids a landed commit naming `stepIds` carries. Attribution rides on
|
|
463
|
+
* the persisted `resolvedByStepId`, so a resolution whose own commit attempt
|
|
464
|
+
* failed still reaches the later commit that absorbs that step's tree.
|
|
465
|
+
*/
|
|
466
|
+
function issueIdsForCommit(state, stepIds) {
|
|
467
|
+
const issues = state.issues;
|
|
468
|
+
if (!issues)
|
|
469
|
+
return [];
|
|
470
|
+
return issues
|
|
471
|
+
.filter((i) => i.disposition === 'resolved' &&
|
|
472
|
+
i.resolvedByStepId !== undefined &&
|
|
473
|
+
stepIds.includes(i.resolvedByStepId) &&
|
|
474
|
+
!resolutionCarried(state, i))
|
|
475
|
+
.map((i) => i.id);
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Attaches the issue ids a landed entry carries; non-landed entries pass
|
|
479
|
+
* through. Every commit append (worker, fold, adopt) routes through this so
|
|
480
|
+
* attribution cannot diverge between terminal paths.
|
|
481
|
+
*/
|
|
482
|
+
function attachIssueIdsToCommitEntry(state, entry) {
|
|
483
|
+
if (entry.kind !== 'landed')
|
|
484
|
+
return entry;
|
|
485
|
+
const issueIds = issueIdsForCommit(state, entry.stepIds);
|
|
486
|
+
return issueIds.length > 0 ? { ...entry, issueIds } : entry;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Reverts resolutions not carried by a landed commit and releases claims
|
|
490
|
+
* during a reset-backed retry. Returned disposition changes form the archive
|
|
491
|
+
* trail; claim releases do not.
|
|
492
|
+
*/
|
|
493
|
+
function reopenResolutionsForStep(state, stepId) {
|
|
494
|
+
const issues = state.issues;
|
|
495
|
+
if (!issues)
|
|
496
|
+
return { state, updates: [] };
|
|
497
|
+
const updates = [];
|
|
498
|
+
let changed = false;
|
|
499
|
+
const next = issues.map((issue) => {
|
|
500
|
+
if (issue.disposition === 'resolved' &&
|
|
501
|
+
issue.resolvedByStepId === stepId &&
|
|
502
|
+
!resolutionCarried(state, issue)) {
|
|
503
|
+
const { resolvedByStepId: _dropped, resolvedAtCommitCount: _fence, claimedByStepId: _stale, ...rest } = issue;
|
|
504
|
+
const disposition = hasClaimableStep(issue.applicableStepIds, stepStatusIndex(state))
|
|
505
|
+
? 'recorded'
|
|
506
|
+
: 'deferred-final';
|
|
507
|
+
updates.push({ issueId: issue.id, stepId, disposition });
|
|
508
|
+
changed = true;
|
|
509
|
+
return { ...rest, disposition };
|
|
510
|
+
}
|
|
511
|
+
if (issue.claimedByStepId === stepId) {
|
|
512
|
+
const { claimedByStepId: _released, ...rest } = issue;
|
|
513
|
+
changed = true;
|
|
514
|
+
return rest;
|
|
515
|
+
}
|
|
516
|
+
return issue;
|
|
517
|
+
});
|
|
518
|
+
return changed
|
|
519
|
+
? { state: { ...state, issues: next }, updates }
|
|
520
|
+
: { state, updates };
|
|
521
|
+
}
|
|
522
|
+
// Unknown applicability is deferred, not recorded: nothing can claim it, so a
|
|
523
|
+
// 'recorded' entry would sit waiting for an assignment that never comes.
|
|
524
|
+
function resolveDisposition(report, applicableStepIds, state) {
|
|
525
|
+
const disposition = report.disposition ??
|
|
526
|
+
(applicableStepIds === 'unknown' ? 'deferred-final' : 'recorded');
|
|
527
|
+
if (disposition !== 'recorded')
|
|
528
|
+
return disposition;
|
|
529
|
+
return hasClaimableStep(applicableStepIds, stepStatusIndex(state))
|
|
530
|
+
? 'recorded'
|
|
531
|
+
: 'deferred-final';
|
|
532
|
+
}
|
|
533
|
+
function mergedApplicableStepIds(existing, report, state) {
|
|
534
|
+
if (report.applicableMigrations === 'unknown') {
|
|
535
|
+
return existing.applicableStepIds;
|
|
536
|
+
}
|
|
537
|
+
const combined = new Set([
|
|
538
|
+
...(Array.isArray(existing.applicableStepIds)
|
|
539
|
+
? existing.applicableStepIds
|
|
540
|
+
: []),
|
|
541
|
+
...mapApplicableSteps(report.applicableMigrations, state),
|
|
542
|
+
]);
|
|
543
|
+
return state.steps.map((s) => s.id).filter((id) => combined.has(id));
|
|
544
|
+
}
|
|
545
|
+
// Non-terminal is the boundary, not 'pending': a just-failed step can be
|
|
546
|
+
// re-armed, and its retry's dispense claims recorded issues.
|
|
547
|
+
function hasClaimableStep(applicableStepIds, stepStatus) {
|
|
548
|
+
if (applicableStepIds === 'unknown')
|
|
549
|
+
return false;
|
|
550
|
+
return applicableStepIds.some((id) => {
|
|
551
|
+
const status = stepStatus.get(id);
|
|
552
|
+
return status !== undefined && !run_state_1.TERMINAL_STEP_STATUSES.has(status);
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
// Built once per pass: a bare package can route every issue to every
|
|
556
|
+
// same-package step, so a per-lookup scan of `state.steps` goes cubic.
|
|
557
|
+
function stepStatusIndex(state) {
|
|
558
|
+
return new Map(state.steps.map((s) => [s.id, s.status]));
|
|
559
|
+
}
|
|
560
|
+
function mapApplicableSteps(identifiers, state) {
|
|
561
|
+
const mapped = new Set();
|
|
562
|
+
for (const identifier of identifiers) {
|
|
563
|
+
for (const stepId of mappedStepIds(identifier, state)) {
|
|
564
|
+
mapped.add(stepId);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return state.steps.map((s) => s.id).filter((id) => mapped.has(id));
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Claims the still-recorded issues applicable to the step being dispensed, up
|
|
571
|
+
* to what the digest can publish. Must run inside the dispense's locked write:
|
|
572
|
+
* serial dispensing is what makes reassignment to the next applicable step
|
|
573
|
+
* deterministic when an earlier one left the issue unresolved.
|
|
574
|
+
*/
|
|
575
|
+
function claimIssuesForStep(state, stepId, runId) {
|
|
576
|
+
const issues = state.issues;
|
|
577
|
+
if (!issues)
|
|
578
|
+
return state;
|
|
579
|
+
// Capped to what the digest publishes: an unlisted assignment is invisible,
|
|
580
|
+
// since issueUpdates may only name digest-marked issues. The instruction line
|
|
581
|
+
// is reserved unconditionally; it renders with any surviving claim, and
|
|
582
|
+
// over-reserving only shortens the claimed prefix.
|
|
583
|
+
const budget = digestEntryByteBudget([``, stepDigestHeading(runId), ASSIGNMENT_INSTRUCTION], unresolvedIssues(state).length, runId);
|
|
584
|
+
const applicableTo = (issue) => issue.disposition === 'recorded' &&
|
|
585
|
+
Array.isArray(issue.applicableStepIds) &&
|
|
586
|
+
issue.applicableStepIds.includes(stepId);
|
|
587
|
+
const applicable = issues.filter(applicableTo);
|
|
588
|
+
const kept = new Set(applicable.slice(0, boundedPrefixCount(applicable.map((issue) => digestEntry(issue, 'assigned to this step')), budget)));
|
|
589
|
+
let changed = false;
|
|
590
|
+
const next = issues.map((issue) => {
|
|
591
|
+
if (!applicableTo(issue))
|
|
592
|
+
return issue;
|
|
593
|
+
if (kept.has(issue)) {
|
|
594
|
+
if (issue.claimedByStepId === stepId)
|
|
595
|
+
return issue;
|
|
596
|
+
changed = true;
|
|
597
|
+
return { ...issue, claimedByStepId: stepId };
|
|
598
|
+
}
|
|
599
|
+
if (issue.claimedByStepId === stepId) {
|
|
600
|
+
changed = true;
|
|
601
|
+
const { claimedByStepId: _dropped, ...rest } = issue;
|
|
602
|
+
return rest;
|
|
603
|
+
}
|
|
604
|
+
return issue;
|
|
605
|
+
});
|
|
606
|
+
return changed ? { ...state, issues: next } : state;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Before rendering, release terminal claims and defer issues no remaining
|
|
610
|
+
* step can claim; report-time routing can become stale as steps finish. Do
|
|
611
|
+
* not archive Nx-driven progression.
|
|
612
|
+
*/
|
|
613
|
+
function settleUnclaimableIssues(state) {
|
|
614
|
+
const issues = state.issues;
|
|
615
|
+
if (!issues)
|
|
616
|
+
return state;
|
|
617
|
+
const stepStatus = stepStatusIndex(state);
|
|
618
|
+
let changed = false;
|
|
619
|
+
const next = issues.map((issue) => {
|
|
620
|
+
if (issue.disposition !== 'recorded')
|
|
621
|
+
return issue;
|
|
622
|
+
if (!hasClaimableStep(issue.applicableStepIds, stepStatus)) {
|
|
623
|
+
changed = true;
|
|
624
|
+
// The claim goes with the demotion: a deferred issue is assigned
|
|
625
|
+
// nowhere, and the state reader rejects a claim on a non-recorded issue.
|
|
626
|
+
const { claimedByStepId: _dropped, ...rest } = issue;
|
|
627
|
+
return { ...rest, disposition: 'deferred-final' };
|
|
628
|
+
}
|
|
629
|
+
// A terminal assignee can never hand back another handoff, so its claim
|
|
630
|
+
// blocks nothing and would read as active ownership.
|
|
631
|
+
const claimantStatus = issue.claimedByStepId !== undefined
|
|
632
|
+
? stepStatus.get(issue.claimedByStepId)
|
|
633
|
+
: undefined;
|
|
634
|
+
if (claimantStatus !== undefined &&
|
|
635
|
+
run_state_1.TERMINAL_STEP_STATUSES.has(claimantStatus)) {
|
|
636
|
+
changed = true;
|
|
637
|
+
const { claimedByStepId: _released, ...rest } = issue;
|
|
638
|
+
return rest;
|
|
639
|
+
}
|
|
640
|
+
return issue;
|
|
641
|
+
});
|
|
642
|
+
return changed ? { ...state, issues: next } : state;
|
|
643
|
+
}
|
|
644
|
+
function issuesDirRef(runId) {
|
|
645
|
+
return `${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${runId}/${ISSUES_DIR_NAME}/`;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* The bounded "Known issues" digest a work dispense carries. Assigned entries
|
|
649
|
+
* lead and share claimIssuesForStep's prefix bound, so every kept claim appears.
|
|
650
|
+
*/
|
|
651
|
+
function renderIssueDigestLines(state, currentStepId, runId) {
|
|
652
|
+
const unresolved = unresolvedIssues(state);
|
|
653
|
+
if (unresolved.length === 0)
|
|
654
|
+
return [];
|
|
655
|
+
const assigned = unresolved.filter((i) => i.disposition === 'recorded' && i.claimedByStepId === currentStepId);
|
|
656
|
+
const entries = [
|
|
657
|
+
...assigned.map((i) => digestEntry(i, 'assigned to this step')),
|
|
658
|
+
...unresolved
|
|
659
|
+
.filter((i) => i.disposition === 'recorded' && i.claimedByStepId !== currentStepId)
|
|
660
|
+
.map((i) => digestEntry(i, 'recorded')),
|
|
661
|
+
...unresolved
|
|
662
|
+
.filter((i) => i.disposition === 'deferred-final')
|
|
663
|
+
.map((i) => digestEntry(i, 'deferred past the migration steps')),
|
|
664
|
+
];
|
|
665
|
+
const fixedLines = [
|
|
666
|
+
``,
|
|
667
|
+
stepDigestHeading(runId),
|
|
668
|
+
...(assigned.length > 0 ? [ASSIGNMENT_INSTRUCTION] : []),
|
|
669
|
+
];
|
|
670
|
+
const budget = digestEntryByteBudget(fixedLines, entries.length, runId);
|
|
671
|
+
if (budget < 0) {
|
|
672
|
+
return [``, DIGEST_FALLBACK_LINE];
|
|
673
|
+
}
|
|
674
|
+
const lines = [
|
|
675
|
+
``,
|
|
676
|
+
stepDigestHeading(runId),
|
|
677
|
+
...boundedEntryLines(entries, runId, budget),
|
|
678
|
+
];
|
|
679
|
+
if (assigned.length > 0) {
|
|
680
|
+
lines.push(ASSIGNMENT_INSTRUCTION);
|
|
681
|
+
}
|
|
682
|
+
return lines;
|
|
683
|
+
}
|
|
684
|
+
function renderUnresolvedIssueLines(state, runId) {
|
|
685
|
+
const unresolved = unresolvedIssues(state);
|
|
686
|
+
if (unresolved.length === 0)
|
|
687
|
+
return [];
|
|
688
|
+
const entries = [
|
|
689
|
+
...unresolved
|
|
690
|
+
.filter((i) => i.disposition === 'recorded')
|
|
691
|
+
.map((i) => digestEntry(i, 'recorded')),
|
|
692
|
+
...unresolved
|
|
693
|
+
.filter((i) => i.disposition === 'deferred-final')
|
|
694
|
+
.map((i) => digestEntry(i, 'deferred past the migration steps')),
|
|
695
|
+
];
|
|
696
|
+
const noun = unresolved.length === 1 ? 'issue' : 'issues';
|
|
697
|
+
const verb = unresolved.length === 1 ? 'remains' : 'remain';
|
|
698
|
+
const heading = `${unresolved.length} reported ${noun} ${verb} unresolved (details under ${issuesDirRef(runId)}):`;
|
|
699
|
+
const budget = digestEntryByteBudget([heading], entries.length, runId);
|
|
700
|
+
if (budget < 0) {
|
|
701
|
+
return [
|
|
702
|
+
`${unresolved.length} reported ${noun} ${verb} unresolved; see ${ISSUES_DIR_FALLBACK_REF}.`,
|
|
703
|
+
];
|
|
704
|
+
}
|
|
705
|
+
return [heading, ...boundedEntryLines(entries, runId, budget)];
|
|
706
|
+
}
|
|
707
|
+
function unresolvedIssues(state) {
|
|
708
|
+
return (state.issues ?? []).filter((i) => i.disposition !== 'resolved');
|
|
709
|
+
}
|
|
710
|
+
function stepDigestHeading(runId) {
|
|
711
|
+
return `Known issues reported earlier in this run (details under ${issuesDirRef(runId)}):`;
|
|
712
|
+
}
|
|
713
|
+
const ASSIGNMENT_INSTRUCTION = `Fix the issues assigned to this step within its scope where you can, and report each result in the handoff's "issueUpdates" field.`;
|
|
714
|
+
// Points at the run's issues dir without embedding the run id, whose length is
|
|
715
|
+
// what made the normal digest impossible.
|
|
716
|
+
const ISSUES_DIR_FALLBACK_REF = `the '${ISSUES_DIR_NAME}' directory inside the run's directory under ${types_1.MIGRATE_RUNS_RELATIVE_DIR}/`;
|
|
717
|
+
const DIGEST_FALLBACK_LINE = `Known issues reported earlier in this run are not listed because the digest would exceed its size limit; see ${ISSUES_DIR_FALLBACK_REF}.`;
|
|
718
|
+
function overflowLine(omitted, runId) {
|
|
719
|
+
return ` ...and ${omitted} more not listed; see ${issuesDirRef(runId)}.`;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Byte budget left for entry lines after reserving the digest's fixed lines
|
|
723
|
+
* out of MAX_DIGEST_BYTES: the given fixed lines, the worst-case overflow line
|
|
724
|
+
* (every entry omitted), and one newline separator per line the digest could
|
|
725
|
+
* render. Derived from the serialized lines themselves, so a run id of any
|
|
726
|
+
* length is priced in. claimIssuesForStep and the renderers share it: a claim
|
|
727
|
+
* kept under it is guaranteed a rendered line, and reserving lines that may
|
|
728
|
+
* not render only shortens the listing. Negative when the fixed prose alone
|
|
729
|
+
* cannot fit.
|
|
730
|
+
*/
|
|
731
|
+
function digestEntryByteBudget(fixedLines, totalEntries, runId) {
|
|
732
|
+
const reserved = [...fixedLines, overflowLine(totalEntries, runId)];
|
|
733
|
+
const maxLines = Math.min(totalEntries, MAX_DIGEST_ENTRIES) + reserved.length;
|
|
734
|
+
const reservedBytes = reserved.reduce((sum, line) => sum + Buffer.byteLength(line, 'utf8'), 0) +
|
|
735
|
+
(maxLines - 1);
|
|
736
|
+
return MAX_DIGEST_BYTES - reservedBytes;
|
|
737
|
+
}
|
|
738
|
+
function digestEntry(issue, label) {
|
|
739
|
+
// The marker counts against the cap: a truncated summary renders at exactly
|
|
740
|
+
// DIGEST_SUMMARY_CHARS characters.
|
|
741
|
+
const summary = issue.summary.length > DIGEST_SUMMARY_CHARS
|
|
742
|
+
? `${issue.summary.slice(0, DIGEST_SUMMARY_CHARS - 3)}...`
|
|
743
|
+
: issue.summary;
|
|
744
|
+
return ` - ${issue.id} (${label}): ${summary}`;
|
|
745
|
+
}
|
|
746
|
+
function boundedEntryLines(entries, runId, budget) {
|
|
747
|
+
const lines = entries.slice(0, boundedPrefixCount(entries, budget));
|
|
748
|
+
const omitted = entries.length - lines.length;
|
|
749
|
+
if (omitted > 0) {
|
|
750
|
+
lines.push(overflowLine(omitted, runId));
|
|
751
|
+
}
|
|
752
|
+
return lines;
|
|
753
|
+
}
|
|
754
|
+
// A prefix only: skipping an oversized entry would let a later, lower-priority
|
|
755
|
+
// entry displace an earlier assigned one.
|
|
756
|
+
function boundedPrefixCount(entries, budget) {
|
|
757
|
+
let count = 0;
|
|
758
|
+
let bytes = 0;
|
|
759
|
+
for (const entry of entries) {
|
|
760
|
+
const entryBytes = Buffer.byteLength(entry, 'utf8');
|
|
761
|
+
if (count >= MAX_DIGEST_ENTRIES || bytes + entryBytes > budget) {
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
count++;
|
|
765
|
+
bytes += entryBytes;
|
|
766
|
+
}
|
|
767
|
+
return count;
|
|
768
|
+
}
|
|
769
|
+
function issuesDir(runDirPath) {
|
|
770
|
+
return (0, path_1.join)(runDirPath, ISSUES_DIR_NAME);
|
|
771
|
+
}
|
|
772
|
+
// `issueId` is nx-assigned (`issue-<n>`), so the join cannot leave the
|
|
773
|
+
// directory.
|
|
774
|
+
function issueArchivePath(runDirPath, issueId) {
|
|
775
|
+
return (0, path_1.join)(issuesDir(runDirPath), `${issueId}.json`);
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Archives an application's new issues (with the detail the ledger does not
|
|
779
|
+
* carry) and appends its updates to the archived files. Failures propagate;
|
|
780
|
+
* the caller then keeps the handoff file as the fallback detail store.
|
|
781
|
+
* `reconstructedIds` is filled as it goes rather than returned alone: a retry
|
|
782
|
+
* finds a rebuilt shell healthy, so a throw-interrupted pass is the only
|
|
783
|
+
* chance to warn that an archive was lost.
|
|
784
|
+
*/
|
|
785
|
+
function archiveIssues(runDirPath, application, reconstructedIds = []) {
|
|
786
|
+
for (const { entry, report } of application.newIssues) {
|
|
787
|
+
const record = { ...newIssueArchiveRecord(entry, report), updates: [] };
|
|
788
|
+
writeIssueArchive(runDirPath, entry.id, record);
|
|
789
|
+
}
|
|
790
|
+
// One append per issue, so an application's whole batch lands in a single
|
|
791
|
+
// atomic write a crash cannot split.
|
|
792
|
+
const batches = batchUpdatesByIssue(application.updates);
|
|
793
|
+
for (const [issueId, batch] of batches) {
|
|
794
|
+
const reconstructed = appendIssueUpdatesToArchive(runDirPath, issueId, batch, application.state);
|
|
795
|
+
if (reconstructed) {
|
|
796
|
+
reconstructedIds.push(issueId);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return reconstructedIds;
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Verifies the archive state expected after phase 1. New-issue fields must
|
|
803
|
+
* match the report and have no updates; existing-issue updates must end with
|
|
804
|
+
* this application's whole batch.
|
|
805
|
+
*/
|
|
806
|
+
function applicationArchivesIntact(runDirPath, application) {
|
|
807
|
+
const readRaw = (issueId) => {
|
|
808
|
+
try {
|
|
809
|
+
const parsed = JSON.parse((0, fs_1.readFileSync)(issueArchivePath(runDirPath, issueId), 'utf-8'));
|
|
810
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
for (const { entry, report } of application.newIssues) {
|
|
817
|
+
const archive = readRaw(entry.id);
|
|
818
|
+
if (archive === null ||
|
|
819
|
+
!isHealthyArchive(archive, entry.id, application.state)) {
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
const expected = newIssueArchiveRecord(entry, report);
|
|
823
|
+
const survives = NEW_ISSUE_ARCHIVE_KEYS.every((key) => JSON.stringify(archive[key]) === JSON.stringify(expected[key]));
|
|
824
|
+
if (!survives ||
|
|
825
|
+
!Array.isArray(archive.updates) ||
|
|
826
|
+
archive.updates.length > 0) {
|
|
827
|
+
return false;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
for (const [issueId, batch] of batchUpdatesByIssue(application.updates)) {
|
|
831
|
+
const archive = readRaw(issueId);
|
|
832
|
+
if (archive === null ||
|
|
833
|
+
!isHealthyArchive(archive, issueId, application.state)) {
|
|
834
|
+
return false;
|
|
835
|
+
}
|
|
836
|
+
const records = Array.isArray(archive.updates) ? archive.updates : [];
|
|
837
|
+
const expectedBatch = batch.map(archiveUpdateRecord);
|
|
838
|
+
const tail = records.slice(records.length - expectedBatch.length);
|
|
839
|
+
if (JSON.stringify(tail) !== JSON.stringify(expectedBatch)) {
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return true;
|
|
844
|
+
}
|
|
845
|
+
const NEW_ISSUE_ARCHIVE_KEYS = [
|
|
846
|
+
'id',
|
|
847
|
+
'fingerprint',
|
|
848
|
+
'summary',
|
|
849
|
+
'reportedByStepId',
|
|
850
|
+
'applicableMigrations',
|
|
851
|
+
'reportedDisposition',
|
|
852
|
+
'disposition',
|
|
853
|
+
'detail',
|
|
854
|
+
];
|
|
855
|
+
// Shared by the write path and the intactness check so their shapes cannot
|
|
856
|
+
// drift.
|
|
857
|
+
function newIssueArchiveRecord(entry, report) {
|
|
858
|
+
return {
|
|
859
|
+
id: entry.id,
|
|
860
|
+
fingerprint: entry.fingerprint,
|
|
861
|
+
summary: entry.summary,
|
|
862
|
+
reportedByStepId: entry.reportedByStepId,
|
|
863
|
+
applicableMigrations: report.applicableMigrations,
|
|
864
|
+
// Both dispositions: the ladder may start the entry somewhere other than what
|
|
865
|
+
// the reporter asked for, and the file keeps both.
|
|
866
|
+
...(report.disposition !== undefined
|
|
867
|
+
? { reportedDisposition: report.disposition }
|
|
868
|
+
: {}),
|
|
869
|
+
disposition: entry.disposition,
|
|
870
|
+
...(report.detail !== undefined ? { detail: report.detail } : {}),
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
function batchUpdatesByIssue(updates) {
|
|
874
|
+
const batches = new Map();
|
|
875
|
+
for (const update of updates) {
|
|
876
|
+
const batch = batches.get(update.issueId) ?? [];
|
|
877
|
+
batch.push(update);
|
|
878
|
+
batches.set(update.issueId, batch);
|
|
879
|
+
}
|
|
880
|
+
return batches;
|
|
881
|
+
}
|
|
882
|
+
// Shared by the append path and the intactness check so their shapes cannot
|
|
883
|
+
// drift.
|
|
884
|
+
function archiveUpdateRecord(update) {
|
|
885
|
+
return {
|
|
886
|
+
stepId: update.stepId,
|
|
887
|
+
disposition: update.disposition,
|
|
888
|
+
...(update.note !== undefined ? { note: update.note } : {}),
|
|
889
|
+
...(update.detail !== undefined ? { detail: update.detail } : {}),
|
|
890
|
+
...(update.applicableMigrations !== undefined
|
|
891
|
+
? { applicableMigrations: update.applicableMigrations }
|
|
892
|
+
: {}),
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
// True when the archive had to be rebuilt (missing or corrupt).
|
|
896
|
+
function appendIssueUpdatesToArchive(runDirPath, issueId, batch, state) {
|
|
897
|
+
const filePath = issueArchivePath(runDirPath, issueId);
|
|
898
|
+
// Missing or corrupt archives are rebuilt from run state, which stays
|
|
899
|
+
// authoritative for dispositions. Any other read error rethrows rather than
|
|
900
|
+
// overwrite: it may hide an intact file, the only copy of the issue's detail.
|
|
901
|
+
let existing;
|
|
902
|
+
let reconstructed = false;
|
|
903
|
+
try {
|
|
904
|
+
const parsed = JSON.parse((0, fs_1.readFileSync)(filePath, 'utf-8'));
|
|
905
|
+
if (isHealthyArchive(parsed, issueId, state)) {
|
|
906
|
+
existing = parsed;
|
|
907
|
+
}
|
|
908
|
+
else {
|
|
909
|
+
existing = reconstructedArchiveShell(issueId, state);
|
|
910
|
+
reconstructed = true;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
catch (e) {
|
|
914
|
+
const code = e.code;
|
|
915
|
+
if (code !== undefined && code !== 'ENOENT') {
|
|
916
|
+
throw e;
|
|
917
|
+
}
|
|
918
|
+
existing = reconstructedArchiveShell(issueId, state);
|
|
919
|
+
reconstructed = true;
|
|
920
|
+
}
|
|
921
|
+
const updates = Array.isArray(existing.updates) ? existing.updates : [];
|
|
922
|
+
const records = batch.map(archiveUpdateRecord);
|
|
923
|
+
// Skip a batch the archive already ends with: a crash between these writes and
|
|
924
|
+
// the fold's state write replays the same application. Compared as a whole
|
|
925
|
+
// suffix, since one application can legally append several records per issue.
|
|
926
|
+
const tail = updates.slice(updates.length - records.length);
|
|
927
|
+
if (tail.length === records.length &&
|
|
928
|
+
JSON.stringify(tail) === JSON.stringify(records)) {
|
|
929
|
+
return reconstructed;
|
|
930
|
+
}
|
|
931
|
+
writeIssueArchive(runDirPath, issueId, {
|
|
932
|
+
...existing,
|
|
933
|
+
updates: [...updates, ...records],
|
|
934
|
+
});
|
|
935
|
+
return reconstructed;
|
|
936
|
+
}
|
|
937
|
+
// Identity only: id, fingerprint, summary and reporter must match the ledger
|
|
938
|
+
// entry. Everything else is informational and deliberately unpoliced, since
|
|
939
|
+
// run.json decides behavior and authenticating archive history is a non-goal.
|
|
940
|
+
// A hollow or mismatched file rebuilds, rather than hiding the loss.
|
|
941
|
+
function isHealthyArchive(value, issueId, state) {
|
|
942
|
+
if (!isPlainObject(value))
|
|
943
|
+
return false;
|
|
944
|
+
if (value.id !== issueId)
|
|
945
|
+
return false;
|
|
946
|
+
const entry = (state.issues ?? []).find((i) => i.id === issueId);
|
|
947
|
+
if (entry !== undefined) {
|
|
948
|
+
return (value.fingerprint === entry.fingerprint &&
|
|
949
|
+
value.summary === entry.summary &&
|
|
950
|
+
value.reportedByStepId === entry.reportedByStepId);
|
|
951
|
+
}
|
|
952
|
+
if (typeof value.fingerprint === 'string' &&
|
|
953
|
+
typeof value.summary === 'string' &&
|
|
954
|
+
typeof value.reportedByStepId === 'string') {
|
|
955
|
+
return true;
|
|
956
|
+
}
|
|
957
|
+
return value.reconstructed === true;
|
|
958
|
+
}
|
|
959
|
+
// The originally reported detail is gone with the file; `reconstructed` keeps
|
|
960
|
+
// that loss visible to whoever the digest points here.
|
|
961
|
+
function reconstructedArchiveShell(issueId, state) {
|
|
962
|
+
const entry = state.issues?.find((i) => i.id === issueId);
|
|
963
|
+
if (!entry)
|
|
964
|
+
return { id: issueId, reconstructed: true };
|
|
965
|
+
return {
|
|
966
|
+
id: entry.id,
|
|
967
|
+
fingerprint: entry.fingerprint,
|
|
968
|
+
summary: entry.summary,
|
|
969
|
+
reportedByStepId: entry.reportedByStepId,
|
|
970
|
+
applicableMigrations: Array.isArray(entry.applicableStepIds)
|
|
971
|
+
? entry.applicableStepIds.map((stepId) => state.steps.find((s) => s.id === stepId)?.migrationId ?? stepId)
|
|
972
|
+
: 'unknown',
|
|
973
|
+
reconstructed: true,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
function writeIssueArchive(runDirPath, issueId, content) {
|
|
977
|
+
(0, fs_1.mkdirSync)(issuesDir(runDirPath), { recursive: true });
|
|
978
|
+
const filePath = issueArchivePath(runDirPath, issueId);
|
|
979
|
+
(0, atomic_write_1.publishFileAtomically)(filePath, (tmpPath) => (0, fileutils_1.writeJsonFile)(tmpPath, content));
|
|
980
|
+
}
|