@north-light/crouter 0.3.281 → 0.3.283

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 (188) hide show
  1. package/dist/api/client.d.ts +19 -3
  2. package/dist/api/client.js +34 -0
  3. package/dist/api/dto/broker-ops.d.ts +11 -0
  4. package/dist/api/dto/broker.d.ts +3 -8
  5. package/dist/api/dto/common.d.ts +1 -1
  6. package/dist/api/dto/messages.d.ts +2 -2
  7. package/dist/api/dto/node-outcomes.d.ts +80 -0
  8. package/dist/api/dto/node-outcomes.js +2 -0
  9. package/dist/api/dto/nodes.d.ts +9 -0
  10. package/dist/api/dto/profiles.d.ts +5 -0
  11. package/dist/api/dto/reports.d.ts +45 -0
  12. package/dist/api/index.d.ts +1 -0
  13. package/dist/api/index.js +1 -0
  14. package/dist/api/plugin-manifest-schema.d.ts +208 -0
  15. package/dist/api/plugin-manifest-schema.js +23 -0
  16. package/dist/api/routes.d.ts +7 -0
  17. package/dist/api/routes.js +7 -0
  18. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +24 -3
  19. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +31 -4
  20. package/dist/cli.js +8 -2
  21. package/dist/clients/attach/overlays/graph.d.ts +5 -0
  22. package/dist/clients/attach/overlays/graph.js +65 -47
  23. package/dist/clients/attach/overlays/pickers.js +8 -9
  24. package/dist/clients/attach/render/card-presentation.js +1 -1
  25. package/dist/clients/attach/viewer.js +1286 -1260
  26. package/dist/commands/api-client.js +22 -23
  27. package/dist/commands/node/create.js +38 -3
  28. package/dist/commands/node/inspect.js +3 -7
  29. package/dist/commands/node/message.js +3 -3
  30. package/dist/commands/node/outcome.d.ts +2 -0
  31. package/dist/commands/node/outcome.js +134 -0
  32. package/dist/commands/node.js +2 -1
  33. package/dist/commands/pkg/browse/catalog.js +1 -1
  34. package/dist/commands/pkg/plugin-manage.d.ts +46 -1
  35. package/dist/commands/pkg/plugin-manage.js +170 -20
  36. package/dist/commands/profile/pause.js +17 -14
  37. package/dist/commands/push.js +102 -1
  38. package/dist/commands/sys/__tests__/setup-core.test.js +4 -1
  39. package/dist/commands/sys/panels/broker-limits-panel.js +7 -1
  40. package/dist/commands/sys/panels/models-panel.js +2 -2
  41. package/dist/commands/sys/settings-shell.d.ts +1 -1
  42. package/dist/commands/sys/settings-shell.js +6 -0
  43. package/dist/commands/sys/settings.js +3 -3
  44. package/dist/core/__tests__/fixtures/fake-engine.d.ts +2 -0
  45. package/dist/core/__tests__/fixtures/fake-engine.js +42 -4
  46. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  47. package/dist/core/__tests__/helpers/harness.js +9 -0
  48. package/dist/core/__tests__/integration/plugin-revalidate.test.d.ts +1 -0
  49. package/dist/core/__tests__/integration/plugin-revalidate.test.js +355 -0
  50. package/dist/core/__tests__/integration/profile-pause.test.d.ts +1 -0
  51. package/dist/core/__tests__/integration/profile-pause.test.js +115 -0
  52. package/dist/core/__tests__/node-outcome.test.d.ts +1 -0
  53. package/dist/core/__tests__/node-outcome.test.js +184 -0
  54. package/dist/core/__tests__/revive-parked-fresh.test.js +13 -20
  55. package/dist/core/__tests__/seam/broker-provider-retry.test.js +6 -2
  56. package/dist/core/__tests__/seam/dormancy-release.test.js +3 -3
  57. package/dist/core/__tests__/seam/node-outcome-delivery.test.d.ts +1 -0
  58. package/dist/core/__tests__/seam/node-outcome-delivery.test.js +153 -0
  59. package/dist/core/bash-jobs.d.ts +6 -0
  60. package/dist/core/bash-jobs.js +22 -0
  61. package/dist/core/canvas/canvas.d.ts +41 -3
  62. package/dist/core/canvas/canvas.js +153 -39
  63. package/dist/core/canvas/crons.js +2 -1
  64. package/dist/core/canvas/index.d.ts +1 -0
  65. package/dist/core/canvas/index.js +1 -0
  66. package/dist/core/canvas/migrations.js +47 -0
  67. package/dist/core/canvas/node-outcome-deliveries.d.ts +80 -0
  68. package/dist/core/canvas/node-outcome-deliveries.js +163 -0
  69. package/dist/core/canvas/pid.d.ts +7 -0
  70. package/dist/core/canvas/pid.js +17 -0
  71. package/dist/core/canvas/render-source.js +2 -2
  72. package/dist/core/canvas/types.d.ts +42 -1
  73. package/dist/core/canvas/types.js +2 -1
  74. package/dist/core/command-manifests/registry.d.ts +3 -1
  75. package/dist/core/command-manifests/schema.d.ts +2 -75
  76. package/dist/core/command-manifests/schema.js +5 -0
  77. package/dist/core/command-plugins/compose.js +1 -1
  78. package/dist/core/command-plugins/revalidate.d.ts +18 -0
  79. package/dist/core/command-plugins/revalidate.js +152 -0
  80. package/dist/core/command-plugins/transport/http-fetch.d.ts +29 -4
  81. package/dist/core/command-plugins/transport/http-fetch.js +18 -8
  82. package/dist/core/command-plugins/transport/http-invoke.js +7 -0
  83. package/dist/core/command.d.ts +8 -0
  84. package/dist/core/command.js +19 -3
  85. package/dist/core/config.d.ts +4 -1
  86. package/dist/core/config.js +25 -1
  87. package/dist/core/exclusive-lock.d.ts +12 -0
  88. package/dist/core/exclusive-lock.js +29 -0
  89. package/dist/core/feed/feed.d.ts +8 -0
  90. package/dist/core/feed/feed.js +25 -4
  91. package/dist/core/feed/inbox.d.ts +5 -0
  92. package/dist/core/feed/inbox.js +13 -0
  93. package/dist/core/installed-plugins.js +31 -0
  94. package/dist/core/keybindings/catalog.d.ts +1 -1
  95. package/dist/core/keybindings/catalog.js +3 -0
  96. package/dist/core/manifest-recovery.d.ts +15 -0
  97. package/dist/core/manifest-recovery.js +72 -0
  98. package/dist/core/manifest-stale.d.ts +18 -0
  99. package/dist/core/manifest-stale.js +39 -0
  100. package/dist/core/plugin-swap-lock.d.ts +9 -0
  101. package/dist/core/plugin-swap-lock.js +31 -0
  102. package/dist/core/profiles/manifest.d.ts +3 -1
  103. package/dist/core/profiles/manifest.js +6 -1
  104. package/dist/core/runtime/broker/daemon-ops.d.ts +3 -1
  105. package/dist/core/runtime/broker/daemon-ops.js +6 -0
  106. package/dist/core/runtime/broker/engine-drive.d.ts +2 -3
  107. package/dist/core/runtime/broker/engine-drive.js +74 -27
  108. package/dist/core/runtime/broker/frame-dispatch.js +1 -0
  109. package/dist/core/runtime/broker/read-ops.js +14 -0
  110. package/dist/core/runtime/broker/rebind.js +0 -7
  111. package/dist/core/runtime/broker-protocol.d.ts +1 -6
  112. package/dist/core/runtime/fleet.d.ts +7 -0
  113. package/dist/core/runtime/headless-pi.d.ts +13 -0
  114. package/dist/core/runtime/headless-pi.js +10 -2
  115. package/dist/core/runtime/lifecycle.d.ts +6 -1
  116. package/dist/core/runtime/lifecycle.js +50 -1
  117. package/dist/core/runtime/nodes.d.ts +5 -1
  118. package/dist/core/runtime/nodes.js +2 -1
  119. package/dist/core/runtime/outcome-document.d.ts +70 -0
  120. package/dist/core/runtime/outcome-document.js +97 -0
  121. package/dist/core/runtime/revive.js +16 -21
  122. package/dist/core/runtime/spawn.d.ts +5 -1
  123. package/dist/core/runtime/spawn.js +18 -7
  124. package/dist/core/runtime/stamp/channel.d.ts +3 -5
  125. package/dist/core/runtime/stamp/channel.js +3 -10
  126. package/dist/core/runtime/stop-guard.js +2 -2
  127. package/dist/core/runtime/structured-output.d.ts +24 -3
  128. package/dist/core/runtime/structured-output.js +42 -3
  129. package/dist/core/runtime/tmux-bindings.js +4 -0
  130. package/dist/core/runtime/warm-pool.d.ts +5 -2
  131. package/dist/core/runtime/warm-pool.js +5 -5
  132. package/dist/core/subscription-state.js +7 -5
  133. package/dist/core/substrate/render.js +10 -10
  134. package/dist/core/user-settings.d.ts +18 -1
  135. package/dist/core/user-settings.js +37 -0
  136. package/dist/daemon/__tests__/node-deadline-lane.test.d.ts +1 -0
  137. package/dist/daemon/__tests__/node-deadline-lane.test.js +76 -0
  138. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.d.ts +1 -0
  139. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +122 -0
  140. package/dist/daemon/__tests__/node-outcome-delivery.test.d.ts +1 -0
  141. package/dist/daemon/__tests__/node-outcome-delivery.test.js +86 -0
  142. package/dist/daemon/api/__tests__/broker-settle-park.test.js +86 -18
  143. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +10 -1
  144. package/dist/daemon/api/__tests__/seam/node-outcome-api.test.d.ts +1 -0
  145. package/dist/daemon/api/__tests__/seam/node-outcome-api.test.js +159 -0
  146. package/dist/daemon/api/__tests__/submit-result.test.d.ts +1 -0
  147. package/dist/daemon/api/__tests__/submit-result.test.js +214 -0
  148. package/dist/daemon/api/handlers/broker-ops.js +53 -24
  149. package/dist/daemon/api/handlers/canvas.js +2 -2
  150. package/dist/daemon/api/handlers/messages.js +12 -2
  151. package/dist/daemon/api/handlers/node-outcomes.d.ts +2 -0
  152. package/dist/daemon/api/handlers/node-outcomes.js +136 -0
  153. package/dist/daemon/api/handlers/nodes.js +67 -1
  154. package/dist/daemon/api/handlers/profiles.js +53 -1
  155. package/dist/daemon/api/handlers/reports.js +197 -33
  156. package/dist/daemon/api/map.d.ts +7 -2
  157. package/dist/daemon/api/map.js +78 -4
  158. package/dist/daemon/api/server.js +2 -0
  159. package/dist/daemon/crtrd.js +12 -0
  160. package/dist/daemon/fleet.d.ts +4 -8
  161. package/dist/daemon/fleet.js +48 -9
  162. package/dist/daemon/messaging/node-message.js +2 -1
  163. package/dist/daemon/node-outcome/deliver-outcome.d.ts +14 -0
  164. package/dist/daemon/node-outcome/deliver-outcome.js +155 -0
  165. package/dist/daemon/park-activity.d.ts +9 -0
  166. package/dist/daemon/park-activity.js +30 -0
  167. package/dist/daemon/park-pending.d.ts +10 -5
  168. package/dist/daemon/park-pending.js +18 -8
  169. package/dist/daemon/reconcilers/bash-deadline.js +6 -0
  170. package/dist/daemon/reconcilers/broker-supervision.d.ts +3 -7
  171. package/dist/daemon/reconcilers/broker-supervision.js +29 -33
  172. package/dist/daemon/reconcilers/node-deadline.d.ts +5 -0
  173. package/dist/daemon/reconcilers/node-deadline.js +82 -0
  174. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -1
  175. package/dist/daemon/reconcilers/node-lifecycle/tick.js +12 -3
  176. package/dist/daemon/reconcilers/node-outcome-delivery-lane.d.ts +10 -0
  177. package/dist/daemon/reconcilers/node-outcome-delivery-lane.js +41 -0
  178. package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +66 -66
  179. package/dist/pi-extensions/canvas-structured-output.d.ts +9 -5
  180. package/dist/pi-extensions/canvas-structured-output.js +23 -297
  181. package/dist/shared/env.d.ts +3 -0
  182. package/dist/shared/env.js +6 -0
  183. package/dist/shared/generated-context.d.ts +3 -9
  184. package/dist/shared/generated-context.js +8 -16
  185. package/dist/types.d.ts +19 -0
  186. package/dist/types.js +1 -0
  187. package/package.json +9 -8
  188. package/runtime.lock.json +227 -383
@@ -0,0 +1,355 @@
1
+ // Contract-seam tests for bundle-plugin revalidation against a stub endpoint.
2
+ // Run with: npm run test:integration
3
+ //
4
+ // Two behaviors whose absence would be SILENT — the package on disk would still
5
+ // be a complete, valid command tree, so nothing fails loudly while the client
6
+ // dispatches against a description the server retired:
7
+ //
8
+ // 1. Round trip — an install stores the served validator; a later check sends
9
+ // it conditionally, leaves the package untouched on 304, and swaps it on a
10
+ // 200 that carries different bytes.
11
+ // 2. Refetch on miss — the forced refetch a `manifest_stale` answer triggers
12
+ // reports whether the package actually CHANGED. That boolean is the whole
13
+ // branch: false means the operation is genuinely gone and the server's own
14
+ // error stands, true means the retry has a fresher tree to parse against.
15
+ // 3. Recovery from a RENAMED op, end to end and entirely off the wire: the
16
+ // stub answers the v1 op with a real 400 error envelope carrying
17
+ // `manifest_stale`, so the flag is parsed by the transport rather than
18
+ // hand-built. `runCli` renders every other failure itself, so recovery is
19
+ // unreachable unless this one class escapes it — and from the outside a
20
+ // rendered error looks identical to a recovered one. Its three legs: the
21
+ // escape, the refetch-and-replay, and exactly-once.
22
+ //
23
+ // All run against an isolated HOME and a startDir with no `.crouter` ancestor,
24
+ // so no ambient plugin or scope of the developer's machine is reachable.
25
+ import { test, describe, before, beforeEach, afterEach, after } from 'node:test';
26
+ import assert from 'node:assert/strict';
27
+ import { mkdtempSync, writeFileSync, rmSync, readFileSync, realpathSync } from 'node:fs';
28
+ import { createServer } from 'node:http';
29
+ import { tmpdir } from 'node:os';
30
+ import { join } from 'node:path';
31
+ import * as tar from 'tar';
32
+ import { resetScopeCache, userScopeRoot } from '../../scope.js';
33
+ import { readState, readConfig } from '../../config.js';
34
+ import { revalidateBundlePlugins, forceRevalidateBundlePlugin } from '../../command-plugins/revalidate.js';
35
+ import { pluginInstall } from '../../../commands/pkg/plugin-manage.js';
36
+ import { runCli } from '../../command.js';
37
+ import { buildRoot } from '../../../build-root.js';
38
+ import { dispatchWithStaleRecovery } from '../../manifest-recovery.js';
39
+ import { CrtrError } from '../../errors.js';
40
+ const PLUGIN = 'revalidate-fixture';
41
+ const ARGV = ['node', 'crtr', 'demo', 'show'];
42
+ /** The positional the fixture leaf echoes back. */
43
+ const FIXTURE_ID = 'fixture-1';
44
+ let isolatedHome;
45
+ let isolatedCwd;
46
+ let prevHome;
47
+ let prevTtl;
48
+ let prevCwd;
49
+ let server;
50
+ let endpoint;
51
+ /** What the stub serves right now, and what every BUNDLE request asked for. */
52
+ let served;
53
+ let requests;
54
+ /** Which generations of the `demo show` op this backend has retired. A call
55
+ * against a retired one gets the 400 + `manifest_stale` envelope that drives
56
+ * client recovery; any other generation is served normally. */
57
+ let retiredOps;
58
+ /** Every generation the leaf was actually invoked at, in order — `['v1','v2']`
59
+ * is one recovery: the stale call, then the replay against the fresh tree. */
60
+ let opCalls;
61
+ before(() => {
62
+ prevHome = process.env.HOME;
63
+ prevTtl = process.env.CRTR_PLUGIN_REVALIDATE_TTL_MS;
64
+ prevCwd = process.cwd();
65
+ });
66
+ beforeEach(async () => {
67
+ isolatedHome = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-revalidate-home-')));
68
+ isolatedCwd = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-revalidate-cwd-')));
69
+ process.env.HOME = isolatedHome;
70
+ process.chdir(isolatedCwd);
71
+ resetScopeCache();
72
+ served = { archive: makeArchive('v1'), etag: '"sha256-v1"' };
73
+ requests = [];
74
+ retiredOps = new Set();
75
+ opCalls = [];
76
+ server = createServer((req, res) => {
77
+ const path = (req.url ?? '/').split('?')[0] ?? '/';
78
+ const op = /^\/(v\d+)\/demo\/(.+)$/.exec(path);
79
+ if (op !== null) {
80
+ serveOp(res, op[1], op[2]);
81
+ return;
82
+ }
83
+ if (path !== '/v1/cli/bundle') {
84
+ res.writeHead(404, { 'Content-Type': 'application/json' });
85
+ res.end(JSON.stringify({ error: { code: 'not_found', message: `no route for ${path}` } }));
86
+ return;
87
+ }
88
+ const ifNoneMatch = typeof req.headers['if-none-match'] === 'string' ? req.headers['if-none-match'] : undefined;
89
+ if (ifNoneMatch === served.etag) {
90
+ requests.push({ ifNoneMatch, status: 304 });
91
+ res.writeHead(304, { ETag: served.etag, 'Cache-Control': 'private, no-cache' });
92
+ res.end();
93
+ return;
94
+ }
95
+ requests.push({ ifNoneMatch, status: 200 });
96
+ res.writeHead(200, { 'Content-Type': 'application/x-tar', ETag: served.etag, 'Cache-Control': 'private, no-cache' });
97
+ res.end(Buffer.from(served.archive));
98
+ });
99
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
100
+ const address = server.address();
101
+ if (address === null || typeof address === 'string')
102
+ throw new Error('stub server has no TCP address');
103
+ endpoint = `http://127.0.0.1:${address.port}/v1/cli/bundle`;
104
+ });
105
+ afterEach(async () => {
106
+ await new Promise((resolve) => server.close(() => resolve()));
107
+ process.chdir(prevCwd);
108
+ rmSync(isolatedHome, { recursive: true, force: true });
109
+ rmSync(isolatedCwd, { recursive: true, force: true });
110
+ });
111
+ after(() => {
112
+ process.chdir(prevCwd);
113
+ process.env.HOME = prevHome;
114
+ if (prevTtl === undefined)
115
+ delete process.env.CRTR_PLUGIN_REVALIDATE_TTL_MS;
116
+ else
117
+ process.env.CRTR_PLUGIN_REVALIDATE_TTL_MS = prevTtl;
118
+ resetScopeCache();
119
+ });
120
+ /** The `demo show` leaf, answered at whichever generation the caller's tree
121
+ * parsed the path from. A retired generation gets a real error envelope with
122
+ * `manifest_stale` set, so the flag reaches the client through the transport's
123
+ * own JSON parse rather than being constructed in the test. */
124
+ function serveOp(res, generation, id) {
125
+ opCalls.push(generation);
126
+ if (retiredOps.has(generation)) {
127
+ res.writeHead(400, { 'Content-Type': 'application/json' });
128
+ res.end(JSON.stringify({
129
+ error: {
130
+ code: 'cli_op_unknown',
131
+ message: `this backend no longer serves "demo show" at ${generation}`,
132
+ manifest_stale: true,
133
+ next: 'Refetch the plugin package and re-run.',
134
+ },
135
+ }));
136
+ return;
137
+ }
138
+ res.writeHead(200, { 'Content-Type': 'application/json' });
139
+ res.end(JSON.stringify({ id }));
140
+ }
141
+ /** One archive per generation. `marker` rides the leaf description AND the REST
142
+ * path its leaf calls, so the tree unpacked on disk says which generation
143
+ * produced it, the two generations are different bytes with different
144
+ * validators, and a call made from the OLD tree is distinguishable on the wire
145
+ * from the same call made after a swap — which is exactly what a renamed op
146
+ * looks like to a backend. */
147
+ function makeArchive(marker) {
148
+ const commands = {
149
+ schemaVersion: 1,
150
+ mounts: [
151
+ {
152
+ parent: [],
153
+ node: {
154
+ kind: 'branch',
155
+ name: 'demo',
156
+ description: `demo commands (${marker})`,
157
+ whenToUse: 'you are exercising the revalidation seam',
158
+ rootEntry: {
159
+ concept: 'a fixture command tree served by the stub endpoint',
160
+ description: `demo commands (${marker})`,
161
+ whenToUse: 'you are exercising the revalidation seam',
162
+ },
163
+ summary: `demo commands (${marker})`,
164
+ model: 'A fixture tree with one read-only leaf.',
165
+ children: [
166
+ {
167
+ kind: 'leaf',
168
+ name: 'show',
169
+ description: `show the fixture (${marker})`,
170
+ whenToUse: 'inspect the fixture record',
171
+ tier: 'important',
172
+ summary: `show the fixture record (${marker})`,
173
+ params: [{ kind: 'positional', name: 'id', required: true, constraint: 'the fixture id' }],
174
+ output: [{ name: 'id', type: 'string', required: true, constraint: 'the echoed fixture id' }],
175
+ effects: ['None. Read-only.'],
176
+ rest: { method: 'GET', path: `/${marker}/demo/{id}`, params: { id: { in: 'path' } } },
177
+ },
178
+ ],
179
+ },
180
+ },
181
+ ],
182
+ };
183
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-revalidate-tar-'));
184
+ try {
185
+ writeFileSync(join(dir, 'bundle.json'), JSON.stringify({ bundleVersion: 1 }));
186
+ writeFileSync(join(dir, 'commands.json'), JSON.stringify(commands));
187
+ const pack = tar.create({ sync: true, cwd: dir, portable: true }, ['bundle.json', 'commands.json']);
188
+ const chunks = [];
189
+ let chunk;
190
+ while ((chunk = pack.read()) !== null)
191
+ chunks.push(chunk);
192
+ return new Uint8Array(Buffer.concat(chunks));
193
+ }
194
+ finally {
195
+ rmSync(dir, { recursive: true, force: true });
196
+ }
197
+ }
198
+ async function install() {
199
+ await pluginInstall.run({ endpoint, name: PLUGIN, scope: 'user' });
200
+ }
201
+ /** The generation of the tree actually unpacked under the user scope. */
202
+ function installedMarker() {
203
+ const path = join(userScopeRoot(), 'plugins', PLUGIN, 'commands.json');
204
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
205
+ const description = parsed.mounts[0].node.description;
206
+ return description.slice(description.indexOf('(') + 1, description.indexOf(')'));
207
+ }
208
+ function pluginState() {
209
+ return readState('user').plugins[PLUGIN] ?? {};
210
+ }
211
+ function installedVersion() {
212
+ return readConfig('user').plugins[PLUGIN]?.version;
213
+ }
214
+ /** Age out the TTL without waiting one out: the pass measures freshness from
215
+ * `checked_at`, so a one-millisecond TTL plus a real pause is due. */
216
+ async function makeDue() {
217
+ process.env.CRTR_PLUGIN_REVALIDATE_TTL_MS = '1';
218
+ await new Promise((resolve) => setTimeout(resolve, 5));
219
+ }
220
+ /** Run a dispatch and collect what the caller would actually have observed:
221
+ * what reached stdout (both rendered results and rendered errors go there) and
222
+ * whether anything escaped the frame. `process.exitCode` is restored, since a
223
+ * rendered error sets it and would otherwise fail the whole test process. */
224
+ async function captured(run) {
225
+ const chunks = [];
226
+ const realWrite = process.stdout.write.bind(process.stdout);
227
+ const realExitCode = process.exitCode;
228
+ process.stdout.write = ((chunk) => {
229
+ chunks.push(String(chunk));
230
+ return true;
231
+ });
232
+ let thrown;
233
+ try {
234
+ await run();
235
+ }
236
+ catch (error) {
237
+ thrown = error;
238
+ }
239
+ finally {
240
+ process.stdout.write = realWrite;
241
+ process.exitCode = realExitCode;
242
+ }
243
+ return { stdout: chunks.join(''), thrown };
244
+ }
245
+ describe('bundle plugin revalidation', () => {
246
+ test('an install fills the cache; an unchanged bundle 304s and a changed one swaps the package', async () => {
247
+ await install();
248
+ // The install IS the first cache fill: unconditional, and it stores what
249
+ // the server named for the bytes it unpacked.
250
+ assert.deepEqual(requests, [{ ifNoneMatch: undefined, status: 200 }]);
251
+ assert.equal(pluginState().etag, '"sha256-v1"');
252
+ assert.ok(pluginState().checked_at !== undefined);
253
+ assert.equal(installedMarker(), 'v1');
254
+ const firstVersion = installedVersion();
255
+ // Inside the TTL there is nothing to do, and nothing is asked.
256
+ process.env.CRTR_PLUGIN_REVALIDATE_TTL_MS = '600000';
257
+ await revalidateBundlePlugins(ARGV);
258
+ assert.equal(requests.length, 1);
259
+ // Due, unchanged: the request carries the stored validator, the server
260
+ // answers 304, and the package on disk is left exactly as it was.
261
+ await makeDue();
262
+ const beforeCheck = pluginState().checked_at;
263
+ await revalidateBundlePlugins(ARGV);
264
+ assert.deepEqual(requests[1], { ifNoneMatch: '"sha256-v1"', status: 304 });
265
+ assert.equal(installedMarker(), 'v1');
266
+ assert.equal(installedVersion(), firstVersion);
267
+ assert.equal(pluginState().etag, '"sha256-v1"');
268
+ assert.notEqual(pluginState().checked_at, beforeCheck);
269
+ // Due, changed: the 200 swaps the package, and the new validator replaces
270
+ // the old one so the next check is conditional on what is now on disk.
271
+ served = { archive: makeArchive('v2'), etag: '"sha256-v2"' };
272
+ await makeDue();
273
+ await revalidateBundlePlugins(ARGV);
274
+ assert.deepEqual(requests[2], { ifNoneMatch: '"sha256-v1"', status: 200 });
275
+ assert.equal(installedMarker(), 'v2');
276
+ assert.notEqual(installedVersion(), firstVersion);
277
+ assert.equal(pluginState().etag, '"sha256-v2"');
278
+ });
279
+ test('a forced refetch reports whether the package changed, ignoring a fresh TTL', async () => {
280
+ await install();
281
+ // A TTL the ordinary pass would honor. The forced path must not.
282
+ process.env.CRTR_PLUGIN_REVALIDATE_TTL_MS = '600000';
283
+ // Nothing newer is served: the operation the caller invoked is genuinely
284
+ // gone, so the refetch reports no change and the server's error stands.
285
+ assert.equal(await forceRevalidateBundlePlugin(PLUGIN), false);
286
+ assert.deepEqual(requests[1], { ifNoneMatch: undefined, status: 200 });
287
+ assert.equal(installedMarker(), 'v1');
288
+ // A changed package: the retry has a fresher tree to re-parse against.
289
+ served = { archive: makeArchive('v2'), etag: '"sha256-v2"' };
290
+ assert.equal(await forceRevalidateBundlePlugin(PLUGIN), true);
291
+ assert.equal(installedMarker(), 'v2');
292
+ assert.equal(pluginState().etag, '"sha256-v2"');
293
+ // A plugin that is not installed is not a recovery path.
294
+ assert.equal(await forceRevalidateBundlePlugin('absent-plugin'), false);
295
+ });
296
+ test('the dispatcher lets a manifest_stale answer off the wire escape, and renders every other failure', async () => {
297
+ await install();
298
+ // The pass must not fire underneath these legs: every fetch below is one a
299
+ // test asked for.
300
+ process.env.CRTR_PLUGIN_REVALIDATE_TTL_MS = '600000';
301
+ retiredOps.add('v1');
302
+ const root = await buildRoot();
303
+ const stale = await captured(() => runCli(root, [...ARGV, FIXTURE_ID]));
304
+ // Rethrown, not rendered: nothing printed, and the frame above holds the
305
+ // error with the two details recovery needs.
306
+ assert.equal(stale.stdout, '');
307
+ const thrown = stale.thrown;
308
+ assert.ok(thrown instanceof CrtrError, String(thrown));
309
+ assert.equal(thrown.details?.['manifest_stale'], true);
310
+ assert.equal(thrown.details?.['plugin'], PLUGIN);
311
+ assert.deepEqual(opCalls, ['v1']);
312
+ // Every other failure stays the dispatcher's to report. The same leaf,
313
+ // missing its required positional, is rendered rather than thrown.
314
+ const usage = await captured(() => runCli(root, ARGV));
315
+ assert.equal(usage.thrown, undefined);
316
+ assert.ok(usage.stdout.includes('id'), usage.stdout);
317
+ });
318
+ test('a stale answer refetches the package and re-runs the ORIGINAL argv against the refreshed tree', async () => {
319
+ await install();
320
+ process.env.CRTR_PLUGIN_REVALIDATE_TTL_MS = '600000';
321
+ // The backend has moved the op on: it retired the generation this client
322
+ // installed, and now serves a package whose leaf calls the new path.
323
+ retiredOps.add('v1');
324
+ served = { archive: makeArchive('v2'), etag: '"sha256-v2"' };
325
+ const root = await buildRoot();
326
+ const run = await captured(() => dispatchWithStaleRecovery(root, [...ARGV, FIXTURE_ID]));
327
+ assert.equal(run.thrown, undefined);
328
+ // The stale call, one forced bundle fetch, then the replay at the NEW
329
+ // generation — which only the refreshed tree could have addressed.
330
+ assert.deepEqual(opCalls, ['v1', 'v2']);
331
+ assert.deepEqual(requests, [
332
+ { ifNoneMatch: undefined, status: 200 }, // the install
333
+ { ifNoneMatch: undefined, status: 200 }, // the forced refetch
334
+ ]);
335
+ assert.equal(installedMarker(), 'v2');
336
+ assert.ok(run.stdout.includes(FIXTURE_ID), run.stdout);
337
+ });
338
+ test('a second stale answer is rendered rather than refetched again', async () => {
339
+ await install();
340
+ process.env.CRTR_PLUGIN_REVALIDATE_TTL_MS = '600000';
341
+ // A backend that keeps answering `manifest_stale` however fresh the package
342
+ // is. The refetch genuinely changes the tree, so the retry does happen —
343
+ // and its own stale answer must end the cycle, not restart it.
344
+ retiredOps = new Set(['v1', 'v2']);
345
+ served = { archive: makeArchive('v2'), etag: '"sha256-v2"' };
346
+ const root = await buildRoot();
347
+ const run = await captured(() => dispatchWithStaleRecovery(root, [...ARGV, FIXTURE_ID]));
348
+ assert.equal(run.thrown, undefined);
349
+ assert.deepEqual(opCalls, ['v1', 'v2']);
350
+ assert.equal(requests.length, 2, 'the install plus exactly ONE forced refetch');
351
+ assert.equal(installedMarker(), 'v2');
352
+ // The backend's own second complaint is the answer the caller gets.
353
+ assert.ok(run.stdout.includes('no longer serves'), run.stdout);
354
+ });
355
+ });
@@ -0,0 +1,115 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
4
+ import { contextDir } from '../../canvas/paths.js';
5
+ import { getRow } from '../../canvas/canvas.js';
6
+ import { bashJobPaths, bashJobsDir } from '../../bash-jobs.js';
7
+ import { readCursor } from '../../feed/inbox.js';
8
+ import { transition } from '../../runtime/lifecycle.js';
9
+ import { ensureRootProfile, pauseProfile } from '../../profiles/manifest.js';
10
+ import { createHeadlessHarness } from '../helpers/harness.js';
11
+ function runningJob(nodeId) {
12
+ const dir = bashJobsDir(contextDir(nodeId));
13
+ try {
14
+ for (const jobId of readdirSync(dir)) {
15
+ const paths = bashJobPaths(contextDir(nodeId), jobId);
16
+ if (existsSync(paths.jobRun))
17
+ return paths;
18
+ }
19
+ }
20
+ catch {
21
+ // The valve has not created its job directory yet.
22
+ }
23
+ return undefined;
24
+ }
25
+ test('profile pause backgrounds real bash, retains its inbox cursor, and recovers after a quick resume without failure', { timeout: 120_000 }, async () => {
26
+ const h = await createHeadlessHarness({ sessionPrefix: 'crtr-profile-pause' });
27
+ try {
28
+ const profile = ensureRootProfile();
29
+ const parent = h.fabricateBrokerNode({ lifecycle: 'resident' });
30
+ const child = await h.spawnHeadlessChild(parent, 'stay available while the profile pauses', { profile: profile.profileId });
31
+ h.fakeCmd(child, { cmd: 'bash', command: "sh -c 'sleep 2; printf preserved'" });
32
+ const job = await h.waitFor(() => runningJob(child), { label: 'valve job.run' });
33
+ assert.ok(existsSync(job.jobRun), 'the bash valve owns the foreground command before pause');
34
+ const paused = h.cli(null, ['profile', 'pause', profile.profileId]);
35
+ assert.equal(paused.code, 0, paused.stderr);
36
+ await h.tick();
37
+ assert.equal(h.bootCount(child), 1, 'the lifecycle pass while paused launches no replacement');
38
+ const retained = h.cli(null, ['node', 'message', 'send', '--to', child, 'mail retained while profile is paused']);
39
+ assert.equal(retained.code, 0, retained.stderr);
40
+ const retainedEntry = h.inbox(child).find((entry) => entry.data?.['body'] === 'mail retained while profile is paused');
41
+ assert.ok(retainedEntry, 'the message was durably appended');
42
+ assert.equal(readCursor(child), undefined, 'pause leaves the durable inbox cursor unadvanced');
43
+ const resumed = h.cli(null, ['profile', 'resume', profile.profileId]);
44
+ assert.equal(resumed.code, 0, resumed.stderr);
45
+ await h.awaitFleetExit(child);
46
+ assert.ok(existsSync(job.jobBg), 'SIGTERM atomically handed job.run to job.bg');
47
+ assert.equal(existsSync(job.jobRun), false, 'the foreground valve ownership is gone');
48
+ await h.waitFor(() => existsSync(job.jobExit), { label: 'background bash completion' });
49
+ assert.equal(readFileSync(job.jobExit, 'utf8').trim(), '0', 'the handoff command completed after its broker exited');
50
+ assert.equal(readCursor(child), undefined, 'neither retained message nor background completion advanced the stopped broker cursor');
51
+ await h.tick();
52
+ assert.equal(h.bootCount(child), 1, 'the first resumed policy pass only consumes the pause interruption');
53
+ assert.equal(getRow(child)?.respawn_failures, 0, 'the intentional SIGTERM never counts as a crash');
54
+ await h.tick();
55
+ // `#respawn`'s backoff-assessment defers this exact relaunch on its first
56
+ // assessment: this node's original spawn (`spawnChild`'s direct
57
+ // `headlessBrokerHost.launch`) never records `launched_at`, so
58
+ // `decideRespawnAttempt` can never see `last_settled_at > launched_at` on
59
+ // this first-ever `#respawn` call for the row and always takes the
60
+ // `respawn_not_before` backoff branch (fleet.ts's RESPAWN_BACKOFF_BASE_MS,
61
+ // currently 5s) instead of launching. A THIRD tick, issued once real time
62
+ // has passed that window, finds `respawn_not_before` already set (so
63
+ // `#respawn` skips reassessment this time) and actually calls `reviveNode`.
64
+ // Real-time sleep, not a fake clock: `now()` here is the daemon's real
65
+ // `Date.now`, so only the wall clock — not another tick — advances it.
66
+ await new Promise((r) => setTimeout(r, 5_500));
67
+ await h.tick();
68
+ await h.awaitBoot(child, { minCount: 2, timeoutMs: 30_000 });
69
+ const deliveries = await h.awaitWake(child, { match: /mail retained while profile is paused/ });
70
+ assert.ok(deliveries.some((content) => /mail retained while profile is paused/.test(content)), 'the resumed watcher delivers the retained backlog');
71
+ }
72
+ finally {
73
+ await h.dispose();
74
+ }
75
+ });
76
+ test('profile pause preserves an idle-release row so mail queued during pause wakes it', { timeout: 120_000 }, async () => {
77
+ const h = await createHeadlessHarness({ sessionPrefix: 'crtr-profile-pause-idle' });
78
+ try {
79
+ const profile = ensureRootProfile();
80
+ const parent = h.fabricateBrokerNode({ lifecycle: 'resident' });
81
+ const child = await h.spawnHeadlessChild(parent, 'await ordinary mail', { profile: profile.profileId, lifecycle: 'resident' });
82
+ transition(child, 'release');
83
+ const paused = h.cli(null, ['profile', 'pause', profile.profileId]);
84
+ assert.equal(paused.code, 0, paused.stderr);
85
+ const queued = h.cli(null, ['node', 'message', 'send', '--to', child, 'ordinary mail queued during pause']);
86
+ assert.equal(queued.code, 0, queued.stderr);
87
+ await h.awaitFleetExit(child);
88
+ const resumed = h.cli(null, ['profile', 'resume', profile.profileId]);
89
+ assert.equal(resumed.code, 0, resumed.stderr);
90
+ await h.tick();
91
+ await h.tick();
92
+ await h.awaitBoot(child, { minCount: 2, timeoutMs: 60_000 });
93
+ const deliveries = await h.awaitWake(child, { match: /ordinary mail queued during pause/ });
94
+ assert.ok(deliveries.some((content) => /ordinary mail queued during pause/.test(content)), 'the idle-release wake is not stranded behind the pause marker');
95
+ }
96
+ finally {
97
+ await h.dispose();
98
+ }
99
+ });
100
+ test('lifecycle catches a broker that became live after the pause command fast scan', { timeout: 60_000 }, async () => {
101
+ const h = await createHeadlessHarness({ sessionPrefix: 'crtr-profile-pause-race' });
102
+ try {
103
+ const profile = ensureRootProfile();
104
+ const parent = h.fabricateBrokerNode({ lifecycle: 'resident' });
105
+ const child = await h.spawnHeadlessChild(parent, 'broker launched after pause scan', { profile: profile.profileId });
106
+ pauseProfile(profile.profileId);
107
+ await h.tick();
108
+ await h.awaitFleetExit(child);
109
+ assert.equal(h.bootCount(child), 1, 'the paused lifecycle tick terminates the late live broker instead of leaving it running');
110
+ assert.equal(getRow(child)?.respawn_failures, 0, 'the late broker interruption has no failure cost');
111
+ }
112
+ finally {
113
+ await h.dispose();
114
+ }
115
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,184 @@
1
+ import { after, afterEach, beforeEach, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { closeDb, MIGRATIONS } from '../canvas/db.js';
8
+ import { createNode, getRow } from '../canvas/canvas.js';
9
+ import { armOutcomeDelivery, claimOutcomeDelivery, outcomeDeliveryBackoffMs, readOutcomeDelivery, recoverRunningOutcomeDeliveries, settleOutcomeDelivery, } from '../canvas/node-outcome-deliveries.js';
10
+ import { transition } from '../runtime/lifecycle.js';
11
+ import { composeNodeOutcomeDocument } from '../runtime/outcome-document.js';
12
+ import { pushFinal } from '../feed/feed.js';
13
+ import { OUTCOME_PAYLOAD_MAX_BYTES } from '../canvas/types.js';
14
+ let home;
15
+ const priorHome = process.env['CRTR_HOME'];
16
+ function node(nodeId) {
17
+ return {
18
+ node_id: nodeId,
19
+ name: nodeId,
20
+ created: new Date().toISOString(),
21
+ cwd: home,
22
+ kind: 'general',
23
+ mode: 'base',
24
+ lifecycle: 'terminal',
25
+ status: 'active',
26
+ };
27
+ }
28
+ beforeEach(() => {
29
+ closeDb();
30
+ home = mkdtempSync(join(tmpdir(), 'crtr-node-outcome-'));
31
+ process.env['CRTR_HOME'] = home;
32
+ });
33
+ afterEach(() => {
34
+ closeDb();
35
+ rmSync(home, { recursive: true, force: true });
36
+ });
37
+ after(() => {
38
+ closeDb();
39
+ if (priorHome === undefined)
40
+ delete process.env['CRTR_HOME'];
41
+ else
42
+ process.env['CRTR_HOME'] = priorHome;
43
+ });
44
+ test('the terminal outcome latch survives competing terminal transitions and revive', () => {
45
+ createNode(node('latched'));
46
+ transition('latched', 'finish');
47
+ transition('latched', 'cancel');
48
+ let row = getRow('latched');
49
+ assert.deepEqual({ kind: row.outcome_kind, reason: row.outcome_reason, revision: row.outcome_revision }, {
50
+ kind: 'failure', reason: 'finished', revision: 1,
51
+ });
52
+ transition('latched', 'revive');
53
+ row = getRow('latched');
54
+ assert.equal(row.outcome_at === null, false, 'revive must not clear a latched outcome');
55
+ assert.equal(row.outcome_revision, 1);
56
+ });
57
+ test('outcome detail keeps only a 500-byte first line', () => {
58
+ createNode(node('detail'));
59
+ transition('detail', 'crash', {
60
+ outcome: { detail: { schema: 'crtr.node-outcome-detail/v1', message: `${'x'.repeat(1_000)}\nignored` } },
61
+ });
62
+ const detail = JSON.parse(getRow('detail').outcome_detail_json);
63
+ assert.equal(Buffer.byteLength(detail.message, 'utf8'), 500);
64
+ assert.equal(detail.message.includes('\n'), false);
65
+ });
66
+ test('the outcome composer is total and omits an oversized structured result', () => {
67
+ const meta = node('document');
68
+ meta.description = 'x'.repeat(2 * 1024);
69
+ createNode(meta);
70
+ const document = composeNodeOutcomeDocument({
71
+ node: meta,
72
+ outcome: { kind: 'result', reason: 'finalized', at: new Date().toISOString(), revision: 1, detailJson: null },
73
+ action: { name: 'test-action', payload: { callback: 'one' } },
74
+ finalReport: 'final.md',
75
+ structuredResult: { value: 'y'.repeat(300 * 1024) },
76
+ });
77
+ assert.ok(Buffer.byteLength(JSON.stringify(document), 'utf8') <= 256 * 1024);
78
+ assert.equal(Buffer.byteLength(document.node.description, 'utf8') <= 1024, true);
79
+ assert.equal(document.result?.structuredResult, undefined);
80
+ assert.equal(document.result?.structuredResultOmitted, true);
81
+ });
82
+ test('the outcome composer caps a failure document with an oversized node name', () => {
83
+ const meta = node('failure-document');
84
+ meta.name = 'x'.repeat(300 * 1024);
85
+ const document = composeNodeOutcomeDocument({
86
+ node: meta,
87
+ outcome: { kind: 'failure', reason: 'crashed', at: new Date().toISOString(), revision: 1, detailJson: null },
88
+ action: { name: 'test-action', payload: { callback: 'one' } },
89
+ });
90
+ assert.ok(Buffer.byteLength(JSON.stringify(document), 'utf8') <= 256 * 1024);
91
+ assert.ok(Buffer.byteLength(document.node.name, 'utf8') <= 1024);
92
+ assert.equal(document.result, undefined);
93
+ assert.ok(document.failure);
94
+ });
95
+ test('a canonical final supersedes a prior failure outcome and increments revision', async () => {
96
+ createNode(node('supersede'));
97
+ transition('supersede', 'crash');
98
+ transition('supersede', 'revive');
99
+ await pushFinal('supersede', 'recovered and finished');
100
+ const row = getRow('supersede');
101
+ assert.deepEqual({ kind: row.outcome_kind, reason: row.outcome_reason, revision: row.outcome_revision }, {
102
+ kind: 'result', reason: 'finalized', revision: 2,
103
+ });
104
+ assert.equal(row.outcome_detail_json, null);
105
+ });
106
+ test('outcome delivery rejects an oversized payload before a node is armed', () => {
107
+ createNode(node('oversized-payload'));
108
+ assert.throws(() => armOutcomeDelivery({
109
+ nodeId: 'oversized-payload', actionName: 'test-action', argv: ['/bin/true'], cwd: home, configOrigin: 'test', payload: { value: 'x'.repeat(OUTCOME_PAYLOAD_MAX_BYTES) },
110
+ }), /exceeds/);
111
+ assert.equal(readOutcomeDelivery('oversized-payload'), null);
112
+ });
113
+ test('outcome delivery armed after settlement is born pending with its document', () => {
114
+ createNode(node('settled-delivery'));
115
+ transition('settled-delivery', 'crash');
116
+ const delivery = armOutcomeDelivery({
117
+ nodeId: 'settled-delivery', actionName: 'test-action', argv: ['/bin/true'], cwd: home, configOrigin: 'test', payload: { callback: 'one' }, now: 123,
118
+ });
119
+ assert.equal(delivery?.state, 'pending');
120
+ assert.equal(delivery?.nextAttemptAt, 123);
121
+ assert.deepEqual(delivery?.document && {
122
+ schema: delivery.document.schema,
123
+ outcomeId: delivery.document.outcomeId,
124
+ action: delivery.document.action,
125
+ }, {
126
+ schema: 'crtr.node-outcome/v1',
127
+ outcomeId: 'settled-delivery:1',
128
+ action: { name: 'test-action', payload: { callback: 'one' } },
129
+ });
130
+ });
131
+ test('outcome delivery state is fenced, recoverable, and follows the durable backoff', () => {
132
+ createNode(node('delivery'));
133
+ assert.ok(armOutcomeDelivery({
134
+ nodeId: 'delivery', actionName: 'test-action', argv: ['/bin/true'], cwd: home, configOrigin: 'test', payload: { callback: 'one' }, now: 100,
135
+ }));
136
+ transition('delivery', 'crash');
137
+ assert.equal(readOutcomeDelivery('delivery')?.state, 'pending');
138
+ assert.ok(readOutcomeDelivery('delivery')?.document, 'the armed row is stamped with its outcome document');
139
+ const first = claimOutcomeDelivery('delivery', Date.now(), 'daemon-a');
140
+ assert.equal(first.state, 'running');
141
+ assert.equal(settleOutcomeDelivery('delivery', first.attempt, 'daemon-b', { state: 'accepted' }, Date.now()), false, 'a stale claimant cannot settle');
142
+ assert.equal(settleOutcomeDelivery('delivery', first.attempt, 'daemon-a', { state: 'accepted' }, Date.now()), true);
143
+ assert.equal(readOutcomeDelivery('delivery')?.state, 'accepted');
144
+ createNode(node('recover'));
145
+ armOutcomeDelivery({ nodeId: 'recover', actionName: 'test-action', argv: ['/bin/true'], cwd: home, configOrigin: 'test', now: 100 });
146
+ transition('recover', 'crash');
147
+ const running = claimOutcomeDelivery('recover', Date.now(), 'daemon-a');
148
+ assert.equal(running.state, 'running');
149
+ const recoveredAt = Date.now();
150
+ assert.equal(recoverRunningOutcomeDeliveries(recoveredAt), 1);
151
+ assert.deepEqual(readOutcomeDelivery('recover') && {
152
+ state: readOutcomeDelivery('recover').state,
153
+ nextAttemptAt: readOutcomeDelivery('recover').nextAttemptAt,
154
+ claimOwner: readOutcomeDelivery('recover').claimOwner,
155
+ }, { state: 'pending', nextAttemptAt: recoveredAt, claimOwner: null });
156
+ assert.equal(outcomeDeliveryBackoffMs(1), 5_000);
157
+ assert.equal(outcomeDeliveryBackoffMs(2), 10_000);
158
+ assert.equal(outcomeDeliveryBackoffMs(3), 20_000);
159
+ assert.equal(outcomeDeliveryBackoffMs(11), 3_600_000);
160
+ assert.equal(outcomeDeliveryBackoffMs(100), 3_600_000);
161
+ });
162
+ test('v39 migrates a v38 database and backfills finalized and terminal rows', () => {
163
+ const dbPath = join(home, 'v38.db');
164
+ mkdirSync(join(home, 'nodes'), { recursive: true });
165
+ const db = new DatabaseSync(dbPath);
166
+ db.exec('PRAGMA foreign_keys = ON;');
167
+ for (const migration of MIGRATIONS.slice(0, 38))
168
+ migration(db);
169
+ db.prepare(`INSERT INTO nodes (node_id, name, kind, mode, lifecycle, status, cwd, created, final_report, finalized_at, terminal_reason)
170
+ VALUES (?, ?, 'general', 'base', 'terminal', ?, ?, ?, ?, ?, ?)`)
171
+ .run('finalized', 'finalized', 'done', home, '2026-01-01T00:00:00.000Z', 'report.md', '2026-01-01T01:00:00.000Z', 'finalized');
172
+ db.prepare(`INSERT INTO nodes (node_id, name, kind, mode, lifecycle, status, cwd, created, terminal_reason)
173
+ VALUES (?, ?, 'general', 'base', 'terminal', ?, ?, ?, ?)`)
174
+ .run('dead', 'dead', 'dead', home, '2026-01-02T00:00:00.000Z', 'crashed');
175
+ MIGRATIONS[38](db);
176
+ const rows = db.prepare('SELECT node_id, outcome_kind, outcome_reason, outcome_at, outcome_revision FROM nodes ORDER BY node_id').all().map((row) => ({ ...row }));
177
+ assert.deepEqual(rows, [
178
+ { node_id: 'dead', outcome_kind: 'failure', outcome_reason: 'crashed', outcome_at: '2026-01-02T00:00:00.000Z', outcome_revision: 1 },
179
+ { node_id: 'finalized', outcome_kind: 'result', outcome_reason: 'finalized', outcome_at: '2026-01-01T01:00:00.000Z', outcome_revision: 1 },
180
+ ]);
181
+ assert.ok(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'node_outcome_deliveries'").get());
182
+ assert.ok(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_nodes_deadline'").get());
183
+ db.close();
184
+ });