@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
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
// Run with: node --conditions=crtr-src --import tsx/esm --test src/core/__tests__/revive-parked-fresh.test.ts
|
|
2
2
|
//
|
|
3
|
-
// Re-entering a parked node
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// input-less revival (a bare lifecycle revive, a viewer attach, a focus) has
|
|
9
|
-
// nothing to answer, so it quietly resumes the old transcript and injects no
|
|
10
|
-
// kickoff. The rule is a property of the node, evaluated once inside
|
|
11
|
-
// reviveNode, so every doorway gets it from one strict-resume veto. Its other
|
|
12
|
-
// exception is a park with nothing on disk to stand on (reachable only on the
|
|
13
|
-
// degraded path, which writes no summary): resuming an old transcript beats
|
|
14
|
-
// waking amnesiac.
|
|
3
|
+
// Re-entering a parked node resumes its main transcript: the isolated parking
|
|
4
|
+
// turn never wrote to it. Wake-worthy mail remains durable for the inbox watcher
|
|
5
|
+
// to deliver after boot; it does not create a fresh-cycle kickoff. An input-less
|
|
6
|
+
// revival likewise resumes quietly. The rule is a property of the node,
|
|
7
|
+
// evaluated once inside reviveNode, so every doorway gets it uniformly.
|
|
15
8
|
import { after, afterEach, before, beforeEach, test } from 'node:test';
|
|
16
9
|
import assert from 'node:assert/strict';
|
|
17
10
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
@@ -103,23 +96,23 @@ after(() => {
|
|
|
103
96
|
else
|
|
104
97
|
process.env['CRTR_HOME'] = priorHome;
|
|
105
98
|
});
|
|
106
|
-
test('a parked node with mail waiting
|
|
99
|
+
test('a parked node with mail waiting resumes its main transcript and clears the marker', async () => {
|
|
107
100
|
parkResident('parked', { roadmap: '# Roadmap\n\n- one open item\n' });
|
|
108
101
|
deliverMail('parked');
|
|
109
102
|
const result = await reviveNode('parked', { resume: true, capacity: 'freeze' });
|
|
110
|
-
assert.equal(result.resumed,
|
|
111
|
-
assert.deepEqual(placements(), [{ nodeId: 'parked', resuming:
|
|
112
|
-
assert.equal(kickedOff(launches[0]),
|
|
103
|
+
assert.equal(result.resumed, true, 'the isolated parking turn never concluded the main transcript');
|
|
104
|
+
assert.deepEqual(placements(), [{ nodeId: 'parked', resuming: true }]);
|
|
105
|
+
assert.equal(kickedOff(launches[0]), false, 'the inbox watcher delivers the mail after the resumed boot');
|
|
113
106
|
const reopened = getNode('parked');
|
|
114
107
|
assert.equal(reopened.status, 'active');
|
|
115
108
|
assert.equal(reopened.intent ?? null, null, 'the park marker clears on revival');
|
|
116
|
-
assert.equal(reopened.cycle_pending,
|
|
109
|
+
assert.equal(reopened.cycle_pending, false, 'a strict resume does not open a new cycle');
|
|
117
110
|
});
|
|
118
|
-
test('a parked node grounded only by a goal still
|
|
111
|
+
test('a parked node grounded only by a goal still resumes when mail rides the revive', async () => {
|
|
119
112
|
parkResident('goal-only', { goal: 'Watch the deploy lane.' });
|
|
120
113
|
deliverMail('goal-only');
|
|
121
|
-
assert.equal((await reviveNode('goal-only', { resume: true, capacity: 'freeze' })).resumed,
|
|
122
|
-
assert.equal(kickedOff(launches[0]),
|
|
114
|
+
assert.equal((await reviveNode('goal-only', { resume: true, capacity: 'freeze' })).resumed, true);
|
|
115
|
+
assert.equal(kickedOff(launches[0]), false);
|
|
123
116
|
});
|
|
124
117
|
test('an input-less revival of a parked node quietly resumes and injects no kickoff', async () => {
|
|
125
118
|
parkResident('quiet', { roadmap: '# Roadmap\n\n- one open item\n' });
|
|
@@ -394,7 +394,11 @@ test('reload_auth restores a newly authenticated personal route before the next
|
|
|
394
394
|
const unauthenticatedProvider = join(h.home, 'fake-pi.unauthenticated-provider');
|
|
395
395
|
mkdirSync(userConfigDir, { recursive: true });
|
|
396
396
|
writeFileSync(userConfigPath, JSON.stringify({
|
|
397
|
-
|
|
397
|
+
// A fixture-private kind, never `general`: the child launches with the
|
|
398
|
+
// package root as cwd, so an ambient project-scope config above the repo
|
|
399
|
+
// (`~/.crouter/config.json`) overrides this user-scope `general` and the
|
|
400
|
+
// child boots on that machine's default strength instead of medium.
|
|
401
|
+
kinds: { 'test/logical-anthropic': { whenToUse: 'Logical Anthropic routing test.', model: 'anthropic/medium' } },
|
|
398
402
|
modelRoutes: {
|
|
399
403
|
personal: { family: 'anthropic', credentialSource: 'user', providerId: personal.provider, models: { ultra: personalSpec, strong: personalSpec, medium: personalSpec, light: personalSpec } },
|
|
400
404
|
funded: { family: 'anthropic', credentialSource: 'northlight', providerId: funded.provider, models: { ultra: `${funded.provider}/${funded.id}:medium`, strong: `${funded.provider}/${funded.id}:medium`, medium: `${funded.provider}/${funded.id}:medium`, light: `${funded.provider}/${funded.id}:medium` } },
|
|
@@ -404,7 +408,7 @@ test('reload_auth restores a newly authenticated personal route before the next
|
|
|
404
408
|
writeFileSync(unauthenticatedProvider, personal.provider);
|
|
405
409
|
try {
|
|
406
410
|
const parent = h.spawnRoot('auth route reconciliation parent');
|
|
407
|
-
const child = await h.spawnHeadlessChild(parent, 'auth route reconciliation child');
|
|
411
|
+
const child = await h.spawnHeadlessChild(parent, 'auth route reconciliation child', { kind: 'test/logical-anthropic' });
|
|
408
412
|
assert.deepEqual(model(child), funded, 'the logical request starts on the funded route while personal auth is absent');
|
|
409
413
|
const controller = await kit.connectRaw(child, { read: true });
|
|
410
414
|
controller.send({ type: 'hello', role: 'controller', client_id: 'auth-route-reconciliation' });
|
|
@@ -175,7 +175,7 @@ test('an unattended resident with no live obligation completes on the daemon clo
|
|
|
175
175
|
await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
176
176
|
const boot = await h.awaitBoot(nodeId);
|
|
177
177
|
assert.equal(h.fleet.has(nodeId), true, 'the revived broker is fleet-owned');
|
|
178
|
-
const seenAt =
|
|
178
|
+
const seenAt = Date.now();
|
|
179
179
|
await h.tick(seenAt);
|
|
180
180
|
await h.tick(seenAt + 1499);
|
|
181
181
|
assert.equal(h.fleet.has(nodeId), true, 'the daemon keeps the broker through the full grace');
|
|
@@ -228,7 +228,7 @@ test('a non-attending observer does not prevent unattended resident parking', {
|
|
|
228
228
|
await attach.attach(nodeId, 'observer', 'system-observer-test', { attends: false });
|
|
229
229
|
assert.equal(JSON.parse(readFileSync(join(h.home, 'nodes', nodeId, 'job', 'attach.json'), 'utf8')).viewers, 0, 'a non-attending hello is not persisted as human attendance');
|
|
230
230
|
try {
|
|
231
|
-
const seenAt =
|
|
231
|
+
const seenAt = Date.now();
|
|
232
232
|
await h.tick(seenAt);
|
|
233
233
|
await h.tick(seenAt + 1500);
|
|
234
234
|
await h.tick(seenAt + 1500 + 1000);
|
|
@@ -383,7 +383,7 @@ test('a resident managed child parks without finalizing while a terminal child i
|
|
|
383
383
|
};
|
|
384
384
|
const residentPid = await boot(resident);
|
|
385
385
|
const terminalPid = await boot(terminal);
|
|
386
|
-
const seenAt =
|
|
386
|
+
const seenAt = Date.now();
|
|
387
387
|
await h.tick(seenAt);
|
|
388
388
|
// Clock expiry arms the resident's pending park and requests its summary
|
|
389
389
|
// turn; the terminal child is cancelled outright by the same pass.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Run with: npm run build && node --test --test-timeout=180000 dist/core/__tests__/seam/node-outcome-delivery.test.js
|
|
2
|
+
//
|
|
3
|
+
// Pins the terminal-outcome delivery boundary: a real node publishes a canonical
|
|
4
|
+
// final through the CLI, then the compiled daemon lane gives the registered
|
|
5
|
+
// command its immutable outcome document on stdin and honors its exit code.
|
|
6
|
+
import { after, before, test } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { closeDb } from '../../canvas/db.js';
|
|
11
|
+
import { readOutcomeDelivery } from '../../canvas/node-outcome-deliveries.js';
|
|
12
|
+
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
13
|
+
let h;
|
|
14
|
+
let root;
|
|
15
|
+
let project;
|
|
16
|
+
function assertKeys(value, expected, label) {
|
|
17
|
+
assert.deepEqual(Object.keys(value).sort(), [...expected].sort(), `${label} has the pinned document fields`);
|
|
18
|
+
}
|
|
19
|
+
function readCapture(action, attempt) {
|
|
20
|
+
const path = join(project, `${action}-${attempt}.json`);
|
|
21
|
+
assert.ok(existsSync(path), `${action} attempt ${attempt} captured stdin`);
|
|
22
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
23
|
+
}
|
|
24
|
+
function assertResultDocument(document, nodeId, action, payloadJson, attempt) {
|
|
25
|
+
assertKeys(document, ['schema', 'outcomeId', 'nodeId', 'revision', 'kind', 'reason', 'settledAt', 'node', 'result', 'action', 'delivery'], 'node outcome document');
|
|
26
|
+
assert.equal(document.schema, 'crtr.node-outcome/v1');
|
|
27
|
+
assert.equal(document.outcomeId, `${nodeId}:1`);
|
|
28
|
+
assert.equal(document.nodeId, nodeId);
|
|
29
|
+
assert.equal(document.revision, 1);
|
|
30
|
+
assert.equal(document.kind, 'result');
|
|
31
|
+
assert.equal(document.reason, 'finalized');
|
|
32
|
+
assert.match(document.settledAt, /^\d{4}-\d{2}-\d{2}T/);
|
|
33
|
+
assert.equal(document.failure, undefined, 'result documents do not carry a failure discriminator');
|
|
34
|
+
assertKeys(document.node, ['name', 'kind', 'mode', 'lifecycle', 'status', 'terminalReason', 'cwd', 'createdAt', 'parent', 'spawnedBy'], 'document node');
|
|
35
|
+
assert.equal(document.node['kind'], 'general');
|
|
36
|
+
assert.equal(document.node['mode'], 'base');
|
|
37
|
+
assert.equal(document.node['lifecycle'], 'terminal');
|
|
38
|
+
assert.equal(document.node['status'], 'done');
|
|
39
|
+
assert.equal(document.node['terminalReason'], 'finalized');
|
|
40
|
+
assert.equal(document.node['cwd'], project);
|
|
41
|
+
assert.equal(document.node['parent'], root);
|
|
42
|
+
assert.equal(document.node['spawnedBy'], root);
|
|
43
|
+
assertKeys(document.result, ['finalReport'], 'result document');
|
|
44
|
+
const finalReport = document.result['finalReport'];
|
|
45
|
+
assertKeys(finalReport, ['basename', 'path'], 'result final-report pointer');
|
|
46
|
+
assert.equal(typeof finalReport['basename'], 'string');
|
|
47
|
+
assert.equal(typeof finalReport['path'], 'string');
|
|
48
|
+
assertKeys(document.action, ['name', 'payload'], 'document action');
|
|
49
|
+
assert.equal(document.action.name, action);
|
|
50
|
+
assert.equal(JSON.stringify(document.action.payload), payloadJson, 'action payload reaches stdin byte-identically');
|
|
51
|
+
assertKeys(document.delivery, ['attempt'], 'document delivery');
|
|
52
|
+
assert.equal(document.delivery.attempt, attempt);
|
|
53
|
+
}
|
|
54
|
+
function deliveryWhen(nodeId, predicate, label) {
|
|
55
|
+
return h.waitFor(() => {
|
|
56
|
+
closeDb();
|
|
57
|
+
const delivery = readOutcomeDelivery(nodeId);
|
|
58
|
+
return delivery !== null && predicate(delivery) ? delivery : null;
|
|
59
|
+
}, { label, timeoutMs: 20_000 });
|
|
60
|
+
}
|
|
61
|
+
async function createAndFinalize(action, payload) {
|
|
62
|
+
const payloadJson = JSON.stringify(payload);
|
|
63
|
+
const created = h.cli(root, [
|
|
64
|
+
'--json', 'node', 'new', '--kind', 'general', '--cwd', project,
|
|
65
|
+
'--outcome-action', action, '--outcome-payload', payloadJson,
|
|
66
|
+
`outcome delivery ${action}`,
|
|
67
|
+
]);
|
|
68
|
+
assert.equal(created.code, 0, `node new for ${action}: ${created.stderr}`);
|
|
69
|
+
const createdBody = created.json;
|
|
70
|
+
assert.equal(typeof createdBody?.node_id, 'string', `node new returns its node id for ${action}`);
|
|
71
|
+
const nodeId = createdBody.node_id;
|
|
72
|
+
await h.awaitBoot(nodeId);
|
|
73
|
+
closeDb();
|
|
74
|
+
const armed = readOutcomeDelivery(nodeId);
|
|
75
|
+
assert.notEqual(armed, null, `node birth arms ${action} delivery`);
|
|
76
|
+
assert.equal(armed.state, 'armed');
|
|
77
|
+
assert.equal(armed.actionName, action);
|
|
78
|
+
assert.equal(armed.argv[0], join(project, `${action}.sh`));
|
|
79
|
+
assert.equal(armed.cwd, project);
|
|
80
|
+
assert.equal(armed.configOrigin, join(project, '.crouter', 'config.json'));
|
|
81
|
+
assert.equal(JSON.stringify(armed.payload), payloadJson, 'registered payload is frozen before launch');
|
|
82
|
+
assert.equal(armed.attempt, 0);
|
|
83
|
+
const finalized = h.cli(nodeId, ['push', 'final', `canonical final for ${action}`]);
|
|
84
|
+
assert.equal(finalized.code, 0, `push final for ${action}: ${finalized.stderr}`);
|
|
85
|
+
const pending = await deliveryWhen(nodeId, (delivery) => delivery.state === 'pending', `${action} delivery pending after canonical final`);
|
|
86
|
+
assert.notEqual(pending.document, null, 'canonical final composes the immutable delivery document');
|
|
87
|
+
return { nodeId, payloadJson };
|
|
88
|
+
}
|
|
89
|
+
function writeCaptureAction(action, exitPolicy) {
|
|
90
|
+
const script = join(project, `${action}.sh`);
|
|
91
|
+
writeFileSync(script, `#!/bin/sh
|
|
92
|
+
attempt_file=.${action}.attempt
|
|
93
|
+
attempt=$(cat "$attempt_file" 2>/dev/null || printf 0)
|
|
94
|
+
attempt=$((attempt + 1))
|
|
95
|
+
printf '%s' "$attempt" > "$attempt_file"
|
|
96
|
+
cat > "${action}-$attempt.json"
|
|
97
|
+
${exitPolicy}
|
|
98
|
+
`);
|
|
99
|
+
chmodSync(script, 0o755);
|
|
100
|
+
}
|
|
101
|
+
before(async () => {
|
|
102
|
+
h = await createHeadlessHarness({ sessionPrefix: 'crtr-node-outcome-delivery' });
|
|
103
|
+
root = h.spawnRoot('node outcome delivery seam root');
|
|
104
|
+
project = join(h.agentHome, 'outcome-delivery-project');
|
|
105
|
+
mkdirSync(join(project, '.crouter'), { recursive: true });
|
|
106
|
+
writeCaptureAction('accepted', 'exit 0');
|
|
107
|
+
writeCaptureAction('permanent', 'exit 78');
|
|
108
|
+
writeCaptureAction('retry', 'if [ "$attempt" -eq 1 ]; then exit 1; fi\nexit 0');
|
|
109
|
+
writeFileSync(join(project, '.crouter', 'config.json'), JSON.stringify({
|
|
110
|
+
humanActions: {
|
|
111
|
+
accepted: { argv: ['accepted.sh'], cwd: '.' },
|
|
112
|
+
permanent: { argv: ['permanent.sh'], cwd: '.' },
|
|
113
|
+
retry: { argv: ['retry.sh'], cwd: '.' },
|
|
114
|
+
},
|
|
115
|
+
}));
|
|
116
|
+
});
|
|
117
|
+
after(async () => {
|
|
118
|
+
if (h !== undefined)
|
|
119
|
+
await h.dispose();
|
|
120
|
+
});
|
|
121
|
+
test('canonical outcomes deliver the exact document and honor accepted, permanent, and retry exits', { timeout: 60_000 }, async () => {
|
|
122
|
+
const acceptedPayload = JSON.parse('{"callbackToken":"accepted-token","callbackId":"accepted-id","nested":{"emoji":"✓"}}');
|
|
123
|
+
const accepted = await createAndFinalize('accepted', acceptedPayload);
|
|
124
|
+
await h.tick();
|
|
125
|
+
const acceptedDelivery = await deliveryWhen(accepted.nodeId, (delivery) => delivery.state === 'accepted' && delivery.attempt === 1, 'accepted outcome delivery');
|
|
126
|
+
assert.equal(acceptedDelivery.acceptedAt === null, false, 'exit 0 settles delivery as accepted');
|
|
127
|
+
assertResultDocument(readCapture('accepted', 1), accepted.nodeId, 'accepted', accepted.payloadJson, 1);
|
|
128
|
+
const permanentPayload = JSON.parse('{"callbackToken":"permanent-token","callbackId":"permanent-id","nested":{"emoji":"✓"}}');
|
|
129
|
+
const permanent = await createAndFinalize('permanent', permanentPayload);
|
|
130
|
+
await h.tick();
|
|
131
|
+
const permanentDelivery = await deliveryWhen(permanent.nodeId, (delivery) => delivery.state === 'permanent_failed' && delivery.attempt === 1, 'permanent outcome delivery');
|
|
132
|
+
assert.equal(permanentDelivery.permanentFailedAt === null, false, 'exit 78 permanently fails delivery');
|
|
133
|
+
assert.equal(permanentDelivery.lastFailure?.exitCode, 78);
|
|
134
|
+
assertResultDocument(readCapture('permanent', 1), permanent.nodeId, 'permanent', permanent.payloadJson, 1);
|
|
135
|
+
const retryPayload = JSON.parse('{"callbackToken":"retry-token","callbackId":"retry-id","nested":{"emoji":"✓"}}');
|
|
136
|
+
const retry = await createAndFinalize('retry', retryPayload);
|
|
137
|
+
await h.tick();
|
|
138
|
+
const first = await deliveryWhen(retry.nodeId, (delivery) => delivery.state === 'pending' && delivery.attempt === 1 && delivery.lastFailure?.kind === 'exit', 'retryable first outcome delivery');
|
|
139
|
+
assert.ok(first.nextAttemptAt > Date.now(), 'exit 1 schedules a later retry');
|
|
140
|
+
assert.equal(first.lastFailure?.exitCode, 1);
|
|
141
|
+
const firstDocument = readCapture('retry', 1);
|
|
142
|
+
assertResultDocument(firstDocument, retry.nodeId, 'retry', retry.payloadJson, 1);
|
|
143
|
+
await h.tick(first.nextAttemptAt + 1);
|
|
144
|
+
const retried = await deliveryWhen(retry.nodeId, (delivery) => delivery.state === 'accepted' && delivery.attempt === 2, 'second outcome delivery accepted');
|
|
145
|
+
assert.equal(retried.acceptedAt === null, false, 'the second attempt accepts delivery');
|
|
146
|
+
const secondDocument = readCapture('retry', 2);
|
|
147
|
+
assertResultDocument(secondDocument, retry.nodeId, 'retry', retry.payloadJson, 2);
|
|
148
|
+
const { delivery: firstDelivery, ...firstImmutable } = firstDocument;
|
|
149
|
+
const { delivery: secondDelivery, ...secondImmutable } = secondDocument;
|
|
150
|
+
assert.deepEqual(secondImmutable, firstImmutable, 'only delivery.attempt changes across retries');
|
|
151
|
+
assert.deepEqual(firstDelivery, { attempt: 1 });
|
|
152
|
+
assert.deepEqual(secondDelivery, { attempt: 2 });
|
|
153
|
+
});
|
package/dist/core/bash-jobs.d.ts
CHANGED
|
@@ -117,6 +117,12 @@ export declare function activeBackgroundBashJobs(contextDir: string): BashJobSta
|
|
|
117
117
|
* passed at `now`. The daemon's deadline lane stops exactly these. A job with
|
|
118
118
|
* no deadline (deliberate handoff, or one the node claimed) never appears. */
|
|
119
119
|
export declare function expiredAutoBackgroundBashJobs(contextDir: string, now: number): BashJobStatus[];
|
|
120
|
+
/** Credit every armed auto-cancel deadline for an interval the node was not
|
|
121
|
+
* permitted to run. A profile pause stops the owning node, so without this the
|
|
122
|
+
* claim window burns down while the only agent that could claim it is stopped,
|
|
123
|
+
* and resume greets it with a job already canceled for not being claimed. A
|
|
124
|
+
* deliberate handoff carries no deadline and is untouched. */
|
|
125
|
+
export declare function shiftAutoBackgroundBashDeadlines(contextDir: string, byMs: number): number;
|
|
120
126
|
export type BackgroundBashJobResult = 'backgrounded' | 'already-backgrounded' | 'finished' | 'missing';
|
|
121
127
|
/** Atomically hand one foreground job to its detached supervisor. The rename
|
|
122
128
|
* is the ownership decision: exactly one caller can move job.run to job.bg,
|
package/dist/core/bash-jobs.js
CHANGED
|
@@ -268,6 +268,28 @@ export function activeBackgroundBashJobs(contextDir) {
|
|
|
268
268
|
export function expiredAutoBackgroundBashJobs(contextDir, now) {
|
|
269
269
|
return activeBackgroundBashJobs(contextDir).filter((job) => job.deadlineAtMs !== null && now >= job.deadlineAtMs);
|
|
270
270
|
}
|
|
271
|
+
/** Credit every armed auto-cancel deadline for an interval the node was not
|
|
272
|
+
* permitted to run. A profile pause stops the owning node, so without this the
|
|
273
|
+
* claim window burns down while the only agent that could claim it is stopped,
|
|
274
|
+
* and resume greets it with a job already canceled for not being claimed. A
|
|
275
|
+
* deliberate handoff carries no deadline and is untouched. */
|
|
276
|
+
export function shiftAutoBackgroundBashDeadlines(contextDir, byMs) {
|
|
277
|
+
if (!Number.isFinite(byMs) || byMs <= 0)
|
|
278
|
+
return 0;
|
|
279
|
+
let shifted = 0;
|
|
280
|
+
for (const job of activeBackgroundBashJobs(contextDir)) {
|
|
281
|
+
if (job.deadlineAtMs === null)
|
|
282
|
+
continue;
|
|
283
|
+
try {
|
|
284
|
+
writeJobDeadlineMs(bashJobPaths(contextDir, job.jobId), job.deadlineAtMs + byMs);
|
|
285
|
+
shifted += 1;
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
/* best-effort — a job that vanished mid-scan needs no credit */
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return shifted;
|
|
292
|
+
}
|
|
271
293
|
/** Atomically hand one foreground job to its detached supervisor. The rename
|
|
272
294
|
* is the ownership decision: exactly one caller can move job.run to job.bg,
|
|
273
295
|
* while the supervisor races to move the same source to job.done. */
|
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
import type { NodeMeta, NodeIdentity, NodeRow, NodeStatus, ExitIntent, SubscriptionRef, Mode, Lifecycle, TerminalReason } from './types.js';
|
|
1
|
+
import type { NodeMeta, NodeIdentity, NodeRow, NodeStatus, ExitIntent, SubscriptionRef, Mode, Lifecycle, TerminalReason, NodeOutcomeKind } from './types.js';
|
|
2
|
+
/** Seed a node's row at BIRTH: identity columns + runtime columns taken from the
|
|
3
|
+
* incoming meta (defaults: status='active', the rest null). The only writer
|
|
4
|
+
* that sets runtime columns alongside identity in one statement — afterwards
|
|
5
|
+
* the atomic setters are the sole runtime writers. */
|
|
6
|
+
export interface OutcomeDeliveryBinding {
|
|
7
|
+
actionName: string;
|
|
8
|
+
argv: string[];
|
|
9
|
+
cwd: string;
|
|
10
|
+
configOrigin: string;
|
|
11
|
+
payload?: unknown;
|
|
12
|
+
}
|
|
2
13
|
/** Create a node: scaffold its dirs, persist identity to meta.json, and seed the
|
|
3
14
|
* row (identity + runtime from the incoming meta). Returns the hydrated view. */
|
|
4
|
-
export declare function createNode(meta: NodeMeta
|
|
15
|
+
export declare function createNode(meta: NodeMeta, opts?: {
|
|
16
|
+
outcomeDelivery?: OutcomeDeliveryBinding;
|
|
17
|
+
}): NodeMeta;
|
|
5
18
|
/** The canonical node record: durable identity (meta.json) ∪ authoritative
|
|
6
19
|
* runtime (the row). Null if unknown. */
|
|
7
20
|
export declare function getNode(nodeId: string): NodeMeta | null;
|
|
@@ -15,6 +28,12 @@ export declare function getRow(nodeId: string): NodeRow | null;
|
|
|
15
28
|
* to adopt a caller's pane as a focus). pane is not UNIQUE in the schema, but a
|
|
16
29
|
* live pane backs at most one node, so this returns the single match. */
|
|
17
30
|
export declare function getRowByPane(pane: string): NodeRow | null;
|
|
31
|
+
/** Nodes whose persisted spawn deadline has elapsed and that remain live work. */
|
|
32
|
+
export declare function dueNodeDeadlines(nowIso: string): NodeRow[];
|
|
33
|
+
/** Write a claimed warm spare's deadline before it is handed to its caller. */
|
|
34
|
+
export declare function setNodeDeadline(nodeId: string, deadlineAt: string | null): boolean;
|
|
35
|
+
/** Shift active/idle profile deadlines by a completed profile pause duration. */
|
|
36
|
+
export declare function shiftProfileNodeDeadlines(profileId: string, pauseDurationMs: number): number;
|
|
18
37
|
/** Merge an IDENTITY patch into a node's meta.json and re-index its identity
|
|
19
38
|
* columns. Identity has a single writer per node, so this read-modify-write is
|
|
20
39
|
* safe (the contended runtime fields were moved out — see the atomic setters
|
|
@@ -39,6 +58,17 @@ export declare function transitionStatusIntent(nodeId: string, fields: {
|
|
|
39
58
|
}, opts?: {
|
|
40
59
|
fromStatuses?: readonly NodeStatus[];
|
|
41
60
|
requireNoFinalReport?: boolean;
|
|
61
|
+
/** Present ⇒ latch an outcome and make an armed delivery pending atomically. */
|
|
62
|
+
outcome?: {
|
|
63
|
+
kind: NodeOutcomeKind;
|
|
64
|
+
reason: TerminalReason;
|
|
65
|
+
detailJson: string | null;
|
|
66
|
+
at: string;
|
|
67
|
+
/** Canonical-final only: replace a prior failure outcome. */
|
|
68
|
+
supersede?: boolean;
|
|
69
|
+
/** The document composed at outcome time for an armed delivery. */
|
|
70
|
+
documentJson?: string | null;
|
|
71
|
+
};
|
|
42
72
|
}): number;
|
|
43
73
|
/** Read a node's committed-final latch pointer. Pure read, no side effect. */
|
|
44
74
|
export declare function getFinalReport(nodeId: string): string | null;
|
|
@@ -135,6 +165,11 @@ export declare function listNodes(filter?: {
|
|
|
135
165
|
withRecordedPid?: boolean;
|
|
136
166
|
withRecordedPidOrFrozen?: boolean;
|
|
137
167
|
}): NodeRow[];
|
|
168
|
+
/** Identity-only projection of every user-visible row: the broker's report
|
|
169
|
+
* resolver needs only "does this sender exist, and what is it called", and a
|
|
170
|
+
* three-column read stays cheap against a canvas of thousands of rows where a
|
|
171
|
+
* full `listNodes()` hydration does not. */
|
|
172
|
+
export declare function listNodeIdentities(): Array<Pick<NodeRow, 'node_id' | 'name' | 'created'>>;
|
|
138
173
|
/** Mark an already-spawned node as an unclaimed spare for `recipeKey`. Hides it
|
|
139
174
|
* from every listing until it is claimed. */
|
|
140
175
|
export declare function registerWarmSpare(nodeId: string, recipeKey: string): void;
|
|
@@ -178,7 +213,10 @@ export declare function isWarmSpare(nodeId: string): boolean;
|
|
|
178
213
|
* spare must not sort as an hour-old conversation. This is the one sanctioned
|
|
179
214
|
* write of `created` after birth (`upsertRow` deliberately never touches it);
|
|
180
215
|
* the caller mirrors the same stamp into meta.json. */
|
|
181
|
-
export declare function claimWarmSpare(recipeKey: string, created: string
|
|
216
|
+
export declare function claimWarmSpare(recipeKey: string, created: string, opts?: {
|
|
217
|
+
deadlineAt?: string;
|
|
218
|
+
outcomeDelivery?: OutcomeDeliveryBinding;
|
|
219
|
+
}): string | null;
|
|
182
220
|
/** How many CLAIMABLE spares are parked for `recipeKey` (the depth check a
|
|
183
221
|
* refill reads before minting). Uses the same liveness test as the claim, so a
|
|
184
222
|
* spare the claim would skip can never hold the pool at depth 1 and starve
|