@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.
- 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/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/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/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/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/config.d.ts +4 -1
- package/dist/core/config.js +25 -1
- 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/keybindings/catalog.d.ts +1 -1
- package/dist/core/keybindings/catalog.js +3 -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 +12 -0
- package/dist/types.js +1 -0
- package/package.json +8 -8
- 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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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'),
|
|
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
|
|
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
|
|
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'),
|
|
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
|
-
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 {};
|