@north-light/crouter 0.3.282 → 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 (160) 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/routes.d.ts +7 -0
  15. package/dist/api/routes.js +7 -0
  16. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +24 -3
  17. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +31 -4
  18. package/dist/clients/attach/overlays/graph.d.ts +5 -0
  19. package/dist/clients/attach/overlays/graph.js +65 -47
  20. package/dist/clients/attach/overlays/pickers.js +8 -9
  21. package/dist/clients/attach/render/card-presentation.js +1 -1
  22. package/dist/clients/attach/viewer.js +1286 -1260
  23. package/dist/commands/api-client.js +22 -23
  24. package/dist/commands/node/create.js +38 -3
  25. package/dist/commands/node/inspect.js +3 -7
  26. package/dist/commands/node/message.js +3 -3
  27. package/dist/commands/node/outcome.d.ts +2 -0
  28. package/dist/commands/node/outcome.js +134 -0
  29. package/dist/commands/node.js +2 -1
  30. package/dist/commands/profile/pause.js +17 -14
  31. package/dist/commands/push.js +102 -1
  32. package/dist/commands/sys/__tests__/setup-core.test.js +4 -1
  33. package/dist/commands/sys/panels/broker-limits-panel.js +7 -1
  34. package/dist/commands/sys/panels/models-panel.js +2 -2
  35. package/dist/commands/sys/settings-shell.d.ts +1 -1
  36. package/dist/commands/sys/settings-shell.js +6 -0
  37. package/dist/commands/sys/settings.js +3 -3
  38. package/dist/core/__tests__/fixtures/fake-engine.d.ts +2 -0
  39. package/dist/core/__tests__/fixtures/fake-engine.js +42 -4
  40. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  41. package/dist/core/__tests__/helpers/harness.js +9 -0
  42. package/dist/core/__tests__/integration/profile-pause.test.d.ts +1 -0
  43. package/dist/core/__tests__/integration/profile-pause.test.js +115 -0
  44. package/dist/core/__tests__/node-outcome.test.d.ts +1 -0
  45. package/dist/core/__tests__/node-outcome.test.js +184 -0
  46. package/dist/core/__tests__/revive-parked-fresh.test.js +13 -20
  47. package/dist/core/__tests__/seam/broker-provider-retry.test.js +6 -2
  48. package/dist/core/__tests__/seam/dormancy-release.test.js +3 -3
  49. package/dist/core/__tests__/seam/node-outcome-delivery.test.d.ts +1 -0
  50. package/dist/core/__tests__/seam/node-outcome-delivery.test.js +153 -0
  51. package/dist/core/bash-jobs.d.ts +6 -0
  52. package/dist/core/bash-jobs.js +22 -0
  53. package/dist/core/canvas/canvas.d.ts +41 -3
  54. package/dist/core/canvas/canvas.js +153 -39
  55. package/dist/core/canvas/crons.js +2 -1
  56. package/dist/core/canvas/index.d.ts +1 -0
  57. package/dist/core/canvas/index.js +1 -0
  58. package/dist/core/canvas/migrations.js +47 -0
  59. package/dist/core/canvas/node-outcome-deliveries.d.ts +80 -0
  60. package/dist/core/canvas/node-outcome-deliveries.js +163 -0
  61. package/dist/core/canvas/pid.d.ts +7 -0
  62. package/dist/core/canvas/pid.js +17 -0
  63. package/dist/core/canvas/render-source.js +2 -2
  64. package/dist/core/canvas/types.d.ts +42 -1
  65. package/dist/core/canvas/types.js +2 -1
  66. package/dist/core/config.d.ts +4 -1
  67. package/dist/core/config.js +25 -1
  68. package/dist/core/feed/feed.d.ts +8 -0
  69. package/dist/core/feed/feed.js +25 -4
  70. package/dist/core/feed/inbox.d.ts +5 -0
  71. package/dist/core/feed/inbox.js +13 -0
  72. package/dist/core/keybindings/catalog.d.ts +1 -1
  73. package/dist/core/keybindings/catalog.js +3 -0
  74. package/dist/core/profiles/manifest.d.ts +3 -1
  75. package/dist/core/profiles/manifest.js +6 -1
  76. package/dist/core/runtime/broker/daemon-ops.d.ts +3 -1
  77. package/dist/core/runtime/broker/daemon-ops.js +6 -0
  78. package/dist/core/runtime/broker/engine-drive.d.ts +2 -3
  79. package/dist/core/runtime/broker/engine-drive.js +74 -27
  80. package/dist/core/runtime/broker/frame-dispatch.js +1 -0
  81. package/dist/core/runtime/broker/read-ops.js +14 -0
  82. package/dist/core/runtime/broker/rebind.js +0 -7
  83. package/dist/core/runtime/broker-protocol.d.ts +1 -6
  84. package/dist/core/runtime/fleet.d.ts +7 -0
  85. package/dist/core/runtime/headless-pi.d.ts +13 -0
  86. package/dist/core/runtime/headless-pi.js +10 -2
  87. package/dist/core/runtime/lifecycle.d.ts +6 -1
  88. package/dist/core/runtime/lifecycle.js +50 -1
  89. package/dist/core/runtime/nodes.d.ts +5 -1
  90. package/dist/core/runtime/nodes.js +2 -1
  91. package/dist/core/runtime/outcome-document.d.ts +70 -0
  92. package/dist/core/runtime/outcome-document.js +97 -0
  93. package/dist/core/runtime/revive.js +16 -21
  94. package/dist/core/runtime/spawn.d.ts +5 -1
  95. package/dist/core/runtime/spawn.js +18 -7
  96. package/dist/core/runtime/stamp/channel.d.ts +3 -5
  97. package/dist/core/runtime/stamp/channel.js +3 -10
  98. package/dist/core/runtime/stop-guard.js +2 -2
  99. package/dist/core/runtime/structured-output.d.ts +24 -3
  100. package/dist/core/runtime/structured-output.js +42 -3
  101. package/dist/core/runtime/tmux-bindings.js +4 -0
  102. package/dist/core/runtime/warm-pool.d.ts +5 -2
  103. package/dist/core/runtime/warm-pool.js +5 -5
  104. package/dist/core/subscription-state.js +7 -5
  105. package/dist/core/substrate/render.js +10 -10
  106. package/dist/core/user-settings.d.ts +18 -1
  107. package/dist/core/user-settings.js +37 -0
  108. package/dist/daemon/__tests__/node-deadline-lane.test.d.ts +1 -0
  109. package/dist/daemon/__tests__/node-deadline-lane.test.js +76 -0
  110. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.d.ts +1 -0
  111. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +122 -0
  112. package/dist/daemon/__tests__/node-outcome-delivery.test.d.ts +1 -0
  113. package/dist/daemon/__tests__/node-outcome-delivery.test.js +86 -0
  114. package/dist/daemon/api/__tests__/broker-settle-park.test.js +86 -18
  115. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +10 -1
  116. package/dist/daemon/api/__tests__/seam/node-outcome-api.test.d.ts +1 -0
  117. package/dist/daemon/api/__tests__/seam/node-outcome-api.test.js +159 -0
  118. package/dist/daemon/api/__tests__/submit-result.test.d.ts +1 -0
  119. package/dist/daemon/api/__tests__/submit-result.test.js +214 -0
  120. package/dist/daemon/api/handlers/broker-ops.js +53 -24
  121. package/dist/daemon/api/handlers/canvas.js +2 -2
  122. package/dist/daemon/api/handlers/messages.js +12 -2
  123. package/dist/daemon/api/handlers/node-outcomes.d.ts +2 -0
  124. package/dist/daemon/api/handlers/node-outcomes.js +136 -0
  125. package/dist/daemon/api/handlers/nodes.js +67 -1
  126. package/dist/daemon/api/handlers/profiles.js +53 -1
  127. package/dist/daemon/api/handlers/reports.js +197 -33
  128. package/dist/daemon/api/map.d.ts +7 -2
  129. package/dist/daemon/api/map.js +78 -4
  130. package/dist/daemon/api/server.js +2 -0
  131. package/dist/daemon/crtrd.js +12 -0
  132. package/dist/daemon/fleet.d.ts +4 -8
  133. package/dist/daemon/fleet.js +48 -9
  134. package/dist/daemon/messaging/node-message.js +2 -1
  135. package/dist/daemon/node-outcome/deliver-outcome.d.ts +14 -0
  136. package/dist/daemon/node-outcome/deliver-outcome.js +155 -0
  137. package/dist/daemon/park-activity.d.ts +9 -0
  138. package/dist/daemon/park-activity.js +30 -0
  139. package/dist/daemon/park-pending.d.ts +10 -5
  140. package/dist/daemon/park-pending.js +18 -8
  141. package/dist/daemon/reconcilers/bash-deadline.js +6 -0
  142. package/dist/daemon/reconcilers/broker-supervision.d.ts +3 -7
  143. package/dist/daemon/reconcilers/broker-supervision.js +29 -33
  144. package/dist/daemon/reconcilers/node-deadline.d.ts +5 -0
  145. package/dist/daemon/reconcilers/node-deadline.js +82 -0
  146. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -1
  147. package/dist/daemon/reconcilers/node-lifecycle/tick.js +12 -3
  148. package/dist/daemon/reconcilers/node-outcome-delivery-lane.d.ts +10 -0
  149. package/dist/daemon/reconcilers/node-outcome-delivery-lane.js +41 -0
  150. package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +66 -66
  151. package/dist/pi-extensions/canvas-structured-output.d.ts +9 -5
  152. package/dist/pi-extensions/canvas-structured-output.js +23 -297
  153. package/dist/shared/env.d.ts +3 -0
  154. package/dist/shared/env.js +6 -0
  155. package/dist/shared/generated-context.d.ts +3 -9
  156. package/dist/shared/generated-context.js +8 -16
  157. package/dist/types.d.ts +12 -0
  158. package/dist/types.js +1 -0
  159. package/package.json +8 -8
  160. package/runtime.lock.json +227 -383
@@ -0,0 +1,76 @@
1
+ import { after, afterEach, beforeEach, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { closeDb } from '../../core/canvas/db.js';
7
+ import { createNode, dueNodeDeadlines, getRow } from '../../core/canvas/canvas.js';
8
+ import { pushFinal } from '../../core/feed/feed.js';
9
+ import { transition } from '../../core/runtime/lifecycle.js';
10
+ import { NodeDeadlineReconciler } from '../reconcilers/node-deadline.js';
11
+ let home;
12
+ const priorHome = process.env['CRTR_HOME'];
13
+ const now = Date.parse('2026-08-31T00:00:10.000Z');
14
+ const pastDeadline = '2026-08-31T00:00:00.000Z';
15
+ const futureDeadline = '2026-08-31T00:01:00.000Z';
16
+ function node(nodeId, opts = {}) {
17
+ return {
18
+ node_id: nodeId,
19
+ name: nodeId,
20
+ created: new Date(now - 1_000).toISOString(),
21
+ cwd: home,
22
+ kind: opts.kind ?? 'general',
23
+ mode: 'base',
24
+ lifecycle: 'terminal',
25
+ status: opts.status ?? 'active',
26
+ deadline_at: opts.deadlineAt ?? null,
27
+ };
28
+ }
29
+ beforeEach(() => {
30
+ closeDb();
31
+ home = mkdtempSync(join(tmpdir(), 'crtr-node-deadline-'));
32
+ process.env['CRTR_HOME'] = home;
33
+ });
34
+ afterEach(() => {
35
+ closeDb();
36
+ rmSync(home, { recursive: true, force: true });
37
+ });
38
+ after(() => {
39
+ closeDb();
40
+ if (priorHome === undefined)
41
+ delete process.env['CRTR_HOME'];
42
+ else
43
+ process.env['CRTR_HOME'] = priorHome;
44
+ });
45
+ test('due node deadlines select only live, non-human, un-outcomed past-due rows', () => {
46
+ createNode(node('active-due', { deadlineAt: pastDeadline }));
47
+ createNode(node('idle-due', { status: 'idle', deadlineAt: pastDeadline }));
48
+ createNode(node('human-due', { kind: 'human', deadlineAt: pastDeadline }));
49
+ createNode(node('future', { deadlineAt: futureDeadline }));
50
+ createNode(node('outcomed', { deadlineAt: pastDeadline }));
51
+ transition('outcomed', 'crash');
52
+ transition('outcomed', 'revive');
53
+ createNode(node('done', { status: 'done', deadlineAt: pastDeadline }));
54
+ assert.deepEqual(dueNodeDeadlines(new Date(now).toISOString()).map((row) => row.node_id), ['active-due', 'idle-due']);
55
+ });
56
+ test('expiry commits cancellation and outcome before its asynchronous teardown request', async () => {
57
+ createNode(node('expired', { deadlineAt: pastDeadline }));
58
+ new NodeDeadlineReconciler().run(now);
59
+ const expired = getRow('expired');
60
+ assert.deepEqual({ status: expired.status, reason: expired.outcome_reason, kind: expired.outcome_kind }, {
61
+ status: 'canceled', reason: 'deadline_exceeded', kind: 'failure',
62
+ });
63
+ assert.equal(expired.outcome_at === null, false, 'the durable outcome is committed before the queued teardown can run');
64
+ await new Promise((resolve) => setImmediate(resolve));
65
+ });
66
+ test('a canonical final that wins after deadline selection is not overwritten', async () => {
67
+ createNode(node('finalized', { deadlineAt: pastDeadline }));
68
+ const staleCandidate = dueNodeDeadlines(new Date(now).toISOString()).find((row) => row.node_id === 'finalized');
69
+ assert.ok(staleCandidate, 'the row was eligible when the deadline lane selected it');
70
+ await pushFinal('finalized', 'finished before the deadline cancellation committed');
71
+ new NodeDeadlineReconciler().expire(staleCandidate, now);
72
+ const finalized = getRow('finalized');
73
+ assert.deepEqual({ status: finalized.status, reason: finalized.outcome_reason, kind: finalized.outcome_kind }, {
74
+ status: 'done', reason: 'finalized', kind: 'result',
75
+ });
76
+ });
@@ -0,0 +1,122 @@
1
+ import { after, afterEach, beforeEach, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { closeDb } from '../../core/canvas/db.js';
7
+ import { createNode, getRow, transitionStatusIntent } from '../../core/canvas/canvas.js';
8
+ import { armOutcomeDelivery, readOutcomeDelivery } from '../../core/canvas/node-outcome-deliveries.js';
9
+ import { pushFinal } from '../../core/feed/feed.js';
10
+ import { transition } from '../../core/runtime/lifecycle.js';
11
+ import { spawnNode } from '../../core/runtime/nodes.js';
12
+ import { spawnChildPrepared } from '../../core/runtime/spawn.js';
13
+ let home;
14
+ const priorHome = process.env.CRTR_HOME;
15
+ function node(nodeId, opts = {}) {
16
+ return {
17
+ node_id: nodeId,
18
+ name: nodeId,
19
+ created: new Date().toISOString(),
20
+ cwd: home,
21
+ kind: 'general',
22
+ mode: 'base',
23
+ lifecycle: 'terminal',
24
+ status: 'active',
25
+ ...opts,
26
+ };
27
+ }
28
+ const binding = {
29
+ actionName: 'test-action',
30
+ argv: ['/bin/true'],
31
+ cwd: '/tmp',
32
+ configOrigin: 'test',
33
+ payload: { callback: 'one' },
34
+ };
35
+ beforeEach(() => {
36
+ closeDb();
37
+ home = mkdtempSync(join(tmpdir(), 'crtr-outcome-birth-'));
38
+ process.env.CRTR_HOME = home;
39
+ });
40
+ afterEach(() => {
41
+ closeDb();
42
+ rmSync(home, { recursive: true, force: true });
43
+ });
44
+ after(() => {
45
+ if (priorHome === undefined)
46
+ delete process.env.CRTR_HOME;
47
+ else
48
+ process.env.CRTR_HOME = priorHome;
49
+ });
50
+ test('birth commits the requested deadline and outcome registration before spawn orchestration can launch a broker', () => {
51
+ const deadlineAt = '2026-01-01T00:00:00.000Z';
52
+ const born = spawnNode({
53
+ kind: 'general',
54
+ cwd: home,
55
+ lifecycle: 'terminal',
56
+ deadlineAt,
57
+ outcomeDelivery: binding,
58
+ });
59
+ assert.equal(getRow(born.node_id)?.deadline_at, deadlineAt);
60
+ assert.deepEqual(readOutcomeDelivery(born.node_id) && {
61
+ state: readOutcomeDelivery(born.node_id).state,
62
+ action: readOutcomeDelivery(born.node_id).actionName,
63
+ payload: readOutcomeDelivery(born.node_id).payload,
64
+ }, {
65
+ state: 'armed',
66
+ action: 'test-action',
67
+ payload: { callback: 'one' },
68
+ });
69
+ });
70
+ test('a pre-launch spawn failure leaves its caller-requested outcome delivery pending', async () => {
71
+ let nodeId;
72
+ await assert.rejects(() => spawnChildPrepared({
73
+ kind: 'general',
74
+ cwd: process.cwd(),
75
+ root: true,
76
+ profile: null,
77
+ prompt: '',
78
+ deadlineAt: '2026-01-01T00:00:00.000Z',
79
+ outcomeDelivery: binding,
80
+ }, (id) => {
81
+ nodeId = id;
82
+ throw new Error('simulated pre-launch failure');
83
+ }), /simulated pre-launch failure/);
84
+ assert.ok(nodeId);
85
+ assert.equal(getRow(nodeId)?.outcome_reason, 'launch_failed');
86
+ assert.equal(readOutcomeDelivery(nodeId)?.state, 'pending');
87
+ assert.ok(readOutcomeDelivery(nodeId)?.document);
88
+ });
89
+ test('an armed delivery cannot become pending without its outcome document', () => {
90
+ createNode(node('guarded'));
91
+ armOutcomeDelivery({ nodeId: 'guarded', ...binding });
92
+ transitionStatusIntent('guarded', { status: 'dead', terminal_reason: 'crashed' }, {
93
+ outcome: {
94
+ kind: 'failure',
95
+ reason: 'crashed',
96
+ detailJson: null,
97
+ at: '2026-01-01T00:00:00.000Z',
98
+ documentJson: null,
99
+ },
100
+ });
101
+ const delivery = readOutcomeDelivery('guarded');
102
+ assert.equal(delivery?.state, 'armed');
103
+ assert.equal(delivery?.document, null);
104
+ });
105
+ test('late registration composes the latched terminal view from hydrated node identity', () => {
106
+ createNode(node('late', { spawned_by: 'origin' }));
107
+ transition('late', 'crash');
108
+ transition('late', 'revive');
109
+ const delivery = armOutcomeDelivery({ nodeId: 'late', ...binding });
110
+ const document = delivery?.document;
111
+ assert.equal(document.node.status, 'dead');
112
+ assert.equal(document.node.terminalReason, 'crashed');
113
+ assert.equal(document.node.spawnedBy, 'origin');
114
+ });
115
+ test('human bridges transition and finalize without latching outcomes', async () => {
116
+ createNode(node('human-transition', { kind: 'human' }));
117
+ transition('human-transition', 'finish');
118
+ assert.equal(getRow('human-transition')?.outcome_at, null);
119
+ createNode(node('human-final', { kind: 'human' }));
120
+ await pushFinal('human-final', 'bridge answer');
121
+ assert.equal(getRow('human-final')?.outcome_at, null);
122
+ });
@@ -0,0 +1,86 @@
1
+ import { after, afterEach, beforeEach, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { closeDb } from '../../core/canvas/db.js';
7
+ import { createNode } from '../../core/canvas/canvas.js';
8
+ import { isPidAlive, killProcessGroup } from '../../core/canvas/pid.js';
9
+ import { armOutcomeDelivery, claimOutcomeDelivery, readOutcomeDelivery } from '../../core/canvas/node-outcome-deliveries.js';
10
+ import { transition } from '../../core/runtime/lifecycle.js';
11
+ import { OUTCOME_DELIVERY_TIMEOUT_MS, deliverOutcome } from '../node-outcome/deliver-outcome.js';
12
+ let home;
13
+ let processGroupFiles;
14
+ const priorHome = process.env.CRTR_HOME;
15
+ function node(nodeId) {
16
+ return { node_id: nodeId, name: nodeId, created: new Date().toISOString(), cwd: home, kind: 'general', mode: 'base', lifecycle: 'terminal', status: 'active' };
17
+ }
18
+ function claimScript(id, script) {
19
+ const path = join(home, `${id}.sh`);
20
+ writeFileSync(path, script);
21
+ chmodSync(path, 0o755);
22
+ createNode(node(id));
23
+ armOutcomeDelivery({ nodeId: id, actionName: 'test', argv: [path], cwd: home, configOrigin: 'test' });
24
+ transition(id, 'crash');
25
+ const delivery = claimOutcomeDelivery(id, Date.now(), 'test-daemon');
26
+ assert.ok(delivery);
27
+ return delivery;
28
+ }
29
+ beforeEach(() => {
30
+ closeDb();
31
+ home = mkdtempSync(join(tmpdir(), 'crtr-outcome-delivery-'));
32
+ process.env.CRTR_HOME = home;
33
+ processGroupFiles = [];
34
+ });
35
+ afterEach(() => {
36
+ for (const path of processGroupFiles) {
37
+ if (!existsSync(path))
38
+ continue;
39
+ const pid = Number(readFileSync(path, 'utf8').trim());
40
+ if (Number.isInteger(pid) && pid > 0)
41
+ killProcessGroup(pid, 'SIGKILL');
42
+ }
43
+ closeDb();
44
+ rmSync(home, { recursive: true, force: true });
45
+ });
46
+ after(() => {
47
+ if (priorHome === undefined)
48
+ delete process.env.CRTR_HOME;
49
+ else
50
+ process.env.CRTR_HOME = priorHome;
51
+ });
52
+ test('exit acknowledgments settle outcome delivery durably', async () => {
53
+ const accepted = claimScript('accepted', '#!/bin/sh\necho ignored\necho diagnostic >&2\nexit 0\n');
54
+ await deliverOutcome(accepted, 'test-daemon');
55
+ assert.equal(readOutcomeDelivery('accepted')?.state, 'accepted');
56
+ const permanent = claimScript('permanent', '#!/bin/sh\nexit 78\n');
57
+ await deliverOutcome(permanent, 'test-daemon');
58
+ assert.equal(readOutcomeDelivery('permanent')?.state, 'permanent_failed');
59
+ const retry = claimScript('retry', '#!/bin/sh\necho retry-stderr >&2\nexit 1\n');
60
+ await deliverOutcome(retry, 'test-daemon');
61
+ const row = readOutcomeDelivery('retry');
62
+ assert.equal(row?.state, 'pending');
63
+ assert.ok(row.nextAttemptAt > Date.now());
64
+ assert.equal(row?.lastFailure?.kind, 'exit');
65
+ assert.equal(row?.lastFailure?.stderr, 'retry-stderr\n');
66
+ });
67
+ test('timeout SIGKILL escalation reaps a SIGTERM-ignoring descendant after its leader exits', async () => {
68
+ const childPid = join(home, 'descendant.pid');
69
+ const processGroupPid = join(home, 'process-group.pid');
70
+ processGroupFiles.push(processGroupPid);
71
+ const delivery = claimScript('timeout', `#!/bin/sh\necho $$ > ${processGroupPid}\n(\n trap '' TERM\n while :; do sleep 30; done\n) &\necho $! > ${childPid}\ntrap 'exit 0' TERM\nwhile :; do sleep 30; done\n`);
72
+ const originalSetTimeout = global.setTimeout;
73
+ global.setTimeout = ((callback, delay, ...args) => originalSetTimeout(callback, delay === OUTCOME_DELIVERY_TIMEOUT_MS ? 1_000 : delay, ...args));
74
+ try {
75
+ await deliverOutcome(delivery, 'test-daemon');
76
+ }
77
+ finally {
78
+ global.setTimeout = originalSetTimeout;
79
+ }
80
+ const row = readOutcomeDelivery('timeout');
81
+ assert.equal(row?.state, 'pending');
82
+ assert.equal(row?.lastFailure?.kind, 'timeout');
83
+ const pid = Number(readFileSync(childPid, 'utf8').trim());
84
+ await new Promise((resolve) => originalSetTimeout(resolve, 2_100));
85
+ assert.equal(isPidAlive(pid), false);
86
+ });
@@ -1,22 +1,24 @@
1
1
  // Run with: node --conditions=crtr-src --import tsx/esm --test src/daemon/api/__tests__/broker-settle-park.test.ts
2
2
  //
3
- // The park clock hands terminalization to broker settlement: it arms a pending
4
- // park, delivers one summary prompt to the live engine, and this handler
5
- // decides what the ending turn meant. The defect surface is the re-evaluation —
6
- // the verdict the clock reached 15 minutes ago must not be enacted when the
7
- // summary turn itself created live work (a human ask, new wake-worthy mail, a
8
- // pushed final), and the pending park must be consumed exactly once regardless.
3
+ // The park clock arms one pending marker, then an isolated parking turn reports
4
+ // completion through its own operation. Main-session settlement must not consume
5
+ // that marker: a user turn can settle while the shadow turn still runs. The
6
+ // completion re-evaluates whether parking still applies exactly once.
9
7
  import { after, before, beforeEach, test } from 'node:test';
10
8
  import assert from 'node:assert/strict';
11
- import { mkdtempSync, rmSync } from 'node:fs';
12
- import { createNode, getNode } from '../../../core/canvas/canvas.js';
9
+ import { mkdtempSync, rmSync, writeFileSync, utimesSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { createNode, getNode, updateNode } from '../../../core/canvas/canvas.js';
13
12
  import { closeDb } from '../../../core/canvas/db.js';
14
- import { appendInbox } from '../../../core/feed/inbox.js';
13
+ import { appendInbox, writeCursor } from '../../../core/feed/inbox.js';
14
+ import { markBusy } from '../../../core/runtime/busy.js';
15
15
  import { beginPendingPark, isPendingPark } from '../../park-pending.js';
16
16
  import { brokerOperationRoutes } from '../handlers/broker-ops.js';
17
17
  let home = '';
18
18
  let originalHome;
19
19
  const settleRoute = brokerOperationRoutes.find((route) => route.method === 'POST' && route.pattern === '/v1/nodes/:id/broker/settle');
20
+ const parkCompleteRoute = brokerOperationRoutes.find((route) => route.method === 'POST' && route.pattern === '/v1/nodes/:id/broker/park-complete');
21
+ const parkActivityRoute = brokerOperationRoutes.find((route) => route.method === 'POST' && route.pattern === '/v1/nodes/:id/broker/park-activity');
20
22
  before(() => {
21
23
  originalHome = process.env['CRTR_HOME'];
22
24
  home = mkdtempSync('/tmp/crtr-settle-park-');
@@ -34,8 +36,7 @@ after(() => {
34
36
  else
35
37
  process.env['CRTR_HOME'] = originalHome;
36
38
  });
37
- /** A live resident mid-parking-turn: the clock armed the park and the summary
38
- * turn is what is now settling. */
39
+ /** A live resident with an isolated parking turn in flight. */
39
40
  function residentMidParkingTurn(id) {
40
41
  const meta = {
41
42
  node_id: id,
@@ -66,9 +67,29 @@ async function settle(id, over = {}) {
66
67
  };
67
68
  return (await settleRoute.handler(ctx)).body;
68
69
  }
69
- test('a summary turn that ends with nothing new parks the node: done + intent=parked, no latch', async () => {
70
+ async function completePark(id) {
71
+ const ctx = {
72
+ method: 'POST',
73
+ path: `/v1/nodes/${id}/broker/park-complete`,
74
+ params: { id },
75
+ query: new URLSearchParams(),
76
+ body: { outcome: 'completed' },
77
+ };
78
+ return (await parkCompleteRoute.handler(ctx)).body;
79
+ }
80
+ async function recordParkActivity(id) {
81
+ const ctx = {
82
+ method: 'POST',
83
+ path: `/v1/nodes/${id}/broker/park-activity`,
84
+ params: { id },
85
+ query: new URLSearchParams(),
86
+ body: {},
87
+ };
88
+ return (await parkActivityRoute.handler(ctx)).body;
89
+ }
90
+ test('a completed park turn with nothing new parks the node: done + intent=parked, no latch', async () => {
70
91
  residentMidParkingTurn('summarized');
71
- const directive = await settle('summarized');
92
+ const directive = await completePark('summarized');
72
93
  assert.deepEqual(directive, { action: 'shutdown' });
73
94
  const parked = getNode('summarized');
74
95
  assert.equal(parked.status, 'done');
@@ -76,27 +97,74 @@ test('a summary turn that ends with nothing new parks the node: done + intent=pa
76
97
  assert.equal(parked.final_report ?? null, null, 'a park never sets the finalization latch');
77
98
  assert.equal(isPendingPark('summarized'), false, 'the pending park is consumed');
78
99
  });
79
- test('a human ask raised during the parking turn keeps the node live instead of parking it', async () => {
100
+ test('a human ask raised during the parking turn does not consume the pending marker', async () => {
80
101
  residentMidParkingTurn('asked');
81
102
  const directive = await settle('asked', { requestedHumanReply: true });
82
103
  assert.deepEqual(directive, { action: 'stay_dormant' });
83
104
  assert.equal(getNode('asked').status, 'active', 'the node stays live awaiting its answer');
84
105
  assert.equal(getNode('asked').intent ?? null, null);
85
- assert.equal(isPendingPark('asked'), false, 'the park is discarded, not held for the next settle');
106
+ assert.equal(isPendingPark('asked'), true, 'main-session settlement leaves completion to the shadow turn');
107
+ });
108
+ test('a main turn racing shadow completion keeps the broker live', async () => {
109
+ residentMidParkingTurn('busy-main');
110
+ markBusy('busy-main');
111
+ const directive = await completePark('busy-main');
112
+ assert.deepEqual(directive, { action: 'stay_dormant' });
113
+ assert.equal(getNode('busy-main').status, 'active');
114
+ assert.equal(getNode('busy-main').intent ?? null, null);
115
+ assert.equal(isPendingPark('busy-main'), false, 'shadow completion consumes the marker after releasing the main turn');
86
116
  });
87
117
  test('wake-worthy mail arriving during the parking turn flips the verdict back to release', async () => {
88
118
  residentMidParkingTurn('mailed');
89
119
  appendInbox('mailed', { from: null, tier: 'normal', kind: 'message', label: 'x', data: { body: 'work' } });
90
- const directive = await settle('mailed');
120
+ const directive = await completePark('mailed');
91
121
  assert.equal(directive.action, 'stay_dormant');
92
122
  assert.equal(getNode('mailed').status, 'active', 're-evaluation saw the new obligation and did not park');
93
123
  assert.equal(getNode('mailed').intent ?? null, null);
94
124
  assert.equal(isPendingPark('mailed'), false);
95
125
  });
96
- test('a final pushed during the parking turn finishes the node rather than parking it', async () => {
126
+ test('a message already consumed by the main turn still releases the parking completion', async () => {
127
+ residentMidParkingTurn('consumed-mail');
128
+ const entry = appendInbox('consumed-mail', { from: null, tier: 'normal', kind: 'message', label: 'work', data: { body: 'work' } });
129
+ writeCursor('consumed-mail', entry.entry_id);
130
+ const directive = await completePark('consumed-mail');
131
+ assert.deepEqual(directive, { action: 'stay_dormant' });
132
+ assert.equal(getNode('consumed-mail').status, 'active');
133
+ assert.equal(getNode('consumed-mail').intent ?? null, null);
134
+ assert.equal(isPendingPark('consumed-mail'), false);
135
+ });
136
+ test('a bound but absent main-session file is not activity', async () => {
137
+ residentMidParkingTurn('empty-session');
138
+ updateNode('empty-session', { pi_session_file: join(home, 'not-created-yet.jsonl') });
139
+ const directive = await completePark('empty-session');
140
+ assert.deepEqual(directive, { action: 'shutdown' });
141
+ assert.equal(getNode('empty-session').status, 'done');
142
+ });
143
+ test('accepted broker input atomically keeps the shadow completion live', async () => {
144
+ residentMidParkingTurn('admitted-input');
145
+ assert.deepEqual(await recordParkActivity('admitted-input'), { activity: 'recorded' });
146
+ const directive = await completePark('admitted-input');
147
+ assert.deepEqual(directive, { action: 'stay_dormant' });
148
+ assert.equal(getNode('admitted-input').status, 'active');
149
+ assert.equal(isPendingPark('admitted-input'), false);
150
+ });
151
+ test('main-session activity already settled before completion releases parking', async () => {
152
+ residentMidParkingTurn('interactive-mail');
153
+ const sessionFile = join(home, 'interactive-mail.jsonl');
154
+ writeFileSync(sessionFile, 'main turn');
155
+ const afterParkStart = new Date(Date.now() + 1000);
156
+ utimesSync(sessionFile, afterParkStart, afterParkStart);
157
+ updateNode('interactive-mail', { pi_session_file: sessionFile });
158
+ const directive = await completePark('interactive-mail');
159
+ assert.deepEqual(directive, { action: 'stay_dormant' });
160
+ assert.equal(getNode('interactive-mail').status, 'active');
161
+ assert.equal(getNode('interactive-mail').intent ?? null, null);
162
+ assert.equal(isPendingPark('interactive-mail'), false);
163
+ });
164
+ test('a final pushed during the parking turn does not consume the pending marker', async () => {
97
165
  residentMidParkingTurn('finished');
98
166
  const directive = await settle('finished', { pushedFinal: true });
99
167
  assert.deepEqual(directive, { action: 'shutdown' });
100
168
  assert.equal(getNode('finished').intent ?? null, null, 'a pushed final is not a park');
101
- assert.equal(isPendingPark('finished'), false);
169
+ assert.equal(isPendingPark('finished'), true, 'main-session settlement does not claim the shadow turn marker');
102
170
  });
@@ -12,6 +12,7 @@ import { reviveNode } from '../../../core/runtime/revive.js';
12
12
  import { resolveLaunchTarget } from '../../../core/runtime/launch-target.js';
13
13
  import { spawnNode } from '../../../core/runtime/nodes.js';
14
14
  import { handleConfig, handleCreate, resolveCreateKind } from '../handlers/nodes.js';
15
+ import { profileRoutes } from '../handlers/profiles.js';
15
16
  const previousCrtrHome = process.env['CRTR_HOME'];
16
17
  const previousHome = process.env.HOME;
17
18
  let home = '';
@@ -44,7 +45,15 @@ after(() => {
44
45
  });
45
46
  test('create rejects a paused profile before a node exists', async () => {
46
47
  const profile = createProfile('paused profile', [{ path: cwd, memory: 'content' }]);
47
- pauseProfile(profile.profileId);
48
+ const pauseRoute = profileRoutes.find((route) => route.method === 'POST' && route.pattern === '/v1/profiles/:name/pause');
49
+ assert.ok(pauseRoute, 'profile pause API route is registered');
50
+ await pauseRoute.handler({
51
+ method: 'POST',
52
+ path: `/v1/profiles/${profile.profileId}/pause`,
53
+ params: { name: profile.profileId },
54
+ query: new URLSearchParams(),
55
+ body: {},
56
+ });
48
57
  const ctx = {
49
58
  method: 'POST',
50
59
  path: '/v1/nodes',
@@ -0,0 +1,159 @@
1
+ // Run with: npm run build && node --test --test-timeout=180000 dist/daemon/api/__tests__/seam/node-outcome-api.test.js
2
+ //
3
+ // Pins the public node-outcome routes against the compiled API server on its
4
+ // Unix socket. The fixture node is written through the canvas, then finalized
5
+ // through the API, so the pending and settled documents cross the real wire.
6
+ import { after, before, test } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { request as httpRequest } from 'node:http';
9
+ import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { CrtrClient } from '../../../../api/index.js';
12
+ import { createNode } from '../../../../core/canvas/canvas.js';
13
+ import { resetScopeCache } from '../../../../core/scope.js';
14
+ import { createHeadlessHarness } from '../../../../core/__tests__/helpers/harness.js';
15
+ let h;
16
+ let client;
17
+ let nodeId;
18
+ function assertKeys(value, expected, label) {
19
+ assert.deepEqual(Object.keys(value).sort(), [...expected].sort(), `${label} has the pinned wire fields`);
20
+ }
21
+ async function rawRequest(method, path, body) {
22
+ const encoded = body === undefined ? undefined : JSON.stringify(body);
23
+ return new Promise((resolve, reject) => {
24
+ const request = httpRequest({
25
+ socketPath: join(h.home, 'crtrd.sock'),
26
+ method,
27
+ path,
28
+ headers: encoded === undefined ? undefined : {
29
+ 'content-type': 'application/json',
30
+ 'content-length': Buffer.byteLength(encoded),
31
+ },
32
+ }, (response) => {
33
+ let content = '';
34
+ response.setEncoding('utf8');
35
+ response.on('data', (chunk) => { content += chunk; });
36
+ response.on('end', () => {
37
+ try {
38
+ resolve({ status: response.statusCode ?? 0, body: content === '' ? null : JSON.parse(content) });
39
+ }
40
+ catch (error) {
41
+ reject(error);
42
+ }
43
+ });
44
+ });
45
+ request.on('error', reject);
46
+ request.end(encoded);
47
+ });
48
+ }
49
+ function assertError(response, status, code) {
50
+ assert.equal(response.status, status);
51
+ const body = response.body;
52
+ assert.equal(body.error.code, code);
53
+ assert.equal(typeof body.error.message, 'string');
54
+ }
55
+ before(async () => {
56
+ h = await createHeadlessHarness({ sessionPrefix: 'crtr-node-outcome-api' });
57
+ client = new CrtrClient({ socketPath: join(h.home, 'crtrd.sock'), autostart: false });
58
+ const actions = join(h.home, 'actions');
59
+ mkdirSync(actions, { recursive: true });
60
+ writeFileSync(join(actions, 'capture'), '#!/bin/sh\ncat >/dev/null\nexit 0\n');
61
+ chmodSync(join(actions, 'capture'), 0o755);
62
+ mkdirSync(join(h.home, '.crouter'), { recursive: true });
63
+ writeFileSync(join(h.home, '.crouter', 'config.json'), JSON.stringify({
64
+ humanActions: {
65
+ capture: { argv: ['actions/capture'], cwd: '.' },
66
+ broken: { argv: ['actions/missing'], cwd: '.' },
67
+ },
68
+ }));
69
+ resetScopeCache();
70
+ nodeId = `outcome-api-${process.pid}`;
71
+ createNode({
72
+ node_id: nodeId,
73
+ name: 'outcome API fixture',
74
+ created: new Date().toISOString(),
75
+ cwd: h.home,
76
+ kind: 'developer',
77
+ mode: 'base',
78
+ lifecycle: 'terminal',
79
+ status: 'active',
80
+ parent: null,
81
+ });
82
+ });
83
+ after(async () => {
84
+ resetScopeCache();
85
+ await h?.dispose();
86
+ });
87
+ test('node outcome and delivery routes preserve their public response and error contracts', async () => {
88
+ assertError(await rawRequest('GET', '/v1/nodes/missing-node/outcome'), 404, 'node_not_found');
89
+ assertError(await rawRequest('GET', `/v1/nodes/${nodeId}/outcome?wait=26.5`), 400, 'invalid_request');
90
+ assertError(await rawRequest('PUT', '/v1/nodes/missing-node/outcome-delivery', { action: 'capture' }), 404, 'node_not_found');
91
+ const pendingResponse = await rawRequest('GET', `/v1/nodes/${nodeId}/outcome`);
92
+ assert.equal(pendingResponse.status, 200, 'GET outcome → 200');
93
+ const pending = pendingResponse.body;
94
+ assertKeys(pending, ['node_id', 'state', 'outcome', 'node_status', 'deadline_at'], 'pending outcome response');
95
+ assert.deepEqual(pending, {
96
+ node_id: nodeId,
97
+ state: 'pending',
98
+ outcome: null,
99
+ node_status: 'active',
100
+ deadline_at: null,
101
+ }, 'GET outcome returns the complete pending NodeOutcomeResponseDTO');
102
+ assertError(await rawRequest('PUT', `/v1/nodes/${nodeId}/outcome-delivery`, { action: '' }), 400, 'invalid_request');
103
+ assertError(await rawRequest('PUT', `/v1/nodes/${nodeId}/outcome-delivery`, { action: 'unknown-action' }), 422, 'unknown_action');
104
+ const invalidAction = await rawRequest('PUT', `/v1/nodes/${nodeId}/outcome-delivery`, { action: 'broken' });
105
+ assertError(invalidAction, 422, 'invalid_action');
106
+ assertKeys(invalidAction.body.error.details, ['reason', 'configOrigin'], 'invalid_action details');
107
+ assertError(await rawRequest('PUT', `/v1/nodes/${nodeId}/outcome-delivery`, { action: 'capture', payload: 'x'.repeat(65 * 1024) }), 413, 'payload_too_large');
108
+ assertError(await rawRequest('GET', `/v1/nodes/${nodeId}/outcome-delivery`), 404, 'outcome_delivery_not_found');
109
+ const payload = { callback: 'fixture', sequence: [1, 2, 3] };
110
+ const armedResponse = await rawRequest('PUT', `/v1/nodes/${nodeId}/outcome-delivery`, { action: 'capture', payload });
111
+ assert.equal(armedResponse.status, 200, 'PUT delivery → 200');
112
+ const armed = armedResponse.body;
113
+ assertKeys(armed, ['node_id', 'state', 'action', 'attempt', 'next_attempt_at', 'accepted_at', 'permanent_failed_at', 'last_failure', 'created_at', 'updated_at'], 'armed delivery response');
114
+ assert.equal(armed.node_id, nodeId);
115
+ assert.equal(armed.state, 'armed');
116
+ assert.equal(armed.action, 'capture');
117
+ assert.equal(armed.attempt, 0);
118
+ assert.equal(armed.next_attempt_at, null);
119
+ assert.equal(armed.accepted_at, null);
120
+ assert.equal(armed.permanent_failed_at, null);
121
+ assert.equal(armed.last_failure, null);
122
+ assert.match(armed.created_at, /^\d{4}-\d{2}-\d{2}T/);
123
+ assert.match(armed.updated_at, /^\d{4}-\d{2}-\d{2}T/);
124
+ const registered = await rawRequest('GET', `/v1/nodes/${nodeId}/outcome-delivery`);
125
+ assert.equal(registered.status, 200, 'GET delivery → 200');
126
+ assert.deepEqual(registered.body, armed, 'GET delivery returns the registered DTO unchanged');
127
+ assert.equal((await rawRequest('DELETE', `/v1/nodes/${nodeId}/outcome-delivery`)).status, 204, 'DELETE armed delivery → 204');
128
+ assert.equal((await rawRequest('DELETE', `/v1/nodes/${nodeId}/outcome-delivery`)).status, 204, 'DELETE absent delivery remains idempotent → 204');
129
+ assertError(await rawRequest('GET', `/v1/nodes/${nodeId}/outcome-delivery`), 404, 'outcome_delivery_not_found');
130
+ const final = await client.pushReport(nodeId, { tier: 'final', body: 'outcome API canonical final' });
131
+ const settledResponse = await rawRequest('GET', `/v1/nodes/${nodeId}/outcome`);
132
+ assert.equal(settledResponse.status, 200, 'GET settled outcome → 200');
133
+ const settled = settledResponse.body;
134
+ assertKeys(settled, ['node_id', 'state', 'outcome', 'node_status', 'deadline_at'], 'settled outcome response');
135
+ assert.equal(settled.node_id, nodeId);
136
+ assert.equal(settled.state, 'settled');
137
+ assert.equal(settled.node_status, 'done');
138
+ assert.equal(settled.deadline_at, null);
139
+ assert.notEqual(settled.outcome, null, 'a canonical final settles an outcome');
140
+ const outcome = settled.outcome;
141
+ assertKeys(outcome, ['node_id', 'revision', 'kind', 'reason', 'settled_at', 'final_report', 'final_report_path', 'structured_result', 'detail', 'declined'], 'settled NodeOutcomeDTO');
142
+ assert.equal(outcome.node_id, nodeId);
143
+ assert.equal(outcome.revision, 1);
144
+ assert.equal(outcome.kind, 'result');
145
+ assert.equal(outcome.reason, 'finalized');
146
+ assert.match(outcome.settled_at, /^\d{4}-\d{2}-\d{2}T/);
147
+ assert.equal(outcome.final_report_path, final.report_path);
148
+ assert.ok(outcome.final_report !== null && outcome.final_report.length > 0, 'result carries its report basename');
149
+ assert.equal(outcome.structured_result, null);
150
+ assert.equal(outcome.detail, null);
151
+ const repeated = await rawRequest('GET', `/v1/nodes/${nodeId}/outcome`);
152
+ assert.equal(repeated.status, 200, 'repeat GET outcome → 200');
153
+ assert.deepEqual(repeated.body, settled, 'repeat GET returns the identical durable outcome response');
154
+ const pendingDelivery = await rawRequest('PUT', `/v1/nodes/${nodeId}/outcome-delivery`, { action: 'capture', payload });
155
+ assert.equal(pendingDelivery.status, 200, 'PUT delivery after settlement → 200');
156
+ assert.equal(pendingDelivery.body.state, 'pending', 'PUT after settlement arms immediate delivery');
157
+ assertError(await rawRequest('PUT', `/v1/nodes/${nodeId}/outcome-delivery`, { action: 'capture', payload }), 409, 'outcome_delivery_settled');
158
+ assertError(await rawRequest('DELETE', `/v1/nodes/${nodeId}/outcome-delivery`), 409, 'outcome_delivery_settled');
159
+ });
@@ -0,0 +1 @@
1
+ export {};