@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,214 @@
1
+ // Run with: node --import tsx/esm --test src/daemon/api/__tests__/submit-result.test.ts
2
+ //
3
+ // `POST /v1/nodes/{id}/result` — the server side of `crtr push result` — and
4
+ // the `structured_result_pending` gate on the plain push route. In-process
5
+ // handler calls against a throwaway CRTR_HOME (reopen-delivery.test.ts pattern).
6
+ import { after, before, beforeEach, test } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { dirname } from 'node:path';
10
+ import { createNode, getNode, openDb } from '../../../core/canvas/index.js';
11
+ import { closeDb } from '../../../core/canvas/db.js';
12
+ import { InputError } from '../../../core/io.js';
13
+ import { outputResultPath, outputSchemaPath, writeOutputSchema, } from '../../../core/runtime/structured-output.js';
14
+ import { reportRoutes } from '../handlers/reports.js';
15
+ import { toNodeOutcomeDTO } from '../map.js';
16
+ let home;
17
+ const priorHome = process.env['CRTR_HOME'];
18
+ before(() => {
19
+ // macOS limits Unix-socket paths to 104 bytes; keep the home short.
20
+ home = mkdtempSync('/tmp/crtr-result-');
21
+ process.env['CRTR_HOME'] = home;
22
+ });
23
+ beforeEach(() => {
24
+ closeDb();
25
+ rmSync(home, { recursive: true, force: true });
26
+ });
27
+ after(() => {
28
+ closeDb();
29
+ rmSync(home, { recursive: true, force: true });
30
+ if (priorHome === undefined)
31
+ delete process.env['CRTR_HOME'];
32
+ else
33
+ process.env['CRTR_HOME'] = priorHome;
34
+ });
35
+ function node(id, lifecycle = 'terminal') {
36
+ return {
37
+ node_id: id,
38
+ name: id,
39
+ created: new Date().toISOString(),
40
+ cwd: '/tmp',
41
+ kind: 'general',
42
+ mode: 'base',
43
+ lifecycle,
44
+ status: 'active',
45
+ };
46
+ }
47
+ function finalize(id) {
48
+ openDb()
49
+ .prepare('UPDATE nodes SET final_report = ?, finalized_at = ? WHERE node_id = ?')
50
+ .run('final.md', new Date().toISOString(), id);
51
+ }
52
+ function route(method, pattern) {
53
+ const def = reportRoutes.find((r) => r.method === method && r.pattern === pattern);
54
+ assert.ok(def, `route ${method} ${pattern} registered`);
55
+ return (id, body) => Promise.resolve(def.handler({
56
+ method,
57
+ path: pattern.replace(':id', id),
58
+ params: { id },
59
+ query: new URLSearchParams(),
60
+ body,
61
+ }));
62
+ }
63
+ const submit = route('POST', '/v1/nodes/:id/result');
64
+ const pushReport = route('POST', '/v1/nodes/:id/reports');
65
+ const SCHEMA = {
66
+ type: 'object',
67
+ properties: { status: { type: 'string' }, count: { type: 'number' } },
68
+ required: ['status'],
69
+ };
70
+ async function rejection(promise) {
71
+ let caught;
72
+ await promise.then(() => assert.fail('expected a rejection'), (err) => { caught = err; });
73
+ assert.ok(caught instanceof InputError, `expected InputError, got ${String(caught)}`);
74
+ return caught;
75
+ }
76
+ test('terminal happy path: result written, schema cleared, node finished', async () => {
77
+ createNode(node('t1'));
78
+ writeOutputSchema('t1', 'terminal', SCHEMA);
79
+ const res = await submit('t1', { value: { status: 'ok' } });
80
+ assert.equal(res.status, 200);
81
+ const dto = res.body;
82
+ assert.equal(dto.mode, 'terminal');
83
+ assert.equal(dto.result_path, outputResultPath('t1'));
84
+ assert.ok(dto.report_path.endsWith('.md'));
85
+ assert.deepEqual(dto.notified, []);
86
+ assert.deepEqual(dto.transitioned, { from: 'active', to: 'done' });
87
+ assert.deepEqual(JSON.parse(readFileSync(outputResultPath('t1'), 'utf8')), { status: 'ok' });
88
+ assert.equal(existsSync(outputSchemaPath('t1')), false);
89
+ const meta = getNode('t1');
90
+ assert.equal(meta?.status, 'done');
91
+ assert.notEqual(meta?.final_report, null);
92
+ assert.match(readFileSync(dto.report_path, 'utf8'), /Structured output submitted/);
93
+ });
94
+ test('one-off happy path: update pushed, node keeps working', async () => {
95
+ createNode(node('o1', 'resident'));
96
+ writeOutputSchema('o1', 'oneoff', SCHEMA);
97
+ const res = await submit('o1', { value: { status: 'ok' } });
98
+ const dto = res.body;
99
+ assert.equal(dto.mode, 'oneoff');
100
+ assert.equal(dto.transitioned, undefined);
101
+ assert.equal(existsSync(outputSchemaPath('o1')), false);
102
+ const meta = getNode('o1');
103
+ assert.equal(meta?.status, 'active');
104
+ assert.equal(meta?.final_report, null);
105
+ });
106
+ test('schema mismatch: field errors, nothing recorded, request retained for retry', async () => {
107
+ createNode(node('m1'));
108
+ writeOutputSchema('m1', 'terminal', SCHEMA);
109
+ const err = await rejection(submit('m1', { value: { count: 3 } }));
110
+ assert.equal(err.payload.error, 'result_schema_mismatch');
111
+ assert.match(err.payload.message, /status/);
112
+ assert.equal(existsSync(outputResultPath('m1')), false);
113
+ assert.equal(existsSync(outputSchemaPath('m1')), true);
114
+ assert.equal(getNode('m1')?.status, 'active');
115
+ });
116
+ test('coercion parity with the retired submit tool: "5" persists as 5', async () => {
117
+ createNode(node('c1'));
118
+ writeOutputSchema('c1', 'terminal', SCHEMA);
119
+ await submit('c1', { value: { status: 'ok', count: '5' } });
120
+ assert.deepEqual(JSON.parse(readFileSync(outputResultPath('c1'), 'utf8')), { status: 'ok', count: 5 });
121
+ });
122
+ test('no pending request → no_output_request', async () => {
123
+ createNode(node('n1'));
124
+ const err = await rejection(submit('n1', { value: {} }));
125
+ assert.equal(err.payload.error, 'no_output_request');
126
+ });
127
+ test('corrupt request file → invalid_output_request naming the parse failure', async () => {
128
+ createNode(node('i1'));
129
+ const path = outputSchemaPath('i1');
130
+ mkdirSync(dirname(path), { recursive: true });
131
+ writeFileSync(path, '{not json', 'utf8');
132
+ const err = await rejection(submit('i1', { value: {} }));
133
+ assert.equal(err.payload.error, 'invalid_output_request');
134
+ assert.match(err.payload.message, /not valid JSON/);
135
+ });
136
+ test('unknown node → 404', async () => {
137
+ await assert.rejects(submit('ghost', { value: {} }), (err) => err.code === 'not_found');
138
+ });
139
+ test('already-finalized node, terminal submit → already_finalized; schema retained', async () => {
140
+ createNode(node('f1'));
141
+ finalize('f1');
142
+ writeOutputSchema('f1', 'terminal', SCHEMA);
143
+ const err = await rejection(submit('f1', { value: { status: 'ok' } }));
144
+ assert.equal(err.payload.error, 'already_finalized');
145
+ assert.equal(existsSync(outputSchemaPath('f1')), true);
146
+ });
147
+ test('missing value key → usage error (distinct from a legal null value)', async () => {
148
+ createNode(node('u1'));
149
+ writeOutputSchema('u1', 'terminal', SCHEMA);
150
+ await assert.rejects(submit('u1', {}), (err) => err.code === 'usage');
151
+ // A PRESENT null value is legal input to validation (here: a mismatch, not usage).
152
+ const err = await rejection(submit('u1', { value: null }));
153
+ assert.equal(err.payload.error, 'result_schema_mismatch');
154
+ });
155
+ test('non-object value against an object schema → mismatch, not a crash', async () => {
156
+ createNode(node('s1'));
157
+ writeOutputSchema('s1', 'terminal', SCHEMA);
158
+ const err = await rejection(submit('s1', { value: 42 }));
159
+ assert.equal(err.payload.error, 'result_schema_mismatch');
160
+ assert.equal(existsSync(outputSchemaPath('s1')), true);
161
+ });
162
+ test('push final while a valid schema is pending → structured_result_pending', async () => {
163
+ createNode(node('g1'));
164
+ writeOutputSchema('g1', 'terminal', SCHEMA);
165
+ const err = await rejection(pushReport('g1', { tier: 'final', body: 'done' }));
166
+ assert.equal(err.payload.error, 'structured_result_pending');
167
+ assert.match(err.payload.next ?? '', /crtr push result/);
168
+ assert.equal(getNode('g1')?.status, 'active');
169
+ });
170
+ test('push update while a schema is pending is still allowed', async () => {
171
+ createNode(node('g2'));
172
+ writeOutputSchema('g2', 'oneoff', SCHEMA);
173
+ const res = await pushReport('g2', { tier: 'update', body: 'progress' });
174
+ assert.equal(res.status, 200);
175
+ });
176
+ test('push final with a corrupt schema file stays allowed (manual-cleanup escape)', async () => {
177
+ createNode(node('g3'));
178
+ const path = outputSchemaPath('g3');
179
+ mkdirSync(dirname(path), { recursive: true });
180
+ writeFileSync(path, '{not json', 'utf8');
181
+ const res = await pushReport('g3', { tier: 'final', body: 'done' });
182
+ assert.equal(res.status, 200);
183
+ assert.equal(getNode('g3')?.status, 'done');
184
+ });
185
+ test('terminal decline: declined.json + failure/declined outcome carrying reason, code, retryable', async () => {
186
+ createNode(node('d1'));
187
+ writeOutputSchema('d1', 'terminal', SCHEMA);
188
+ const res = await submit('d1', { decline: 'The route is blocked. ', code: 'blocked-device-route', retryable: true });
189
+ const dto = res.body;
190
+ assert.equal(dto.mode, 'terminal');
191
+ assert.deepEqual(dto.declined, { reason: 'The route is blocked.', code: 'blocked-device-route', retryable: true });
192
+ assert.deepEqual(dto.transitioned, { from: 'active', to: 'done' });
193
+ assert.equal(existsSync(outputResultPath('d1')), false);
194
+ assert.equal(existsSync(outputSchemaPath('d1')), false);
195
+ const marker = JSON.parse(readFileSync(dto.result_path, 'utf8'));
196
+ assert.equal(marker['code'], 'blocked-device-route');
197
+ assert.equal(marker['retryable'], true);
198
+ const outcome = toNodeOutcomeDTO(getNode('d1'));
199
+ assert.ok(outcome !== null && outcome.kind === 'failure' && outcome.reason === 'declined');
200
+ assert.deepEqual(outcome.declined, { reason: 'The route is blocked.', code: 'blocked-device-route', retryable: true });
201
+ assert.equal(outcome.detail?.message, 'The route is blocked.');
202
+ assert.match(readFileSync(dto.report_path, 'utf8'), /Code: blocked-device-route/);
203
+ });
204
+ test('decline without a code, or with a non-boolean retryable, records nothing', async () => {
205
+ createNode(node('d2'));
206
+ writeOutputSchema('d2', 'terminal', SCHEMA);
207
+ const noCode = await rejection(submit('d2', { decline: 'blocked', retryable: false }));
208
+ assert.equal(noCode.payload.error, 'invalid_decline_code');
209
+ const spaced = await rejection(submit('d2', { decline: 'blocked', code: 'has space', retryable: false }));
210
+ assert.equal(spaced.payload.error, 'invalid_decline_code');
211
+ await assert.rejects(submit('d2', { decline: 'blocked', code: 'x' }), (err) => err.code === 'usage');
212
+ assert.equal(existsSync(outputSchemaPath('d2')), true);
213
+ assert.equal(getNode('d2')?.status, 'active');
214
+ });
@@ -1,7 +1,8 @@
1
1
  // Broker-originated canvas operations. The broker sends Pi lifecycle facts;
2
2
  // crtrd validates and commits their durable effects while the handler stays
3
3
  // strictly canvas-write-only — no broker launch, kill, or view.sock round trip.
4
- import { canonicalSessionFile, findNodeBySessionFile, getNode, isWarmSpare, listNodes, recordPid, recordSettledTurn, setPresence, subscriptionsOf, updateNode } from '../../../core/canvas/canvas.js';
4
+ import { canonicalSessionFile, findNodeBySessionFile, getNode, isWarmSpare, listNodeIdentities, recordPid, recordSettledTurn, setPresence, subscriptionsOf, updateNode } from '../../../core/canvas/canvas.js';
5
+ import { isSafeNodeId } from '../../../core/canvas/paths.js';
5
6
  import { isPidAlive } from '../../../core/canvas/pid.js';
6
7
  import { editorLabel } from '../../../core/canvas/labels.js';
7
8
  import { assembleNodeSubject } from '../../../core/substrate/subject.js';
@@ -17,8 +18,9 @@ import { evaluateStop } from '../../../core/runtime/stop-guard.js';
17
18
  import { clearFault, recordFault } from '../../../core/runtime/fault.js';
18
19
  import { updateModelRecipe } from '../../../core/runtime/model-swap.js';
19
20
  import { completeCompanionRequestedSubmit } from '../../review/finish.js';
20
- import { consumePendingPark } from '../../park-pending.js';
21
- import { parkEvent } from '../../reconcilers/live-obligation.js';
21
+ import { emitEvent } from '../../../core/events/emit.js';
22
+ import { consumePendingPark, recordPendingParkActivity } from '../../park-pending.js';
23
+ import { shouldParkAfterSummary } from '../../park-activity.js';
22
24
  function objectBody(body, operation) {
23
25
  if (typeof body !== 'object' || body === null || Array.isArray(body)) {
24
26
  throw usage(`${operation} requires a JSON body`);
@@ -55,6 +57,13 @@ function parseSettleBody(body) {
55
57
  }
56
58
  return value;
57
59
  }
60
+ function parseParkCompleteBody(body) {
61
+ const value = objectBody(body, 'broker park-complete');
62
+ if (value['outcome'] !== 'completed' && value['outcome'] !== 'failed') {
63
+ throw usage('outcome must be completed or failed');
64
+ }
65
+ return value;
66
+ }
58
67
  function parseInboxCursorBody(body) {
59
68
  const value = objectBody(body, 'broker inbox-cursor');
60
69
  const throughEntryId = value['throughEntryId'];
@@ -137,7 +146,6 @@ function extensionState(nodeId) {
137
146
  const subject = assembleNodeSubject(nodeId);
138
147
  if (subject === null)
139
148
  throw notFound(`unknown node: ${nodeId}`, { received: nodeId });
140
- const report_nodes = listNodes().map((row) => ({ node_id: row.node_id, name: row.name, created: row.created }));
141
149
  return {
142
150
  node: extensionNode(node),
143
151
  warm_spare: isWarmSpare(nodeId),
@@ -145,7 +153,7 @@ function extensionState(nodeId) {
145
153
  children,
146
154
  fork_source: source === null ? null : extensionNode(source),
147
155
  subject,
148
- report_nodes,
156
+ report_nodes: listNodeIdentities(),
149
157
  };
150
158
  }
151
159
  function samePersona(a, b) {
@@ -250,11 +258,6 @@ function handleSessionBound(ctx) {
250
258
  * boundary. This handler intentionally never invokes the broker that called it:
251
259
  * the returned directive is the only Pi-side consequence. */
252
260
  function settleBroker(nodeId, request) {
253
- // A parking turn in flight ends HERE, whatever it ended as. Consuming
254
- // unconditionally is the abort rule expressed once: every early return below
255
- // — clean quit, pushed final, human ask, interrupted turn, refresh — discards
256
- // the park and leaves the node live under a restarted clock.
257
- const parkPending = consumePendingPark(nodeId);
258
261
  const node = getNode(nodeId);
259
262
  if (node === null) {
260
263
  // A command may have recorded its Pi-local completion marker just as an
@@ -301,20 +304,6 @@ function settleBroker(nodeId, request) {
301
304
  clearFault(nodeId);
302
305
  return { action: 'shutdown' };
303
306
  }
304
- // The parking turn just ended. Re-evaluate the verdict the clock reached
305
- // before this turn ran: a `crtr human send`, new wake-worthy mail, a fresh
306
- // subscription to a child it spawned, or a pending structured-output request
307
- // all make the node live work again, and the ordinary stop policy applies
308
- // instead. `pushedFinal` was checked above, so a node that finished during
309
- // its parking turn ends latched rather than parked.
310
- if (parkPending && parkEvent(nodeId, node.lifecycle) === 'park') {
311
- clearFault(nodeId);
312
- transition(nodeId, 'park');
313
- if (focusOf(nodeId) !== null)
314
- tearDownNode(nodeId);
315
- setPresence(nodeId, { pane: null, tmux_session: null, window: null });
316
- return { action: 'shutdown' };
317
- }
318
307
  const decision = evaluateStop(nodeId, signals, request.backgroundJobsRunning);
319
308
  clearFault(nodeId);
320
309
  if (decision.action === 'reprompt')
@@ -345,6 +334,44 @@ function handleSettle(ctx) {
345
334
  void completeCompanionRequestedSubmit(nodeId);
346
335
  return { status: 200, body: directive };
347
336
  }
337
+ /** The shadow parking turn is the sole good-path enactor for its pending marker.
338
+ * The ordinary main-session settle path remains independent so user activity
339
+ * cannot erase the marker before this completion re-evaluates the obligation. */
340
+ function handleParkComplete(ctx) {
341
+ const nodeId = ctx.params['id'];
342
+ const request = parseParkCompleteBody(ctx.body);
343
+ const directive = withCanvasWrite(() => {
344
+ const pending = consumePendingPark(nodeId);
345
+ if (pending === undefined)
346
+ return { action: 'stay_dormant' };
347
+ const node = requireNode(nodeId);
348
+ if (!shouldParkAfterSummary(nodeId, node, pending))
349
+ return { action: 'stay_dormant' };
350
+ clearFault(nodeId);
351
+ transition(nodeId, 'park');
352
+ if (focusOf(nodeId) !== null)
353
+ tearDownNode(nodeId);
354
+ setPresence(nodeId, { pane: null, tmux_session: null, window: null });
355
+ emitEvent({
356
+ level: request.outcome === 'completed' ? 'info' : 'warn',
357
+ event: 'broker.park.complete',
358
+ ...(isSafeNodeId(nodeId) ? { node_id: nodeId } : {}),
359
+ outcome: request.outcome === 'completed' ? 'succeeded' : 'failed',
360
+ });
361
+ return { action: 'shutdown' };
362
+ });
363
+ return { status: 200, body: directive };
364
+ }
365
+ /** Record an accepted main-engine input before Pi begins driving it. This shares
366
+ * the canvas-write boundary with park completion, so a pending shadow turn can
367
+ * never terminalize a viewer or live-delivery input that won admission. */
368
+ function handleParkActivity(ctx) {
369
+ const nodeId = ctx.params['id'];
370
+ const result = withCanvasWrite(() => ({
371
+ activity: recordPendingParkActivity(nodeId) ? 'recorded' : 'none',
372
+ }));
373
+ return { status: 200, body: result };
374
+ }
348
375
  /** Commit a watcher cursor only while this broker generation remains current.
349
376
  * A refresh or a replacement binding must leave the cursor untouched so the
350
377
  * replacement replays every handoff that may have reached the discarded Pi
@@ -447,6 +474,8 @@ function handlePersonaAck(ctx) {
447
474
  export const brokerOperationRoutes = [
448
475
  { method: 'POST', pattern: '/v1/nodes/:id/broker/session-bound', handler: handleSessionBound },
449
476
  { method: 'POST', pattern: '/v1/nodes/:id/broker/settle', handler: handleSettle },
477
+ { method: 'POST', pattern: '/v1/nodes/:id/broker/park-complete', handler: handleParkComplete },
478
+ { method: 'POST', pattern: '/v1/nodes/:id/broker/park-activity', handler: handleParkActivity },
450
479
  { method: 'POST', pattern: '/v1/nodes/:id/broker/inbox-cursor', handler: handleInboxCursor },
451
480
  { method: 'POST', pattern: '/v1/nodes/:id/broker/model', handler: handleModelCommit },
452
481
  { method: 'GET', pattern: '/v1/nodes/:id/broker/extension-state', handler: handleExtensionState },
@@ -15,7 +15,7 @@ import { listNodes, pruneNodes, pruneToLimit, rebuildIndex, rosterEdges, rosterN
15
15
  import { activeBackgroundBashJobs } from '../../../core/bash-jobs.js';
16
16
  import { contextDir } from '../../../core/canvas/paths.js';
17
17
  import { nowIso } from '../../../core/fs-utils.js';
18
- import { isBrokerLive } from '../../../core/runtime/model-swap.js';
18
+ import { isPidPresent } from '../../../core/canvas/pid.js';
19
19
  import { dashboardRowsAllFromSource, enrichRowsFromSource } from '../../../core/canvas/render-source.js';
20
20
  import { localCanvasSource } from '../../../core/canvas/source.js';
21
21
  import { listFocuses } from '../../../core/canvas/focuses.js';
@@ -343,7 +343,7 @@ async function handleSnapshot(ctx) {
343
343
  ...(row.mtimeMs !== undefined ? { last_activity: new Date(row.mtimeMs).toISOString() } : {}),
344
344
  ctx_tokens: row.ctx_tokens,
345
345
  streaming: row.streaming ?? false,
346
- jobs_active: src !== undefined && isBrokerLive(src)
346
+ jobs_active: src !== undefined && isPidPresent(src.pi_pid)
347
347
  ? activeBackgroundBashJobs(contextDir(row.node_id)).length
348
348
  : 0,
349
349
  waiting_for: src?.waiting_for ?? null,
@@ -8,12 +8,14 @@ import { isBrokerLive } from '../../../core/runtime/model-swap.js';
8
8
  import { deliverLive, interruptLive } from '../../../core/runtime/interactive-deliver.js';
9
9
  import { isProfilePaused } from '../../../core/profiles/manifest.js';
10
10
  import { getNode, withFreshTerminalGuard } from '../../../core/canvas/canvas.js';
11
+ import { withCanvasWrite } from '../../../core/canvas/db.js';
11
12
  import { cancelCronsOnWake } from '../../../core/canvas/crons.js';
12
13
  import { assertNotFinalized, assertFinalizedForReopen, commitReopen, commitReopenResident, } from '../../../core/runtime/reopen.js';
13
14
  import { writeOutputSchema } from '../../../core/runtime/structured-output.js';
14
15
  import { appendSituationalContext, formatSituationalProse } from '../../../core/runtime/situational-context.js';
15
16
  import { formatDataCard } from '../../../shared/generated-context.js';
16
17
  import { hasNoNaturalCycle } from '../../../core/runtime/revive-all.js';
18
+ import { recordPendingParkActivity } from '../../park-pending.js';
17
19
  import { hasFreshGroundState } from '../../../core/runtime/revive.js';
18
20
  import { notFound, usage } from '../../../core/errors.js';
19
21
  import { InputError } from '../../../core/io.js';
@@ -298,7 +300,7 @@ async function handleMessage(ctx) {
298
300
  if (hasBody || schema !== null) {
299
301
  const messageBody = hasBody
300
302
  ? req.body
301
- : 'A one-off structured-output request has been granted. Call the `submit` tool with a result matching the requested schema before you stop; after submit succeeds, continue normally.';
303
+ : 'A one-off structured-output request has been granted. Submit a result matching the requested schema with `crtr push result` before you stop; after it is accepted, continue normally.';
302
304
  return appendInbox(id, {
303
305
  from,
304
306
  tier,
@@ -341,6 +343,14 @@ async function handleMessage(ctx) {
341
343
  return deliverImmediate();
342
344
  };
343
345
  const entry = deliver();
346
+ // Wake-capable durable mail wins the same park-completion boundary as a live
347
+ // broker input. A completion that won before this append is handled by the
348
+ // fresh row read below, which revives the now-parked node.
349
+ if (entry.tier !== 'deferred')
350
+ withCanvasWrite(() => recordPendingParkActivity(id));
351
+ const current = getNode(id);
352
+ if (current === null)
353
+ throw notFound(`unknown node: ${id}`, { received: id });
344
354
  // Deferred mail normally waits for a natural cycle, but a frozen row already
345
355
  // owes a thaw; consume its deadline when that durable future wake arrives.
346
356
  if (entry.tier !== 'deferred' || meta.frozen_at !== null)
@@ -351,7 +361,7 @@ async function handleMessage(ctx) {
351
361
  // already raised to a wake-capable tier by the append.
352
362
  let revived = false;
353
363
  let frozen = false;
354
- if (entry.tier !== 'deferred' && !paused && !isBrokerLive(meta)) {
364
+ if (entry.tier !== 'deferred' && !paused && !isBrokerLive(current)) {
355
365
  try {
356
366
  // The entry is already durable, so a wake denied for capacity is only
357
367
  // delayed: the row freezes and the tick relaunches it — at which point
@@ -0,0 +1,2 @@
1
+ import type { RouteTable } from '../router.js';
2
+ export declare const nodeOutcomeRoutes: RouteTable;
@@ -0,0 +1,136 @@
1
+ // Durable node-outcome reads and outcome-delivery registration.
2
+ import { setTimeout as sleep } from 'node:timers/promises';
3
+ import { ApiError } from '../../../api/errors.js';
4
+ import { getNode } from '../../../core/canvas/canvas.js';
5
+ import { armOutcomeDelivery, disarmOutcomeDelivery, readOutcomeDelivery, } from '../../../core/canvas/node-outcome-deliveries.js';
6
+ import { OUTCOME_PAYLOAD_MAX_BYTES } from '../../../core/canvas/types.js';
7
+ import { HUMAN_ACTION_NAME_PATTERN } from '../../../core/human/action-binding.js';
8
+ import { resolveHumanAction } from '../../../core/human-actions.js';
9
+ import { toNodeOutcomeDTO, toOutcomeDeliveryDTO } from '../map.js';
10
+ const POLL_INTERVAL_MS = 250;
11
+ const MAX_WAIT_SECONDS = 25;
12
+ function requireNode(id) {
13
+ const node = getNode(id);
14
+ if (node === null)
15
+ throw new ApiError(404, 'node_not_found', `no node ${id}`);
16
+ return node;
17
+ }
18
+ function parseWait(ctx) {
19
+ const raw = ctx.query.get('wait');
20
+ if (raw === null || raw === '')
21
+ return 0;
22
+ if (!/^(?:0|[1-9]\d*)$/.test(raw)) {
23
+ throw new ApiError(400, 'invalid_request', '`wait` must be an integer between 0 and 25');
24
+ }
25
+ const seconds = Number(raw);
26
+ if (seconds < 0 || seconds > MAX_WAIT_SECONDS) {
27
+ throw new ApiError(400, 'invalid_request', '`wait` must be an integer between 0 and 25');
28
+ }
29
+ return seconds;
30
+ }
31
+ function responseFor(node) {
32
+ const outcome = toNodeOutcomeDTO(node);
33
+ return {
34
+ node_id: node.node_id,
35
+ state: outcome === null ? 'pending' : 'settled',
36
+ outcome,
37
+ node_status: node.status,
38
+ deadline_at: node.deadline_at ?? null,
39
+ };
40
+ }
41
+ /** GET /v1/nodes/:id/outcome — poll now, or poll the durable row for up to 25 seconds. */
42
+ async function handleGetOutcome(ctx) {
43
+ const id = ctx.params['id'] ?? '';
44
+ const waitSeconds = parseWait(ctx);
45
+ const deadline = Date.now() + waitSeconds * 1_000;
46
+ let node = requireNode(id);
47
+ while (toNodeOutcomeDTO(node) === null && Date.now() < deadline) {
48
+ await sleep(Math.min(POLL_INTERVAL_MS, deadline - Date.now()));
49
+ node = requireNode(id);
50
+ }
51
+ return { status: 200, body: responseFor(node) };
52
+ }
53
+ function isPlainObject(value) {
54
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
55
+ }
56
+ function payloadBytes(payload) {
57
+ let serialized;
58
+ try {
59
+ serialized = JSON.stringify(payload);
60
+ }
61
+ catch {
62
+ throw new ApiError(400, 'invalid_request', '`payload` must be JSON-serializable');
63
+ }
64
+ if (serialized === undefined)
65
+ throw new ApiError(400, 'invalid_request', '`payload` must be JSON-serializable');
66
+ return Buffer.byteLength(serialized, 'utf8');
67
+ }
68
+ function parseRegistration(body) {
69
+ if (!isPlainObject(body))
70
+ throw new ApiError(400, 'invalid_request', 'request body must be an object');
71
+ for (const key of Object.keys(body)) {
72
+ if (key !== 'action' && key !== 'payload') {
73
+ throw new ApiError(400, 'invalid_request', `unexpected field in request body: ${key}`);
74
+ }
75
+ }
76
+ const action = body['action'];
77
+ if (typeof action !== 'string' || !HUMAN_ACTION_NAME_PATTERN.test(action)) {
78
+ throw new ApiError(400, 'invalid_request', '`action` must be a declared action name');
79
+ }
80
+ if (body['payload'] !== undefined && payloadBytes(body['payload']) > OUTCOME_PAYLOAD_MAX_BYTES) {
81
+ throw new ApiError(413, 'payload_too_large', `serialized payload exceeds ${OUTCOME_PAYLOAD_MAX_BYTES} bytes`);
82
+ }
83
+ return body['payload'] === undefined ? { action } : { action, payload: body['payload'] };
84
+ }
85
+ function resolveAction(action, cwd) {
86
+ const resolution = resolveHumanAction(action, cwd);
87
+ if (!('kind' in resolution))
88
+ return resolution;
89
+ if (resolution.kind === 'unknown_action') {
90
+ throw new ApiError(422, 'unknown_action', `no action named ${action} is declared from ${cwd}`);
91
+ }
92
+ throw new ApiError(422, 'invalid_action', `${action} is declared in ${resolution.configOrigin} but unusable: ${resolution.reason}`, { reason: resolution.reason, configOrigin: resolution.configOrigin });
93
+ }
94
+ /** PUT /v1/nodes/:id/outcome-delivery — arm/re-arm an unsettled target. */
95
+ function handlePutDelivery(ctx) {
96
+ const id = ctx.params['id'] ?? '';
97
+ const node = requireNode(id);
98
+ const request = parseRegistration(ctx.body);
99
+ const target = resolveAction(request.action, node.cwd);
100
+ const delivery = armOutcomeDelivery({
101
+ nodeId: node.node_id,
102
+ actionName: request.action,
103
+ argv: target.argv,
104
+ cwd: target.cwd,
105
+ configOrigin: target.configOrigin,
106
+ ...(request.payload === undefined ? {} : { payload: request.payload }),
107
+ });
108
+ if (delivery === null) {
109
+ throw new ApiError(409, 'outcome_delivery_settled', 'outcome delivery has already started or settled');
110
+ }
111
+ return { status: 200, body: toOutcomeDeliveryDTO(delivery) };
112
+ }
113
+ /** GET /v1/nodes/:id/outcome-delivery — inspect the one durable registration. */
114
+ function handleGetDelivery(ctx) {
115
+ const id = ctx.params['id'] ?? '';
116
+ const delivery = readOutcomeDelivery(id);
117
+ if (delivery === null)
118
+ throw new ApiError(404, 'outcome_delivery_not_found', `no outcome delivery for node ${id}`);
119
+ return { status: 200, body: toOutcomeDeliveryDTO(delivery) };
120
+ }
121
+ /** DELETE /v1/nodes/:id/outcome-delivery — disarm only before delivery begins. */
122
+ function handleDeleteDelivery(ctx) {
123
+ const id = ctx.params['id'] ?? '';
124
+ const current = readOutcomeDelivery(id);
125
+ if (current !== null && current.state !== 'armed') {
126
+ throw new ApiError(409, 'outcome_delivery_settled', 'outcome delivery has already started or settled');
127
+ }
128
+ disarmOutcomeDelivery(id);
129
+ return { status: 204 };
130
+ }
131
+ export const nodeOutcomeRoutes = [
132
+ { method: 'GET', pattern: '/v1/nodes/:id/outcome', handler: handleGetOutcome },
133
+ { method: 'PUT', pattern: '/v1/nodes/:id/outcome-delivery', handler: handlePutDelivery },
134
+ { method: 'GET', pattern: '/v1/nodes/:id/outcome-delivery', handler: handleGetDelivery },
135
+ { method: 'DELETE', pattern: '/v1/nodes/:id/outcome-delivery', handler: handleDeleteDelivery },
136
+ ];