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.
Files changed (149) hide show
  1. package/dist/src/command-line/format/format.js +2 -18
  2. package/dist/src/command-line/graph/graph.js +42 -3
  3. package/dist/src/command-line/init/implementation/format.js +2 -3
  4. package/dist/src/command-line/migrate/agentic/definitions.js +75 -19
  5. package/dist/src/command-line/migrate/agentic/format-command.d.ts +14 -0
  6. package/dist/src/command-line/migrate/agentic/format-command.js +44 -0
  7. package/dist/src/command-line/migrate/agentic/handoff-gitignore.d.ts +3 -3
  8. package/dist/src/command-line/migrate/agentic/handoff-gitignore.js +3 -3
  9. package/dist/src/command-line/migrate/agentic/handoff.d.ts +27 -17
  10. package/dist/src/command-line/migrate/agentic/handoff.js +107 -24
  11. package/dist/src/command-line/migrate/agentic/instruction-files.d.ts +21 -0
  12. package/dist/src/command-line/migrate/agentic/instruction-files.js +36 -0
  13. package/dist/src/command-line/migrate/agentic/prompts/fragments.d.ts +16 -0
  14. package/dist/src/command-line/migrate/agentic/prompts/fragments.js +70 -0
  15. package/dist/src/command-line/migrate/agentic/prompts/generic-validation.js +4 -3
  16. package/dist/src/command-line/migrate/agentic/prompts/system-prompt.d.ts +25 -1
  17. package/dist/src/command-line/migrate/agentic/prompts/system-prompt.js +57 -34
  18. package/dist/src/command-line/migrate/agentic/run-step.js +28 -4
  19. package/dist/src/command-line/migrate/agentic/runner.d.ts +17 -8
  20. package/dist/src/command-line/migrate/agentic/runner.js +110 -55
  21. package/dist/src/command-line/migrate/agentic/types.d.ts +23 -8
  22. package/dist/src/command-line/migrate/agentic/types.js +10 -7
  23. package/dist/src/command-line/migrate/migrate-commits.d.ts +1 -0
  24. package/dist/src/command-line/migrate/migrate-commits.js +9 -0
  25. package/dist/src/command-line/migrate/migrate-output.d.ts +3 -7
  26. package/dist/src/command-line/migrate/migrate-output.js +3 -7
  27. package/dist/src/command-line/migrate/migrate.js +21 -8
  28. package/dist/src/command-line/migrate/run/agent-output.d.ts +10 -0
  29. package/dist/src/command-line/migrate/run/agent-output.js +16 -2
  30. package/dist/src/command-line/migrate/run/agent-work-payload.d.ts +37 -0
  31. package/dist/src/command-line/migrate/run/agent-work-payload.js +142 -0
  32. package/dist/src/command-line/migrate/run/atomic-write.d.ts +8 -0
  33. package/dist/src/command-line/migrate/run/atomic-write.js +17 -0
  34. package/dist/src/command-line/migrate/run/index.d.ts +1 -1
  35. package/dist/src/command-line/migrate/run/issues.d.ts +106 -0
  36. package/dist/src/command-line/migrate/run/issues.js +980 -0
  37. package/dist/src/command-line/migrate/run/orchestrator.d.ts +1 -0
  38. package/dist/src/command-line/migrate/run/orchestrator.js +662 -105
  39. package/dist/src/command-line/migrate/run/run-state.d.ts +43 -8
  40. package/dist/src/command-line/migrate/run/run-state.js +184 -20
  41. package/dist/src/command-line/migrate/run/runbook.d.ts +17 -0
  42. package/dist/src/command-line/migrate/run/runbook.js +97 -0
  43. package/dist/src/command-line/migrate/run/state-lock.d.ts +2 -2
  44. package/dist/src/command-line/migrate/run/state-lock.js +2 -2
  45. package/dist/src/command-line/migrate/run/state-machine.d.ts +5 -1
  46. package/dist/src/command-line/migrate/run/state-machine.js +27 -1
  47. package/dist/src/command-line/migrate/run/worker.js +206 -56
  48. package/dist/src/command-line/nx-cloud/connect/connect-to-nx-cloud.js +5 -1
  49. package/dist/src/command-line/release/publish.js +11 -4
  50. package/dist/src/command-line/release/utils/remote-release-clients/github.js +3 -3
  51. package/dist/src/command-line/release/utils/remote-release-clients/gitlab.js +3 -3
  52. package/dist/src/command-line/release/utils/remote-release-clients/remote-release-client.d.ts +2 -0
  53. package/dist/src/command-line/release/utils/remote-release-clients/remote-release-client.js +35 -0
  54. package/dist/src/command-line/yargs-utils/shared-options.d.ts +1 -1
  55. package/dist/src/command-line/yargs-utils/shared-options.js +22 -4
  56. package/dist/src/config/workspace-json-project-json.d.ts +2 -0
  57. package/dist/src/core/graph/main.js +1 -1
  58. package/dist/src/daemon/client/client.d.ts +4 -2
  59. package/dist/src/daemon/client/client.js +20 -11
  60. package/dist/src/daemon/message-types/run-tasks-execution-hooks.d.ts +3 -2
  61. package/dist/src/daemon/server/handle-hash-tasks.d.ts +8 -2
  62. package/dist/src/daemon/server/handle-hash-tasks.js +16 -3
  63. package/dist/src/daemon/server/handle-outputs-changes.d.ts +2 -2
  64. package/dist/src/daemon/server/handle-outputs-changes.js +21 -6
  65. package/dist/src/daemon/server/handle-tasks-execution-hooks.d.ts +3 -2
  66. package/dist/src/daemon/server/outputs-tracking.d.ts +6 -0
  67. package/dist/src/daemon/server/outputs-tracking.js +18 -0
  68. package/dist/src/daemon/server/project-graph-incremental-recomputation.d.ts +22 -7
  69. package/dist/src/daemon/server/project-graph-incremental-recomputation.js +99 -69
  70. package/dist/src/daemon/server/restart-checks.d.ts +22 -0
  71. package/dist/src/daemon/server/restart-checks.js +77 -0
  72. package/dist/src/daemon/server/server.js +28 -18
  73. package/dist/src/daemon/server/shutdown-utils.d.ts +0 -5
  74. package/dist/src/daemon/server/shutdown-utils.js +3 -26
  75. package/dist/src/hasher/hash-task.js +20 -16
  76. package/dist/src/hasher/native-task-hasher-impl.d.ts +8 -0
  77. package/dist/src/hasher/native-task-hasher-impl.js +46 -3
  78. package/dist/src/hasher/task-hasher.d.ts +9 -0
  79. package/dist/src/hasher/task-hasher.js +14 -0
  80. package/dist/src/native/index.d.ts +202 -21
  81. package/dist/src/native/index.js +3 -3
  82. package/dist/src/native/native-bindings.js +3 -1
  83. package/dist/src/native/nx.wasm32-wasi.debug.wasm +0 -0
  84. package/dist/src/native/nx.wasm32-wasi.wasm +0 -0
  85. package/dist/src/plugins/js/lock-file/pnpm-parser.js +1 -1
  86. package/dist/src/plugins/js/lock-file/utils/pnpm-normalizer.d.ts +2 -0
  87. package/dist/src/plugins/js/lock-file/utils/pnpm-normalizer.js +22 -0
  88. package/dist/src/plugins/js/utils/register.js +2 -1
  89. package/dist/src/project-graph/plugins/isolation/isolated-plugin.js +2 -1
  90. package/dist/src/project-graph/plugins/isolation/messaging.d.ts +3 -2
  91. package/dist/src/project-graph/plugins/isolation/plugin-worker.js +5 -1
  92. package/dist/src/project-graph/plugins/loaded-nx-plugin.d.ts +3 -2
  93. package/dist/src/project-graph/plugins/loaded-nx-plugin.js +5 -1
  94. package/dist/src/project-graph/plugins/task-results-stub.d.ts +50 -0
  95. package/dist/src/project-graph/plugins/task-results-stub.js +111 -0
  96. package/dist/src/project-graph/plugins/tasks-execution-hooks.d.ts +3 -2
  97. package/dist/src/project-graph/plugins/tasks-execution-hooks.js +2 -1
  98. package/dist/src/project-graph/project-graph.js +2 -0
  99. package/dist/src/tasks-runner/batch/run-batch.js +15 -0
  100. package/dist/src/tasks-runner/cache.d.ts +35 -0
  101. package/dist/src/tasks-runner/cache.js +40 -0
  102. package/dist/src/tasks-runner/create-task-graph.js +1 -0
  103. package/dist/src/tasks-runner/default-tasks-runner.js +1 -1
  104. package/dist/src/tasks-runner/forked-process-task-runner.d.ts +1 -1
  105. package/dist/src/tasks-runner/forked-process-task-runner.js +8 -7
  106. package/dist/src/tasks-runner/init-tasks-runner.js +5 -1
  107. package/dist/src/tasks-runner/is-tui-enabled.js +4 -3
  108. package/dist/src/tasks-runner/life-cycle.d.ts +7 -0
  109. package/dist/src/tasks-runner/life-cycle.js +7 -0
  110. package/dist/src/tasks-runner/life-cycles/summary-terminal-output-life-cycle.d.ts +35 -0
  111. package/dist/src/tasks-runner/life-cycles/summary-terminal-output-life-cycle.js +146 -0
  112. package/dist/src/tasks-runner/prune-task-graph.d.ts +17 -0
  113. package/dist/src/tasks-runner/prune-task-graph.js +45 -0
  114. package/dist/src/tasks-runner/run-command.js +14 -3
  115. package/dist/src/tasks-runner/running-tasks/batch-process.d.ts +93 -19
  116. package/dist/src/tasks-runner/running-tasks/batch-process.js +184 -88
  117. package/dist/src/tasks-runner/task-orchestrator.d.ts +64 -2
  118. package/dist/src/tasks-runner/task-orchestrator.js +232 -46
  119. package/dist/src/tasks-runner/terminal-output-path.d.ts +11 -0
  120. package/dist/src/tasks-runner/terminal-output-path.js +18 -0
  121. package/dist/src/tasks-runner/utils.js +5 -2
  122. package/dist/src/utils/ab-testing.d.ts +3 -0
  123. package/dist/src/utils/ab-testing.js +11 -3
  124. package/dist/src/utils/chunkify.d.ts +1 -7
  125. package/dist/src/utils/chunkify.js +2 -8
  126. package/dist/src/utils/command-line-utils.d.ts +19 -0
  127. package/dist/src/utils/command-line-utils.js +10 -3
  128. package/dist/src/utils/formatters/index.d.ts +6 -0
  129. package/dist/src/utils/formatters/index.js +5 -0
  130. package/dist/src/utils/formatters/prettier.d.ts +0 -8
  131. package/dist/src/utils/formatters/prettier.js +4 -26
  132. package/dist/src/utils/installed-nx-version.js +2 -48
  133. package/dist/src/utils/min-release-age/packument.js +1 -1
  134. package/dist/src/utils/output.d.ts +22 -3
  135. package/dist/src/utils/output.js +28 -5
  136. package/dist/src/utils/package-json.d.ts +25 -0
  137. package/dist/src/utils/package-json.js +41 -6
  138. package/dist/src/utils/package-manager.d.ts +6 -0
  139. package/dist/src/utils/package-manager.js +10 -0
  140. package/dist/src/utils/provenance.js +20 -4
  141. package/dist/src/utils/workspace-context.d.ts +47 -3
  142. package/dist/src/utils/workspace-context.js +162 -10
  143. package/package.json +12 -12
  144. package/schemas/nx-schema.json +4 -0
  145. package/schemas/project-schema.json +4 -0
  146. package/dist/src/daemon/server/file-watching/route-workspace-changes.d.ts +0 -9
  147. package/dist/src/daemon/server/file-watching/route-workspace-changes.js +0 -76
  148. package/dist/src/daemon/server/watcher.d.ts +0 -18
  149. 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
+ }