@crouton-kit/crouter 0.3.81 → 0.3.83
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/builtin-pi-packages/pi-mode-switch/README.md +3 -3
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +12 -4
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +7 -3
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +104 -0
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +16 -44
- package/dist/clients/attach/attach-cmd.d.ts +1 -2
- package/dist/clients/attach/attach-cmd.js +960 -961
- package/dist/clients/attach/attach-def.d.ts +2 -0
- package/dist/clients/attach/attach-def.js +93 -0
- package/dist/clients/attach/canvas-panels.d.ts +10 -3
- package/dist/clients/attach/canvas-panels.js +42 -16
- package/dist/clients/attach/chat-view.d.ts +20 -1
- package/dist/clients/attach/chat-view.js +85 -2
- package/dist/commands/__tests__/human.test.js +7 -22
- package/dist/commands/attention.js +31 -30
- package/dist/commands/human/prompts.js +51 -38
- package/dist/commands/human/queue.d.ts +1 -28
- package/dist/commands/human/queue.js +156 -520
- package/dist/commands/human/shared.d.ts +28 -68
- package/dist/commands/human/shared.js +37 -120
- package/dist/commands/human.js +13 -17
- package/dist/commands/node.js +4 -2
- package/dist/commands/surface.js +1 -1
- package/dist/commands/sys/__tests__/setup-core.test.js +1 -1
- package/dist/commands/sys/__tests__/setup-front-door.test.js +98 -0
- package/dist/commands/sys/setup-core.d.ts +22 -0
- package/dist/commands/sys/setup-core.js +62 -0
- package/dist/commands/sys/setup.js +8 -1
- package/dist/core/__tests__/human-deliver-e2e.test.js +95 -0
- package/dist/core/__tests__/human-deliver.test.js +332 -0
- package/dist/core/__tests__/human-node-not-supervised.test.js +5 -4
- package/dist/core/__tests__/pid-identity-match.test.js +83 -0
- package/dist/core/canvas/__tests__/attention.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/attention.test.js +126 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.js +4 -4
- package/dist/core/canvas/__tests__/render-remote.test.js +1 -1
- package/dist/core/canvas/attention.d.ts +16 -14
- package/dist/core/canvas/attention.js +34 -29
- package/dist/core/canvas/boot.d.ts +1 -0
- package/dist/core/canvas/boot.js +1 -1
- package/dist/core/canvas/browse/app.js +3 -3
- package/dist/core/canvas/pid.d.ts +43 -17
- package/dist/core/canvas/pid.js +104 -35
- package/dist/core/canvas/remote-canvas-source.d.ts +1 -1
- package/dist/core/canvas/remote-canvas-source.js +4 -2
- package/dist/core/canvas/render.d.ts +5 -5
- package/dist/core/canvas/render.js +14 -14
- package/dist/core/canvas/source.d.ts +2 -2
- package/dist/core/canvas/source.js +3 -3
- package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +3 -2
- package/dist/core/preview-registry.js +22 -29
- package/dist/core/profiles/select.js +4 -4
- package/dist/core/runtime/front-door.js +19 -6
- package/dist/core/runtime/package-health.d.ts +13 -0
- package/dist/core/runtime/package-health.js +18 -0
- package/dist/core/runtime/pi-vendored.js +15 -1
- package/dist/core/runtime/placement.d.ts +1 -10
- package/dist/core/runtime/placement.js +43 -26
- package/dist/core/runtime/resume.js +72 -4
- package/dist/core/runtime/session-list-cache.d.ts +22 -2
- package/dist/core/runtime/session-list-cache.js +92 -24
- package/dist/core/runtime/spawn.js +9 -0
- package/dist/core/spawn.d.ts +0 -59
- package/dist/core/spawn.js +5 -163
- package/dist/daemon/crtrd.js +7 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/web-client/assets/index-DZdKS-FB.js +78 -0
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +2 -2
- package/dist/core/__tests__/full/dead-pane-regression.test.js +0 -154
- package/dist/core/__tests__/full/human-new-window-regression.test.js +0 -104
- package/dist/core/__tests__/full/review-render-pane-regression.test.js +0 -133
- package/dist/core/__tests__/human-stranded-deliver.test.js +0 -145
- package/dist/core/__tests__/human-surface-target.test.js +0 -98
- package/dist/web-client/assets/index--md2ylfi.js +0 -78
- /package/dist/{core/__tests__/full/dead-pane-regression.test.d.ts → clients/attach/__tests__/chat-view-snapshot-ordering.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/human-new-window-regression.test.d.ts → commands/sys/__tests__/setup-front-door.test.d.ts} +0 -0
- /package/dist/core/__tests__/{full/review-render-pane-regression.test.d.ts → human-deliver-e2e.test.d.ts} +0 -0
- /package/dist/core/__tests__/{human-stranded-deliver.test.d.ts → human-deliver.test.d.ts} +0 -0
- /package/dist/core/__tests__/{human-surface-target.test.d.ts → pid-identity-match.test.d.ts} +0 -0
|
@@ -8,6 +8,9 @@ import { BINDING_CATALOG, normalizeGesture, resolveKeybindings, } from '../../co
|
|
|
8
8
|
import { builtinPiPackageDir } from '../../core/scope.js';
|
|
9
9
|
import { readJsonIfExists } from '../../core/fs-utils.js';
|
|
10
10
|
import { resolveBundledPiCliPath } from '../../core/runtime/pi-cli.js';
|
|
11
|
+
import { installInboxBinding } from '@crouton-kit/humanloop';
|
|
12
|
+
import { listNodes } from '../../core/canvas/index.js';
|
|
13
|
+
import { registerCrouterRoot } from '../human/shared.js';
|
|
11
14
|
function isKeybindingsObject(value) {
|
|
12
15
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
13
16
|
}
|
|
@@ -422,6 +425,8 @@ export function renderSetupStaticInstructions(state) {
|
|
|
422
425
|
'',
|
|
423
426
|
instructions.exaLine,
|
|
424
427
|
instructions.composioLine,
|
|
428
|
+
'',
|
|
429
|
+
'Inbox popup: running `crtr sys setup` installs the collision-safe ⌥I humanloop inbox popup binding and registers known nodes\' interaction roots (best-effort; the binding needs a running tmux server). On a key collision, re-bind with `hl inbox bind --key <tmux-key>`.',
|
|
425
430
|
instructions.footer,
|
|
426
431
|
].join('\n') + '\n';
|
|
427
432
|
}
|
|
@@ -447,6 +452,42 @@ export async function writeComposioKey(composioKeyPath, composioKey) {
|
|
|
447
452
|
await writeFile(composioKeyPath, `${composioKey}\n`, 'utf8');
|
|
448
453
|
await chmod(composioKeyPath, 0o600).catch(() => undefined);
|
|
449
454
|
}
|
|
455
|
+
/** Reconcile humanloop's collision-safe ⌥I inbox popup binding on the current
|
|
456
|
+
* tmux server, and register each known node's interaction root to owner
|
|
457
|
+
* `crouter` so a resolved ticket can call back into `human _deliver`. Both are
|
|
458
|
+
* best-effort: setup must never fail because tmux is absent or the canvas is
|
|
459
|
+
* unreadable. Crouter calls humanloop's installer — it never authors its own
|
|
460
|
+
* tmux command. */
|
|
461
|
+
export function reconcileInboxFrontDoor() {
|
|
462
|
+
let binding = { state: 'unavailable', key: '', isDefault: false };
|
|
463
|
+
try {
|
|
464
|
+
const state = installInboxBinding();
|
|
465
|
+
binding = { state: state.state, key: state.key, isDefault: state.isDefault };
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
/* best-effort: no tmux server / installer unavailable */
|
|
469
|
+
}
|
|
470
|
+
let interactionRootsRegistered = 0;
|
|
471
|
+
try {
|
|
472
|
+
const seen = new Set();
|
|
473
|
+
for (const node of listNodes()) {
|
|
474
|
+
if (seen.has(node.cwd))
|
|
475
|
+
continue;
|
|
476
|
+
seen.add(node.cwd);
|
|
477
|
+
try {
|
|
478
|
+
registerCrouterRoot(node.cwd);
|
|
479
|
+
interactionRootsRegistered += 1;
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
/* skip a cwd humanloop can't canonicalize/create */
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
/* best-effort: canvas unavailable */
|
|
488
|
+
}
|
|
489
|
+
return { binding, interactionRootsRegistered };
|
|
490
|
+
}
|
|
450
491
|
export function summarizeSetupExecution(result) {
|
|
451
492
|
return {
|
|
452
493
|
installed: [...result.installedPackages, ...result.installedSystemDeps],
|
|
@@ -455,8 +496,22 @@ export function summarizeSetupExecution(result) {
|
|
|
455
496
|
exaKeyPath: result.exaKeyPath,
|
|
456
497
|
composioKeyWritten: result.composioKeyWritten,
|
|
457
498
|
composioKeyPath: result.composioKeyPath,
|
|
499
|
+
inboxBinding: result.inboxBinding,
|
|
500
|
+
interactionRootsRegistered: result.interactionRootsRegistered,
|
|
458
501
|
};
|
|
459
502
|
}
|
|
503
|
+
export function formatInboxBindingLine(binding) {
|
|
504
|
+
switch (binding.state) {
|
|
505
|
+
case 'installed':
|
|
506
|
+
return `inbox popup binding: ⌥I (${binding.key})${binding.isDefault ? '' : ' — custom key'} installed`;
|
|
507
|
+
case 'collision':
|
|
508
|
+
return `inbox popup binding: NOT installed — ${binding.key} is already bound by another action. Re-bind explicitly with \`hl inbox bind --key <tmux-key>\`.`;
|
|
509
|
+
case 'unbound':
|
|
510
|
+
return 'inbox popup binding: not bound';
|
|
511
|
+
default:
|
|
512
|
+
return 'inbox popup binding: unavailable (no tmux server)';
|
|
513
|
+
}
|
|
514
|
+
}
|
|
460
515
|
export function formatSetupExecutionSummary(summary) {
|
|
461
516
|
return [
|
|
462
517
|
'crtr sys setup complete',
|
|
@@ -464,6 +519,8 @@ export function formatSetupExecutionSummary(summary) {
|
|
|
464
519
|
summary.skipped.length > 0 ? `skipped: ${summary.skipped.join(', ')}` : 'skipped: none',
|
|
465
520
|
summary.exaKeyWritten ? `exa key written: ${summary.exaKeyPath}` : 'exa key written: no',
|
|
466
521
|
summary.composioKeyWritten ? `composio key written: ${summary.composioKeyPath}` : 'composio key written: no',
|
|
522
|
+
formatInboxBindingLine(summary.inboxBinding),
|
|
523
|
+
`interaction roots registered: ${summary.interactionRootsRegistered}`,
|
|
467
524
|
].join('\n') + '\n';
|
|
468
525
|
}
|
|
469
526
|
export async function executeSetupSubmission(submission) {
|
|
@@ -499,6 +556,9 @@ export async function executeSetupSubmission(submission) {
|
|
|
499
556
|
if (composioKeyWritten) {
|
|
500
557
|
await writeComposioKey(submission.composioKeyPath, submission.composioKey);
|
|
501
558
|
}
|
|
559
|
+
// Front-door reconciliation: install the ⌥I inbox popup binding and register
|
|
560
|
+
// known nodes' interaction roots for `human _deliver` callback. Best-effort.
|
|
561
|
+
const { binding, interactionRootsRegistered } = reconcileInboxFrontDoor();
|
|
502
562
|
return summarizeSetupExecution({
|
|
503
563
|
installedPackages,
|
|
504
564
|
skippedPackages,
|
|
@@ -508,5 +568,7 @@ export async function executeSetupSubmission(submission) {
|
|
|
508
568
|
exaKeyPath: submission.exaKeyPath,
|
|
509
569
|
composioKeyWritten,
|
|
510
570
|
composioKeyPath: submission.composioKeyPath,
|
|
571
|
+
inboxBinding: binding,
|
|
572
|
+
interactionRootsRegistered,
|
|
511
573
|
});
|
|
512
574
|
}
|
|
@@ -41,7 +41,7 @@ const theme = {
|
|
|
41
41
|
return getMarkdownTheme().bold(text);
|
|
42
42
|
},
|
|
43
43
|
};
|
|
44
|
-
import { buildSetupInitialState, executeSetupSubmission, formatSetupExecutionSummary, renderSetupStaticInstructions, resolveExaKeyPath, resolveComposioKeyPath, detectExaKey, detectComposioKey, SetupKeybindingsDraft, } from './setup-core.js';
|
|
44
|
+
import { buildSetupInitialState, executeSetupSubmission, formatSetupExecutionSummary, reconcileInboxFrontDoor, renderSetupStaticInstructions, resolveExaKeyPath, resolveComposioKeyPath, detectExaKey, detectComposioKey, SetupKeybindingsDraft, } from './setup-core.js';
|
|
45
45
|
function wrapText(text, width) {
|
|
46
46
|
const clean = text.trim();
|
|
47
47
|
if (clean === '')
|
|
@@ -1299,6 +1299,12 @@ export class SetupWizard {
|
|
|
1299
1299
|
}
|
|
1300
1300
|
async function runSetup() {
|
|
1301
1301
|
const state = buildSetupInitialState();
|
|
1302
|
+
// Front-door reconciliation: invoking `crtr sys setup` — TTY or not —
|
|
1303
|
+
// installs the collision-safe ⌥I inbox popup binding and registers known
|
|
1304
|
+
// nodes' interaction roots. Best-effort + idempotent, so it is safe to run on
|
|
1305
|
+
// every invocation regardless of whether the wizard is opened, canceled, or
|
|
1306
|
+
// submitted (the post-submission call re-runs it harmlessly).
|
|
1307
|
+
reconcileInboxFrontDoor();
|
|
1302
1308
|
if (!process.stdout.isTTY) {
|
|
1303
1309
|
process.stdout.write(renderSetupStaticInstructions(state));
|
|
1304
1310
|
return;
|
|
@@ -1397,6 +1403,7 @@ export const sysSetupLeaf = defineLeaf({
|
|
|
1397
1403
|
'Collects companion package installs, system dependency installs, API keys, and sparse user overrides for crouter-owned keybindings across the remaining tabs.',
|
|
1398
1404
|
'Saves validated keybinding changes atomically in the user config and refreshes crouter-owned tmux bindings without overwriting occupied user keys.',
|
|
1399
1405
|
'Runs the bundled pi CLI to install the selected companion packages, writes EXA_API_KEY to ~/.crouter/exa.key and COMPOSIO_API_KEY to ~/.crouter/composio.key when provided, and prints a summary.',
|
|
1406
|
+
'On every invocation (best-effort, idempotent), installs the collision-safe ⌥I humanloop inbox popup binding on the current tmux server (leaving any pre-existing binding untouched and reporting the collision remedy) and registers known nodes\' interaction roots so resolved tickets can call back into the asking node.',
|
|
1400
1407
|
],
|
|
1401
1408
|
},
|
|
1402
1409
|
run: async () => {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/human-deliver-e2e.test.ts
|
|
2
|
+
//
|
|
3
|
+
// M2 e2e — drive a REAL completion through the REGISTERED crouter handler as a
|
|
4
|
+
// SUBPROCESS, not in-process. This exercises the whole cutover seam that the
|
|
5
|
+
// in-process human-deliver.test.ts stubs out with a benign handler:
|
|
6
|
+
// registerInboxRoot(crouterDeliverHandler()) → submitDeck → claim →
|
|
7
|
+
// completeDeck → humanloop spawns `crouterCliEntry human _deliver` on stdin →
|
|
8
|
+
// that CLI subprocess reads the canvas DB at the temp CRTR_HOME, pushFinals
|
|
9
|
+
// the envelope to the parent, and exits 0.
|
|
10
|
+
//
|
|
11
|
+
// crouterDeliverHandler() resolves the runnable CLI entry from the loaded
|
|
12
|
+
// shared.ts location. Under `tsx`, that is `src/cli.ts` via the tsx ESM loader
|
|
13
|
+
// (dist/cli.js is not a sibling of src/); either entry is a real subprocess that
|
|
14
|
+
// must resolve the canvas at CRTR_HOME, which humanloop's dispatch inherits from
|
|
15
|
+
// this process's env.
|
|
16
|
+
import { test, before, beforeEach, after } from 'node:test';
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { mkdtempSync, mkdirSync, realpathSync, readFileSync, existsSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { registerInboxRoot, submitDeck, validateDeck, claimTicket, completeDeck, atomicWriteJson, } from '@crouton-kit/humanloop';
|
|
22
|
+
import { crouterDeliverHandler } from '../../commands/human/shared.js';
|
|
23
|
+
import { spawnNode } from '../runtime/nodes.js';
|
|
24
|
+
import { getNode, closeDb } from '../canvas/index.js';
|
|
25
|
+
import { readInboxSince } from '../feed/inbox.js';
|
|
26
|
+
let home;
|
|
27
|
+
let stateHome;
|
|
28
|
+
let root;
|
|
29
|
+
let priorHome;
|
|
30
|
+
before(() => {
|
|
31
|
+
priorHome = process.env['HOME'];
|
|
32
|
+
});
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
closeDb();
|
|
35
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-e2e-home-'));
|
|
36
|
+
stateHome = mkdtempSync(join(tmpdir(), 'crtr-e2e-state-'));
|
|
37
|
+
const rootDir = mkdtempSync(join(tmpdir(), 'crtr-e2e-root-'));
|
|
38
|
+
process.env['CRTR_HOME'] = home;
|
|
39
|
+
process.env['HOME'] = home;
|
|
40
|
+
process.env['XDG_STATE_HOME'] = stateHome;
|
|
41
|
+
delete process.env['CRTR_NODE_ID'];
|
|
42
|
+
// Register with the REAL crouter completion handler — this is what humanloop
|
|
43
|
+
// will spawn as a subprocess on resolution.
|
|
44
|
+
root = registerInboxRoot({ root: realpathSync(rootDir), owner: 'crouter', handler: crouterDeliverHandler() }).root;
|
|
45
|
+
});
|
|
46
|
+
after(() => {
|
|
47
|
+
closeDb();
|
|
48
|
+
if (priorHome !== undefined)
|
|
49
|
+
process.env['HOME'] = priorHome;
|
|
50
|
+
else
|
|
51
|
+
delete process.env['HOME'];
|
|
52
|
+
delete process.env['CRTR_HOME'];
|
|
53
|
+
delete process.env['XDG_STATE_HOME'];
|
|
54
|
+
delete process.env['CRTR_NODE_ID'];
|
|
55
|
+
});
|
|
56
|
+
function reportBody(ref) {
|
|
57
|
+
const raw = readFileSync(ref, 'utf8');
|
|
58
|
+
const end = raw.indexOf('\n---\n');
|
|
59
|
+
return end === -1 ? raw : raw.slice(end + 5);
|
|
60
|
+
}
|
|
61
|
+
test('a real completeDeck dispatches the registered crouter CLI subprocess, which pushFinals the envelope to the parent', async () => {
|
|
62
|
+
const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
|
|
63
|
+
const bridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-ask', lifecycle: 'terminal' }).node_id;
|
|
64
|
+
const dir = join(root, bridge);
|
|
65
|
+
mkdirSync(dir, { recursive: true });
|
|
66
|
+
atomicWriteJson(join(dir, 'run.json'), { mode: 'ask', job_id: bridge });
|
|
67
|
+
const deck = validateDeck({ title: 'Ship it?', interactions: [{ id: 'question', title: 'Ship it?', options: [], allowFreetext: true }] });
|
|
68
|
+
submitDeck({ root, id: bridge, deck });
|
|
69
|
+
const claim = claimTicket(dir);
|
|
70
|
+
assert.ok(claim !== null, 'claimed the fresh ticket');
|
|
71
|
+
// Release the test process's DB handle so the spawned handler subprocess has
|
|
72
|
+
// clean access to the temp canvas at CRTR_HOME.
|
|
73
|
+
closeDb();
|
|
74
|
+
const { won } = await completeDeck(dir, [{ id: 'question', freetext: 'yes, ship' }], claim.token);
|
|
75
|
+
assert.equal(won, true, 'completeDeck won');
|
|
76
|
+
// humanloop writes delivery.json only after the handler exits 0.
|
|
77
|
+
assert.ok(existsSync(join(dir, 'delivery.json')), 'the handler subprocess acked delivery (exit 0)');
|
|
78
|
+
assert.ok(!existsSync(join(dir, 'delivery-error.json')), 'no delivery error');
|
|
79
|
+
// The subprocess pushFinal'd through the real CLI: bridge finalized + parent inbox has the envelope.
|
|
80
|
+
assert.equal(getNode(bridge)?.status, 'done', 'bridge finalized by the subprocess handler');
|
|
81
|
+
const finals = readInboxSince(parent).filter((e) => e.from === bridge && e.kind === 'final');
|
|
82
|
+
assert.equal(finals.length, 1, 'exactly one pushFinal reached the parent via the subprocess');
|
|
83
|
+
const env = JSON.parse(reportBody(finals[0].ref));
|
|
84
|
+
assert.equal(env.schema, 'humanloop.response/v2');
|
|
85
|
+
assert.deepEqual(env.responses, [{ id: 'question', freetext: 'yes, ship' }]);
|
|
86
|
+
});
|
|
87
|
+
test('the registered handler argv is an absolute, cwd-independent runnable CLI entry', () => {
|
|
88
|
+
const handler = crouterDeliverHandler();
|
|
89
|
+
assert.equal(handler.command, process.execPath);
|
|
90
|
+
// Last two args are always the subcommand path.
|
|
91
|
+
assert.deepEqual(handler.args.slice(-2), ['human', '_deliver']);
|
|
92
|
+
// The CLI entry is an existing absolute file (dist/cli.js when built, else src/cli.ts under tsx).
|
|
93
|
+
const entry = handler.args.find((a) => a.endsWith('cli.js') || a.endsWith('cli.ts'));
|
|
94
|
+
assert.ok(entry !== undefined && entry.startsWith('/') && existsSync(entry), `handler CLI entry must be an existing absolute path: ${entry}`);
|
|
95
|
+
});
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/human-deliver.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Unit C1 — crouter as a humanloop producer + completion bridge. These tests
|
|
4
|
+
// pin the two crouter boundaries the hard cut leaves behind:
|
|
5
|
+
// 1. ask/review ENQUEUE only — they create a bridge node, write a minimal
|
|
6
|
+
// run.json, submit to humanloop, and return immediately. No pane/pid.
|
|
7
|
+
// 2. `_deliver` is the registered completion handler — it turns a humanloop
|
|
8
|
+
// completion event into the existing `pushFinal` delivery (deck/review) or
|
|
9
|
+
// a quiet bridge finalization (canceled), idempotently.
|
|
10
|
+
//
|
|
11
|
+
// Everything crosses the real humanloop package boundary: fixtures are the
|
|
12
|
+
// canonical `response.json` union that humanloop's strict `readTicketResult`
|
|
13
|
+
// decodes. Tickets live under a root REGISTERED to owner 'crouter' (the
|
|
14
|
+
// delivery contract), isolated via a temp XDG_STATE_HOME + HOME + CRTR_HOME.
|
|
15
|
+
import { test, describe, before, after, beforeEach } from 'node:test';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { mkdtempSync, mkdirSync, writeFileSync, realpathSync, readFileSync, existsSync, symlinkSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { dirname, join, basename } from 'node:path';
|
|
20
|
+
import { registerInboxRoot, submitDeck, validateDeck, claimTicket, completeDeck, cancelTicket, readTicketResult, atomicWriteJson, } from '@crouton-kit/humanloop';
|
|
21
|
+
import { spawnNode } from '../runtime/nodes.js';
|
|
22
|
+
import { getNode, listNodes, closeDb } from '../canvas/index.js';
|
|
23
|
+
import { readInboxSince } from '../feed/inbox.js';
|
|
24
|
+
import { humanAsk, humanReview, humanNotify } from '../../commands/human/prompts.js';
|
|
25
|
+
import { humanDeliver } from '../../commands/human/queue.js';
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Isolated environment
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
let home; // CRTR_HOME (canvas db) + HOME (os.homedir → interactionsRoot)
|
|
30
|
+
let stateHome; // XDG_STATE_HOME (humanloop root registry)
|
|
31
|
+
let root; // canonical registered interaction root, owner 'crouter'
|
|
32
|
+
let priorHome;
|
|
33
|
+
const ISO = '2026-07-10T12:00:00.000Z';
|
|
34
|
+
/** A benign direct-exec handler so humanloop's completion dispatch during the
|
|
35
|
+
* submit/cancel race is a fast no-op — the crouter delivery is exercised by
|
|
36
|
+
* calling `_deliver` in-process, not through this subprocess. */
|
|
37
|
+
function benignHandler() {
|
|
38
|
+
return { command: process.execPath, args: ['-e', ''] };
|
|
39
|
+
}
|
|
40
|
+
/** Register `dir` (created fresh) as an owner-'crouter' root and return its
|
|
41
|
+
* canonical path. */
|
|
42
|
+
function registerRoot(dir) {
|
|
43
|
+
return registerInboxRoot({ root: dir, owner: 'crouter', handler: benignHandler() }).root;
|
|
44
|
+
}
|
|
45
|
+
/** Create a subscribed parent + a terminal human bridge child; return both ids.
|
|
46
|
+
* The parent is auto-subscribed to the bridge at spawn, so a `pushFinal` from
|
|
47
|
+
* the bridge fans into the parent's inbox — the sole answer route. */
|
|
48
|
+
function bridgeUnder() {
|
|
49
|
+
const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
|
|
50
|
+
const bridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-ask', lifecycle: 'terminal' }).node_id;
|
|
51
|
+
return { parent, bridge };
|
|
52
|
+
}
|
|
53
|
+
/** Materialize an interaction dir under the registered root with a run.json. */
|
|
54
|
+
function seedTicket(id, mode, jobId) {
|
|
55
|
+
const dir = join(root, id);
|
|
56
|
+
mkdirSync(dir, { recursive: true });
|
|
57
|
+
atomicWriteJson(join(dir, 'run.json'), { mode, ...(jobId !== undefined ? { job_id: jobId } : {}) });
|
|
58
|
+
return dir;
|
|
59
|
+
}
|
|
60
|
+
function completionEvent(dir, kind) {
|
|
61
|
+
return {
|
|
62
|
+
schema: 'humanloop.completion/v1',
|
|
63
|
+
root,
|
|
64
|
+
dir,
|
|
65
|
+
ticketId: dir.split('/').pop(),
|
|
66
|
+
kind,
|
|
67
|
+
outcome: kind === 'canceled' ? 'canceled' : 'resolved',
|
|
68
|
+
responsePath: join(dir, 'response.json'),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** Read the report body a subscriber inbox pointer references (strips YAML front). */
|
|
72
|
+
function reportBody(ref) {
|
|
73
|
+
const raw = readFileSync(ref, 'utf8');
|
|
74
|
+
const end = raw.indexOf('\n---\n');
|
|
75
|
+
return end === -1 ? raw : raw.slice(end + 5);
|
|
76
|
+
}
|
|
77
|
+
before(() => {
|
|
78
|
+
priorHome = process.env['HOME'];
|
|
79
|
+
});
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
closeDb();
|
|
82
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-deliver-home-'));
|
|
83
|
+
stateHome = mkdtempSync(join(tmpdir(), 'crtr-deliver-state-'));
|
|
84
|
+
const rootDir = mkdtempSync(join(tmpdir(), 'crtr-deliver-root-'));
|
|
85
|
+
process.env['CRTR_HOME'] = home;
|
|
86
|
+
process.env['HOME'] = home;
|
|
87
|
+
process.env['XDG_STATE_HOME'] = stateHome;
|
|
88
|
+
delete process.env['CRTR_NODE_ID'];
|
|
89
|
+
root = registerRoot(realpathSync(rootDir));
|
|
90
|
+
});
|
|
91
|
+
after(() => {
|
|
92
|
+
closeDb();
|
|
93
|
+
if (priorHome !== undefined)
|
|
94
|
+
process.env['HOME'] = priorHome;
|
|
95
|
+
else
|
|
96
|
+
delete process.env['HOME'];
|
|
97
|
+
delete process.env['CRTR_HOME'];
|
|
98
|
+
delete process.env['XDG_STATE_HOME'];
|
|
99
|
+
delete process.env['CRTR_NODE_ID'];
|
|
100
|
+
});
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// (a) ask/review enqueue immediately — no pane/pid; run.json = {mode, job_id}
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
describe('enqueue is producer-only', () => {
|
|
105
|
+
test('ask returns a job handle with no pane/pid fields and a minimal run.json', async () => {
|
|
106
|
+
const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
|
|
107
|
+
process.env['CRTR_NODE_ID'] = parent;
|
|
108
|
+
const r = (await humanAsk.run({ question: 'Ship it?' }));
|
|
109
|
+
assert.equal(typeof r['job_id'], 'string');
|
|
110
|
+
assert.equal(typeof r['dir'], 'string');
|
|
111
|
+
assert.equal(typeof r['follow_up'], 'string');
|
|
112
|
+
for (const forbidden of ['pane_id', 'render_pane_id', 'pid', 'worker_pid']) {
|
|
113
|
+
assert.ok(!(forbidden in r), `ask result must not expose ${forbidden}`);
|
|
114
|
+
}
|
|
115
|
+
const rc = JSON.parse(readFileSync(join(r['dir'], 'run.json'), 'utf8'));
|
|
116
|
+
assert.deepEqual(rc, { mode: 'ask', job_id: r['job_id'] });
|
|
117
|
+
const bridge = getNode(r['job_id']);
|
|
118
|
+
assert.equal(bridge?.kind, 'human');
|
|
119
|
+
assert.equal(bridge?.parent, parent);
|
|
120
|
+
});
|
|
121
|
+
test('review returns immediately and writes run.json = {mode:review, job_id}', async () => {
|
|
122
|
+
const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
|
|
123
|
+
process.env['CRTR_NODE_ID'] = parent;
|
|
124
|
+
const md = join(home, 'plan.md');
|
|
125
|
+
writeFileSync(md, '# Plan\n\nline\n');
|
|
126
|
+
const r = (await humanReview.run({ file: md }));
|
|
127
|
+
assert.equal(typeof r['job_id'], 'string');
|
|
128
|
+
assert.ok(!('pane_id' in r) && !('render_pane_id' in r), 'no pane fields');
|
|
129
|
+
const rc = JSON.parse(readFileSync(join(r['dir'], 'run.json'), 'utf8'));
|
|
130
|
+
assert.deepEqual(rc, { mode: 'review', job_id: r['job_id'] });
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// (b) deck _deliver: pushFinal exactly once, parent inbox gets the envelope
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
describe('_deliver: deck', () => {
|
|
137
|
+
test('pushFinals the ResolutionEnvelope once and wakes the subscribed parent', async () => {
|
|
138
|
+
const { parent, bridge } = bridgeUnder();
|
|
139
|
+
const dir = seedTicket(bridge, 'ask', bridge);
|
|
140
|
+
const deckResult = {
|
|
141
|
+
schema: 'humanloop.response/v2',
|
|
142
|
+
kind: 'deck',
|
|
143
|
+
responses: [{ id: 'question', freetext: 'yes, ship' }],
|
|
144
|
+
summary: 'Ship it?: yes, ship',
|
|
145
|
+
completedAt: ISO,
|
|
146
|
+
};
|
|
147
|
+
writeFileSync(join(dir, 'response.json'), JSON.stringify(deckResult));
|
|
148
|
+
const out = (await humanDeliver.run({ body: JSON.stringify(completionEvent(dir, 'deck')) }));
|
|
149
|
+
assert.equal(out.delivered, true);
|
|
150
|
+
// Bridge finalized.
|
|
151
|
+
closeDb();
|
|
152
|
+
assert.equal(getNode(bridge)?.status, 'done');
|
|
153
|
+
// Parent inbox has exactly one final pointer carrying the envelope.
|
|
154
|
+
const finals = readInboxSince(parent).filter((e) => e.from === bridge && e.kind === 'final');
|
|
155
|
+
assert.equal(finals.length, 1, 'exactly one pushFinal reached the parent');
|
|
156
|
+
const env = JSON.parse(reportBody(finals[0].ref));
|
|
157
|
+
assert.equal(env.schema, 'humanloop.response/v2');
|
|
158
|
+
assert.deepEqual(env.responses, deckResult.responses);
|
|
159
|
+
assert.equal(env.summary, deckResult.summary);
|
|
160
|
+
assert.equal(env.completedAt, ISO);
|
|
161
|
+
assert.equal(env.responsePath, join(dir, 'response.json'));
|
|
162
|
+
});
|
|
163
|
+
// (c) replay idempotent
|
|
164
|
+
test('a second _deliver is idempotent — no second push, still delivered', async () => {
|
|
165
|
+
const { parent, bridge } = bridgeUnder();
|
|
166
|
+
const dir = seedTicket(bridge, 'ask', bridge);
|
|
167
|
+
writeFileSync(join(dir, 'response.json'), JSON.stringify({ schema: 'humanloop.response/v2', kind: 'deck', responses: [{ id: 'q', freetext: 'x' }], summary: 'q: x', completedAt: ISO }));
|
|
168
|
+
const ev = JSON.stringify(completionEvent(dir, 'deck'));
|
|
169
|
+
await humanDeliver.run({ body: ev });
|
|
170
|
+
const replay = (await humanDeliver.run({ body: ev }));
|
|
171
|
+
assert.equal(replay.delivered, true);
|
|
172
|
+
closeDb();
|
|
173
|
+
const finals = readInboxSince(parent).filter((e) => e.from === bridge && e.kind === 'final');
|
|
174
|
+
assert.equal(finals.length, 1, 'replay after a terminal bridge pushes nothing further');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// (d) review _deliver: delivered == bare FeedbackResult == output projection
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
describe('_deliver: review', () => {
|
|
181
|
+
test('pushes the bare FeedbackResult, matching the output projection content', async () => {
|
|
182
|
+
const { parent, bridge } = bridgeUnder();
|
|
183
|
+
const dir = seedTicket(bridge, 'review', bridge);
|
|
184
|
+
const md = realpathSync(home) + '/spec.md';
|
|
185
|
+
writeFileSync(md, '# spec\n');
|
|
186
|
+
const feedback = {
|
|
187
|
+
file: md,
|
|
188
|
+
submitted: true,
|
|
189
|
+
approved: false,
|
|
190
|
+
comments: [{ id: 'c1', line: 1, endLine: 1, lineText: '# spec', comment: 'tighten', createdAt: ISO }],
|
|
191
|
+
submittedAt: ISO,
|
|
192
|
+
savedAt: ISO,
|
|
193
|
+
};
|
|
194
|
+
writeFileSync(join(dir, 'response.json'), JSON.stringify({ schema: 'humanloop.review-response/v1', kind: 'review', result: feedback, completedAt: ISO }));
|
|
195
|
+
// The output projection humanloop writes: the bare FeedbackResult.
|
|
196
|
+
const projection = join(dir, 'feedback.json');
|
|
197
|
+
writeFileSync(projection, JSON.stringify(feedback));
|
|
198
|
+
const out = (await humanDeliver.run({ body: JSON.stringify(completionEvent(dir, 'review')) }));
|
|
199
|
+
assert.equal(out.delivered, true);
|
|
200
|
+
closeDb();
|
|
201
|
+
const finals = readInboxSince(parent).filter((e) => e.from === bridge && e.kind === 'final');
|
|
202
|
+
assert.equal(finals.length, 1);
|
|
203
|
+
const delivered = JSON.parse(reportBody(finals[0].ref));
|
|
204
|
+
assert.deepEqual(delivered, feedback, 'delivered payload is the bare FeedbackResult');
|
|
205
|
+
assert.deepEqual(delivered, JSON.parse(readFileSync(projection, 'utf8')), 'delivered == output projection file');
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// (e) canceled _deliver: bridge finalized, deferred note skips the actor
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
describe('_deliver: canceled', () => {
|
|
212
|
+
test('finalizes the bridge and sends a deferred no-answer note to non-actor subscribers only', async () => {
|
|
213
|
+
// Two subscribers: the actor (who canceled) and another waiter.
|
|
214
|
+
const actor = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' }).node_id;
|
|
215
|
+
const bridge = spawnNode({ kind: 'human', parent: actor, cwd: home, name: 'human-ask', lifecycle: 'terminal' }).node_id;
|
|
216
|
+
const other = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'other' }).node_id;
|
|
217
|
+
// `other` also subscribes to the bridge.
|
|
218
|
+
const { subscribe } = await import('../canvas/canvas.js');
|
|
219
|
+
subscribe(other, bridge, true);
|
|
220
|
+
const dir = seedTicket(bridge, 'ask', bridge);
|
|
221
|
+
writeFileSync(join(dir, 'response.json'), JSON.stringify({ schema: 'humanloop.cancel/v1', kind: 'canceled', canceledAt: ISO, reason: 'stale', actor }));
|
|
222
|
+
const out = (await humanDeliver.run({ body: JSON.stringify(completionEvent(dir, 'canceled')) }));
|
|
223
|
+
assert.equal(out.delivered, true);
|
|
224
|
+
closeDb();
|
|
225
|
+
assert.equal(getNode(bridge)?.status, 'done', 'canceled bridge finalized');
|
|
226
|
+
// Actor (who canceled) gets no note; the other waiter does.
|
|
227
|
+
assert.equal(readInboxSince(actor).length, 0, 'the cancelling actor is not messaged');
|
|
228
|
+
const otherNotes = readInboxSince(other);
|
|
229
|
+
assert.equal(otherNotes.length, 1, 'the other waiter gets one deferred note');
|
|
230
|
+
assert.equal(otherNotes[0].tier, 'deferred');
|
|
231
|
+
assert.match(otherNotes[0].label, /canceled/);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// (e2) REGRESSION (Unit X DEFECT F) — a completion event whose root/dir carry an
|
|
236
|
+
// UNRESOLVED symlinked path component still delivers.
|
|
237
|
+
//
|
|
238
|
+
// Humanloop's registry stores every root realpath-canonicalized, but its
|
|
239
|
+
// `dispatchCompletion` only `resolve()`s the dir into the event (not realpath),
|
|
240
|
+
// and crouter's own `human cancel` hands `cancelTicket` a CRTR_HOME-derived dir
|
|
241
|
+
// that is likewise not realpath'd. On any host whose interaction path has a
|
|
242
|
+
// symlinked component (macOS `/tmp`→`/private/tmp`, `/var`→`/private/var`) the
|
|
243
|
+
// event thus carries `/tmp/…` while the registry holds `/private/tmp/…`. Before
|
|
244
|
+
// the fix, `_deliver` compared those with a plain `resolve()` and spuriously
|
|
245
|
+
// threw `interaction dir is not a direct child of the event root` (exit 2) →
|
|
246
|
+
// humanloop wrote delivery-error.json and NO delivery.json, and the cancel
|
|
247
|
+
// completion was never acknowledged. The deck-SUBMIT path dodged this only
|
|
248
|
+
// because the controller's dir already comes from scanning the realpath'd root.
|
|
249
|
+
// `_deliver` now realpath-canonicalizes both sides, so any caller's event
|
|
250
|
+
// delivers regardless of symlinked components.
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
describe('_deliver: symlinked event paths (DEFECT F regression)', () => {
|
|
253
|
+
test('delivers a canceled completion whose root/dir carry an unresolved symlink', async () => {
|
|
254
|
+
// A symlink whose target is the registered root's PARENT, so `symRoot`
|
|
255
|
+
// realpath-resolves to the registered (realpath'd) `root` but is a distinct
|
|
256
|
+
// string with an unresolved symlink component — exactly humanloop's event shape.
|
|
257
|
+
const linkParent = mkdtempSync(join(tmpdir(), 'crtr-deliver-link-'));
|
|
258
|
+
const link = join(linkParent, 'lnk');
|
|
259
|
+
symlinkSync(dirname(root), link);
|
|
260
|
+
const symRoot = join(link, basename(root));
|
|
261
|
+
assert.notEqual(symRoot, root, 'symRoot is a distinct string');
|
|
262
|
+
assert.equal(realpathSync(symRoot), root, 'but realpath-resolves to the registered root');
|
|
263
|
+
const actor = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' }).node_id;
|
|
264
|
+
const bridge = spawnNode({ kind: 'human', parent: actor, cwd: home, name: 'human-ask', lifecycle: 'terminal' }).node_id;
|
|
265
|
+
const dir = seedTicket(bridge, 'ask', bridge); // real (realpath'd) dir on disk
|
|
266
|
+
writeFileSync(join(dir, 'response.json'), JSON.stringify({ schema: 'humanloop.cancel/v1', kind: 'canceled', canceledAt: ISO, actor }));
|
|
267
|
+
// The event carries the SYMLINKED root/dir/responsePath (non-realpath), the
|
|
268
|
+
// way dispatchCompletion + `human cancel` build it on a symlinked host.
|
|
269
|
+
const symDir = join(symRoot, bridge);
|
|
270
|
+
const event = {
|
|
271
|
+
schema: 'humanloop.completion/v1',
|
|
272
|
+
root: symRoot,
|
|
273
|
+
dir: symDir,
|
|
274
|
+
ticketId: bridge,
|
|
275
|
+
kind: 'canceled',
|
|
276
|
+
outcome: 'canceled',
|
|
277
|
+
responsePath: join(symDir, 'response.json'),
|
|
278
|
+
};
|
|
279
|
+
const out = (await humanDeliver.run({ body: JSON.stringify(event) }));
|
|
280
|
+
assert.equal(out.delivered, true, 'the cancel completion delivers (no exit-2 rejection)');
|
|
281
|
+
closeDb();
|
|
282
|
+
assert.equal(getNode(bridge)?.status, 'done', 'the bridge is finalized exactly once');
|
|
283
|
+
assert.equal(readInboxSince(actor).length, 0, 'the cancelling actor is not messaged');
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// (f) submit/cancel race via humanloop primitives → one outcome; crouter converges
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
describe('submit/cancel race', () => {
|
|
290
|
+
test('one canonical outcome wins and _deliver converges the bridge to a single terminal state', async () => {
|
|
291
|
+
const { bridge } = bridgeUnder();
|
|
292
|
+
const dir = join(root, bridge);
|
|
293
|
+
mkdirSync(dir, { recursive: true });
|
|
294
|
+
atomicWriteJson(join(dir, 'run.json'), { mode: 'ask', job_id: bridge });
|
|
295
|
+
const deck = validateDeck({ title: 'Go?', interactions: [{ id: 'q', title: 'Go?', options: [], allowFreetext: true }] });
|
|
296
|
+
submitDeck({ root, id: bridge, deck });
|
|
297
|
+
const claim = claimTicket(dir);
|
|
298
|
+
assert.ok(claim !== null, 'claimed the fresh ticket');
|
|
299
|
+
// Race an answer against a cancellation on the same exclusive publish.
|
|
300
|
+
const [completed, canceled] = await Promise.allSettled([
|
|
301
|
+
completeDeck(dir, [{ id: 'q', freetext: 'go' }], claim.token),
|
|
302
|
+
cancelTicket(dir, { reason: 'changed mind', actor: 'someone' }),
|
|
303
|
+
]);
|
|
304
|
+
// Exactly one canonical final marker exists on disk.
|
|
305
|
+
const result = readTicketResult(dir);
|
|
306
|
+
assert.ok(result !== null, 'a single canonical response.json won');
|
|
307
|
+
assert.ok(result.kind === 'deck' || result.kind === 'canceled');
|
|
308
|
+
// Whatever won, at most one side reports a win.
|
|
309
|
+
const wonDeck = completed.status === 'fulfilled' && completed.value.won === true;
|
|
310
|
+
const wonCancel = canceled.status === 'fulfilled' && canceled.value.status === 'canceled';
|
|
311
|
+
assert.ok(!(wonDeck && wonCancel), 'submit and cancel cannot both win');
|
|
312
|
+
// Feed the winning outcome to crouter; the bridge reaches exactly one terminal state.
|
|
313
|
+
const out = (await humanDeliver.run({ body: JSON.stringify(completionEvent(dir, result.kind)) }));
|
|
314
|
+
assert.equal(out.delivered, true);
|
|
315
|
+
closeDb();
|
|
316
|
+
assert.equal(getNode(bridge)?.status, 'done', 'bridge converged to one terminal outcome');
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// (g) notify creates no node
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
describe('notify', () => {
|
|
323
|
+
test('enqueues a durable deck and creates no canvas node', async () => {
|
|
324
|
+
const before = listNodes().length;
|
|
325
|
+
const r = (await humanNotify.run({ title: 'Build done' }));
|
|
326
|
+
assert.equal(r['queued'], true);
|
|
327
|
+
assert.equal(typeof r['dir'], 'string');
|
|
328
|
+
assert.ok(existsSync(join(r['dir'], 'deck.json')), 'notify wrote a durable deck');
|
|
329
|
+
closeDb();
|
|
330
|
+
assert.equal(listNodes().length, before, 'notify created no node');
|
|
331
|
+
});
|
|
332
|
+
});
|
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
// at boot with "pi vehicle exited before the session came up".
|
|
6
6
|
//
|
|
7
7
|
// ROOT CAUSE: a kind:'human' row is NOT a broker-hosted agent node — it is a
|
|
8
|
-
// bookkeeping row for the `crtr human` bridge.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// records a `pi_pid` or `pi_session_id`. After the
|
|
8
|
+
// bookkeeping row for the `crtr human` bridge. Nothing runs in it: the human
|
|
9
|
+
// answers from humanloop's inbox, and the row's lifecycle is driven entirely by
|
|
10
|
+
// humanloop's completion handler (`crtr human _deliver`) calling `pushFinal` (or
|
|
11
|
+
// `human cancel`). So it never records a `pi_pid` or `pi_session_id`. After the
|
|
12
|
+
// headless hard-cut made the
|
|
12
13
|
// broker the universal node host, superviseTick supervised EVERY active|idle row
|
|
13
14
|
// as a broker engine: it read the human row (pid==null, session==null) as a
|
|
14
15
|
// never-booted broker, waited out the boot grace, then crash'd it and fired
|