@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.
- package/dist/api/client.d.ts +19 -3
- package/dist/api/client.js +34 -0
- package/dist/api/dto/broker-ops.d.ts +11 -0
- package/dist/api/dto/broker.d.ts +3 -8
- package/dist/api/dto/common.d.ts +1 -1
- package/dist/api/dto/messages.d.ts +2 -2
- package/dist/api/dto/node-outcomes.d.ts +80 -0
- package/dist/api/dto/node-outcomes.js +2 -0
- package/dist/api/dto/nodes.d.ts +9 -0
- package/dist/api/dto/profiles.d.ts +5 -0
- package/dist/api/dto/reports.d.ts +45 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/plugin-manifest-schema.d.ts +208 -0
- package/dist/api/plugin-manifest-schema.js +23 -0
- package/dist/api/routes.d.ts +7 -0
- package/dist/api/routes.js +7 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +24 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +31 -4
- package/dist/cli.js +8 -2
- package/dist/clients/attach/overlays/graph.d.ts +5 -0
- package/dist/clients/attach/overlays/graph.js +65 -47
- package/dist/clients/attach/overlays/pickers.js +8 -9
- package/dist/clients/attach/render/card-presentation.js +1 -1
- package/dist/clients/attach/viewer.js +1286 -1260
- package/dist/commands/api-client.js +22 -23
- package/dist/commands/node/create.js +38 -3
- package/dist/commands/node/inspect.js +3 -7
- package/dist/commands/node/message.js +3 -3
- package/dist/commands/node/outcome.d.ts +2 -0
- package/dist/commands/node/outcome.js +134 -0
- package/dist/commands/node.js +2 -1
- package/dist/commands/pkg/browse/catalog.js +1 -1
- package/dist/commands/pkg/plugin-manage.d.ts +46 -1
- package/dist/commands/pkg/plugin-manage.js +170 -20
- package/dist/commands/profile/pause.js +17 -14
- package/dist/commands/push.js +102 -1
- package/dist/commands/sys/__tests__/setup-core.test.js +4 -1
- package/dist/commands/sys/panels/broker-limits-panel.js +7 -1
- package/dist/commands/sys/panels/models-panel.js +2 -2
- package/dist/commands/sys/settings-shell.d.ts +1 -1
- package/dist/commands/sys/settings-shell.js +6 -0
- package/dist/commands/sys/settings.js +3 -3
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +2 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +42 -4
- package/dist/core/__tests__/helpers/harness.d.ts +1 -0
- package/dist/core/__tests__/helpers/harness.js +9 -0
- package/dist/core/__tests__/integration/plugin-revalidate.test.d.ts +1 -0
- package/dist/core/__tests__/integration/plugin-revalidate.test.js +355 -0
- package/dist/core/__tests__/integration/profile-pause.test.d.ts +1 -0
- package/dist/core/__tests__/integration/profile-pause.test.js +115 -0
- package/dist/core/__tests__/node-outcome.test.d.ts +1 -0
- package/dist/core/__tests__/node-outcome.test.js +184 -0
- package/dist/core/__tests__/revive-parked-fresh.test.js +13 -20
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +6 -2
- package/dist/core/__tests__/seam/dormancy-release.test.js +3 -3
- package/dist/core/__tests__/seam/node-outcome-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/seam/node-outcome-delivery.test.js +153 -0
- package/dist/core/bash-jobs.d.ts +6 -0
- package/dist/core/bash-jobs.js +22 -0
- package/dist/core/canvas/canvas.d.ts +41 -3
- package/dist/core/canvas/canvas.js +153 -39
- package/dist/core/canvas/crons.js +2 -1
- package/dist/core/canvas/index.d.ts +1 -0
- package/dist/core/canvas/index.js +1 -0
- package/dist/core/canvas/migrations.js +47 -0
- package/dist/core/canvas/node-outcome-deliveries.d.ts +80 -0
- package/dist/core/canvas/node-outcome-deliveries.js +163 -0
- package/dist/core/canvas/pid.d.ts +7 -0
- package/dist/core/canvas/pid.js +17 -0
- package/dist/core/canvas/render-source.js +2 -2
- package/dist/core/canvas/types.d.ts +42 -1
- package/dist/core/canvas/types.js +2 -1
- package/dist/core/command-manifests/registry.d.ts +3 -1
- package/dist/core/command-manifests/schema.d.ts +2 -75
- package/dist/core/command-manifests/schema.js +5 -0
- package/dist/core/command-plugins/compose.js +1 -1
- package/dist/core/command-plugins/revalidate.d.ts +18 -0
- package/dist/core/command-plugins/revalidate.js +152 -0
- package/dist/core/command-plugins/transport/http-fetch.d.ts +29 -4
- package/dist/core/command-plugins/transport/http-fetch.js +18 -8
- package/dist/core/command-plugins/transport/http-invoke.js +7 -0
- package/dist/core/command.d.ts +8 -0
- package/dist/core/command.js +19 -3
- package/dist/core/config.d.ts +4 -1
- package/dist/core/config.js +25 -1
- package/dist/core/exclusive-lock.d.ts +12 -0
- package/dist/core/exclusive-lock.js +29 -0
- package/dist/core/feed/feed.d.ts +8 -0
- package/dist/core/feed/feed.js +25 -4
- package/dist/core/feed/inbox.d.ts +5 -0
- package/dist/core/feed/inbox.js +13 -0
- package/dist/core/installed-plugins.js +31 -0
- package/dist/core/keybindings/catalog.d.ts +1 -1
- package/dist/core/keybindings/catalog.js +3 -0
- package/dist/core/manifest-recovery.d.ts +15 -0
- package/dist/core/manifest-recovery.js +72 -0
- package/dist/core/manifest-stale.d.ts +18 -0
- package/dist/core/manifest-stale.js +39 -0
- package/dist/core/plugin-swap-lock.d.ts +9 -0
- package/dist/core/plugin-swap-lock.js +31 -0
- package/dist/core/profiles/manifest.d.ts +3 -1
- package/dist/core/profiles/manifest.js +6 -1
- package/dist/core/runtime/broker/daemon-ops.d.ts +3 -1
- package/dist/core/runtime/broker/daemon-ops.js +6 -0
- package/dist/core/runtime/broker/engine-drive.d.ts +2 -3
- package/dist/core/runtime/broker/engine-drive.js +74 -27
- package/dist/core/runtime/broker/frame-dispatch.js +1 -0
- package/dist/core/runtime/broker/read-ops.js +14 -0
- package/dist/core/runtime/broker/rebind.js +0 -7
- package/dist/core/runtime/broker-protocol.d.ts +1 -6
- package/dist/core/runtime/fleet.d.ts +7 -0
- package/dist/core/runtime/headless-pi.d.ts +13 -0
- package/dist/core/runtime/headless-pi.js +10 -2
- package/dist/core/runtime/lifecycle.d.ts +6 -1
- package/dist/core/runtime/lifecycle.js +50 -1
- package/dist/core/runtime/nodes.d.ts +5 -1
- package/dist/core/runtime/nodes.js +2 -1
- package/dist/core/runtime/outcome-document.d.ts +70 -0
- package/dist/core/runtime/outcome-document.js +97 -0
- package/dist/core/runtime/revive.js +16 -21
- package/dist/core/runtime/spawn.d.ts +5 -1
- package/dist/core/runtime/spawn.js +18 -7
- package/dist/core/runtime/stamp/channel.d.ts +3 -5
- package/dist/core/runtime/stamp/channel.js +3 -10
- package/dist/core/runtime/stop-guard.js +2 -2
- package/dist/core/runtime/structured-output.d.ts +24 -3
- package/dist/core/runtime/structured-output.js +42 -3
- package/dist/core/runtime/tmux-bindings.js +4 -0
- package/dist/core/runtime/warm-pool.d.ts +5 -2
- package/dist/core/runtime/warm-pool.js +5 -5
- package/dist/core/subscription-state.js +7 -5
- package/dist/core/substrate/render.js +10 -10
- package/dist/core/user-settings.d.ts +18 -1
- package/dist/core/user-settings.js +37 -0
- package/dist/daemon/__tests__/node-deadline-lane.test.d.ts +1 -0
- package/dist/daemon/__tests__/node-deadline-lane.test.js +76 -0
- package/dist/daemon/__tests__/node-outcome-birth-invariants.test.d.ts +1 -0
- package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +122 -0
- package/dist/daemon/__tests__/node-outcome-delivery.test.d.ts +1 -0
- package/dist/daemon/__tests__/node-outcome-delivery.test.js +86 -0
- package/dist/daemon/api/__tests__/broker-settle-park.test.js +86 -18
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +10 -1
- package/dist/daemon/api/__tests__/seam/node-outcome-api.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/seam/node-outcome-api.test.js +159 -0
- package/dist/daemon/api/__tests__/submit-result.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/submit-result.test.js +214 -0
- package/dist/daemon/api/handlers/broker-ops.js +53 -24
- package/dist/daemon/api/handlers/canvas.js +2 -2
- package/dist/daemon/api/handlers/messages.js +12 -2
- package/dist/daemon/api/handlers/node-outcomes.d.ts +2 -0
- package/dist/daemon/api/handlers/node-outcomes.js +136 -0
- package/dist/daemon/api/handlers/nodes.js +67 -1
- package/dist/daemon/api/handlers/profiles.js +53 -1
- package/dist/daemon/api/handlers/reports.js +197 -33
- package/dist/daemon/api/map.d.ts +7 -2
- package/dist/daemon/api/map.js +78 -4
- package/dist/daemon/api/server.js +2 -0
- package/dist/daemon/crtrd.js +12 -0
- package/dist/daemon/fleet.d.ts +4 -8
- package/dist/daemon/fleet.js +48 -9
- package/dist/daemon/messaging/node-message.js +2 -1
- package/dist/daemon/node-outcome/deliver-outcome.d.ts +14 -0
- package/dist/daemon/node-outcome/deliver-outcome.js +155 -0
- package/dist/daemon/park-activity.d.ts +9 -0
- package/dist/daemon/park-activity.js +30 -0
- package/dist/daemon/park-pending.d.ts +10 -5
- package/dist/daemon/park-pending.js +18 -8
- package/dist/daemon/reconcilers/bash-deadline.js +6 -0
- package/dist/daemon/reconcilers/broker-supervision.d.ts +3 -7
- package/dist/daemon/reconcilers/broker-supervision.js +29 -33
- package/dist/daemon/reconcilers/node-deadline.d.ts +5 -0
- package/dist/daemon/reconcilers/node-deadline.js +82 -0
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -1
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +12 -3
- package/dist/daemon/reconcilers/node-outcome-delivery-lane.d.ts +10 -0
- package/dist/daemon/reconcilers/node-outcome-delivery-lane.js +41 -0
- package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +66 -66
- package/dist/pi-extensions/canvas-structured-output.d.ts +9 -5
- package/dist/pi-extensions/canvas-structured-output.js +23 -297
- package/dist/shared/env.d.ts +3 -0
- package/dist/shared/env.js +6 -0
- package/dist/shared/generated-context.d.ts +3 -9
- package/dist/shared/generated-context.js +8 -16
- package/dist/types.d.ts +19 -0
- package/dist/types.js +1 -0
- package/package.json +9 -8
- 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,
|
|
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 {
|
|
21
|
-
import {
|
|
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 {
|
|
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 &&
|
|
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.
|
|
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(
|
|
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,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
|
+
];
|