@crouton-kit/crouter 0.3.82 → 0.3.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/builtin-pi-packages/pi-mode-switch/README.md +3 -3
  2. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +12 -4
  3. package/dist/clients/attach/__tests__/attach-keybindings.test.js +21 -4
  4. package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +104 -0
  5. package/dist/clients/attach/__tests__/crtr-output-render.test.js +17 -44
  6. package/dist/clients/attach/attach-cmd.d.ts +2 -3
  7. package/dist/clients/attach/attach-cmd.js +689 -689
  8. package/dist/clients/attach/attach-def.d.ts +2 -0
  9. package/dist/clients/attach/attach-def.js +93 -0
  10. package/dist/clients/attach/canvas-panels.d.ts +10 -3
  11. package/dist/clients/attach/canvas-panels.js +42 -16
  12. package/dist/clients/attach/chat-view.d.ts +20 -1
  13. package/dist/clients/attach/chat-view.js +85 -2
  14. package/dist/commands/__tests__/human.test.js +7 -22
  15. package/dist/commands/attention.js +31 -30
  16. package/dist/commands/human/prompts.js +57 -38
  17. package/dist/commands/human/queue.d.ts +1 -28
  18. package/dist/commands/human/queue.js +156 -520
  19. package/dist/commands/human/shared.d.ts +28 -68
  20. package/dist/commands/human/shared.js +37 -120
  21. package/dist/commands/human.js +15 -17
  22. package/dist/commands/node.js +5 -2
  23. package/dist/commands/surface.js +1 -1
  24. package/dist/commands/sys/__tests__/setup-core.test.js +1 -1
  25. package/dist/commands/sys/__tests__/setup-front-door.test.js +85 -0
  26. package/dist/commands/sys/setup-core.d.ts +10 -0
  27. package/dist/commands/sys/setup-core.js +39 -1
  28. package/dist/commands/sys/setup.js +10 -1
  29. package/dist/core/__tests__/boot.test.js +141 -6
  30. package/dist/core/__tests__/human-deliver-e2e.test.js +95 -0
  31. package/dist/core/__tests__/human-deliver.test.js +332 -0
  32. package/dist/core/__tests__/human-node-not-supervised.test.js +5 -4
  33. package/dist/core/__tests__/pid-identity-match.test.js +145 -0
  34. package/dist/core/__tests__/tmux-surface.test.js +180 -2
  35. package/dist/core/canvas/__tests__/attention.test.d.ts +1 -0
  36. package/dist/core/canvas/__tests__/attention.test.js +126 -0
  37. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +4 -4
  38. package/dist/core/canvas/__tests__/render-remote.test.js +1 -1
  39. package/dist/core/canvas/attention.d.ts +16 -14
  40. package/dist/core/canvas/attention.js +34 -29
  41. package/dist/core/canvas/boot.d.ts +45 -0
  42. package/dist/core/canvas/boot.js +36 -3
  43. package/dist/core/canvas/browse/app.js +3 -3
  44. package/dist/core/canvas/canvas.d.ts +40 -0
  45. package/dist/core/canvas/canvas.js +65 -1
  46. package/dist/core/canvas/pid.d.ts +86 -17
  47. package/dist/core/canvas/pid.js +152 -37
  48. package/dist/core/canvas/remote-canvas-source.d.ts +1 -1
  49. package/dist/core/canvas/remote-canvas-source.js +4 -2
  50. package/dist/core/canvas/render.d.ts +5 -5
  51. package/dist/core/canvas/render.js +14 -14
  52. package/dist/core/canvas/source.d.ts +2 -2
  53. package/dist/core/canvas/source.js +3 -3
  54. package/dist/core/keybindings/__tests__/inbox-affordance.test.d.ts +1 -0
  55. package/dist/core/keybindings/__tests__/inbox-affordance.test.js +38 -0
  56. package/dist/core/keybindings/__tests__/resolve.test.js +2 -1
  57. package/dist/core/keybindings/catalog.d.ts +2 -2
  58. package/dist/core/keybindings/catalog.js +4 -2
  59. package/dist/core/keybindings/inbox.d.ts +21 -0
  60. package/dist/core/keybindings/inbox.js +35 -0
  61. package/dist/core/keybindings/index.d.ts +1 -0
  62. package/dist/core/keybindings/index.js +1 -0
  63. package/dist/core/preview-registry.js +23 -29
  64. package/dist/core/profiles/select.js +4 -4
  65. package/dist/core/runtime/pi-vendored.js +15 -1
  66. package/dist/core/runtime/placement.d.ts +1 -10
  67. package/dist/core/runtime/placement.js +43 -26
  68. package/dist/core/runtime/session-list-cache.d.ts +22 -2
  69. package/dist/core/runtime/session-list-cache.js +92 -24
  70. package/dist/core/runtime/spawn.js +2 -1
  71. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  72. package/dist/core/runtime/tmux-chrome.js +1 -1
  73. package/dist/core/runtime/tmux.d.ts +1 -0
  74. package/dist/core/runtime/tmux.js +55 -1
  75. package/dist/core/spawn.d.ts +0 -59
  76. package/dist/core/spawn.js +5 -163
  77. package/dist/daemon/crtrd.js +36 -11
  78. package/dist/index.d.ts +2 -2
  79. package/dist/index.js +1 -1
  80. package/dist/web-client/assets/index-DZdKS-FB.js +78 -0
  81. package/dist/web-client/index.html +1 -1
  82. package/dist/web-client/sw.js +1 -1
  83. package/package.json +2 -2
  84. package/dist/core/__tests__/full/dead-pane-regression.test.js +0 -154
  85. package/dist/core/__tests__/full/human-new-window-regression.test.js +0 -104
  86. package/dist/core/__tests__/full/review-render-pane-regression.test.js +0 -133
  87. package/dist/core/__tests__/human-stranded-deliver.test.js +0 -145
  88. package/dist/core/__tests__/human-surface-target.test.js +0 -98
  89. package/dist/web-client/assets/index--md2ylfi.js +0 -78
  90. /package/dist/{core/__tests__/full/dead-pane-regression.test.d.ts → clients/attach/__tests__/chat-view-snapshot-ordering.test.d.ts} +0 -0
  91. /package/dist/{core/__tests__/full/human-new-window-regression.test.d.ts → commands/sys/__tests__/setup-front-door.test.d.ts} +0 -0
  92. /package/dist/core/__tests__/{full/review-render-pane-regression.test.d.ts → human-deliver-e2e.test.d.ts} +0 -0
  93. /package/dist/core/__tests__/{human-stranded-deliver.test.d.ts → human-deliver.test.d.ts} +0 -0
  94. /package/dist/core/__tests__/{human-surface-target.test.d.ts → pid-identity-match.test.d.ts} +0 -0
@@ -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,14 @@ 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
+ // registers known nodes' interaction roots so resolved tickets can call back
1304
+ // into the asking node. 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). The inbox-toggle
1307
+ // tmux key is a crouter keybinding, installed by the catalog/save path through
1308
+ // installTmuxBindings() like every other binding — not reconciled here.
1309
+ reconcileInboxFrontDoor();
1302
1310
  if (!process.stdout.isTTY) {
1303
1311
  process.stdout.write(renderSetupStaticInstructions(state));
1304
1312
  return;
@@ -1397,6 +1405,7 @@ export const sysSetupLeaf = defineLeaf({
1397
1405
  'Collects companion package installs, system dependency installs, API keys, and sparse user overrides for crouter-owned keybindings across the remaining tabs.',
1398
1406
  'Saves validated keybinding changes atomically in the user config and refreshes crouter-owned tmux bindings without overwriting occupied user keys.',
1399
1407
  '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.',
1408
+ 'On every invocation (best-effort, idempotent), registers known nodes\' interaction roots so resolved tickets can call back into the asking node. The inbox popup toggle is a crouter-owned keybinding, edited on the keybindings tab and installed through the catalog/save path alongside every other tmux binding.',
1400
1409
  ],
1401
1410
  },
1402
1411
  run: async () => {
@@ -17,15 +17,22 @@ import assert from 'node:assert/strict';
17
17
  import { mkdtempSync, rmSync } from 'node:fs';
18
18
  import { tmpdir } from 'node:os';
19
19
  import { join } from 'node:path';
20
- import { createNode, getRow, recordPid } from '../canvas/canvas.js';
21
- import { closeDb } from '../canvas/db.js';
22
- import { bootChanged, reconcileBootLiveness } from '../canvas/boot.js';
20
+ import { createNode, getRow, recordPid, migrateLegacyPidIdentities } from '../canvas/canvas.js';
21
+ import { closeDb, openDb } from '../canvas/db.js';
22
+ import { bootChanged, bootProvenSame, reconcileBootLiveness, reconcileAndMigrateBoot, } from '../canvas/boot.js';
23
23
  let home;
24
24
  /** Build a BootIdentity for injection. `bootId` null models the non-Linux
25
25
  * fallback (wall-clock window); a string models the exact kernel boot_id. */
26
26
  function ident(bootId, bootEpochMs) {
27
27
  return { bootId, bootEpochMs };
28
28
  }
29
+ const BOOT_A = '4b0c1d2e-3f40-4152-8637-a1b2c3d4e5f6';
30
+ /** Force a row's recorded pid + identity baseline directly (recordPid derives
31
+ * the identity from the real `ps`, which can't produce a controlled legacy
32
+ * `lstart#ticks` value on a non-Linux test host). */
33
+ function setPidIdentity(id, pid, identity) {
34
+ openDb().prepare('UPDATE nodes SET pi_pid = ?, pi_pid_identity = ? WHERE node_id = ?').run(pid, identity, id);
35
+ }
29
36
  function node(id, over = {}) {
30
37
  return {
31
38
  node_id: id,
@@ -80,12 +87,60 @@ test('bootChanged: wall-clock fallback (no boot_id) — epochs far apart are a D
80
87
  // ---------------------------------------------------------------------------
81
88
  // reconcileBootLiveness — the impure sweep against a real canvas.db
82
89
  // ---------------------------------------------------------------------------
90
+ // ---------------------------------------------------------------------------
91
+ // migrateLegacyPidIdentities — one-time in-place format migration at startup
92
+ // ---------------------------------------------------------------------------
93
+ test('migrateLegacyPidIdentities: a live legacy lstart#ticks broker is re-recorded to <bootId>#<ticks>', () => {
94
+ createNode(node('n1'));
95
+ setPidIdentity('n1', 111, 'Sat Jul 11 17:51:45 2026#53459'); // legacy baseline
96
+ // The pid still names the same live process; its CURRENT identity on this
97
+ // (now boot_id-capable) platform reads bootId#ticks with the SAME ticks.
98
+ const result = migrateLegacyPidIdentities((pid) => (pid === 111 ? `${BOOT_A}#53459` : undefined));
99
+ assert.deepEqual(result.migrated, ['n1']);
100
+ assert.equal(getRow('n1')?.pi_pid_identity, `${BOOT_A}#53459`, 'legacy row completed to boot-scoped format');
101
+ assert.equal(getRow('n1')?.pi_pid, 111, 'the pid itself is untouched');
102
+ });
103
+ test('migrateLegacyPidIdentities: a legacy row whose pid is GONE is left to normal liveness handling', () => {
104
+ createNode(node('n1'));
105
+ setPidIdentity('n1', 111, 'Sat Jul 11 17:51:45 2026#53459');
106
+ const result = migrateLegacyPidIdentities(() => undefined); // pid gone / probe failed
107
+ assert.deepEqual(result.migrated, []);
108
+ assert.equal(getRow('n1')?.pi_pid_identity, 'Sat Jul 11 17:51:45 2026#53459', 'untouched — liveness handles it');
109
+ });
110
+ test('migrateLegacyPidIdentities: a REUSED pid (different ticks) is NOT re-recorded', () => {
111
+ createNode(node('n1'));
112
+ setPidIdentity('n1', 111, 'Sat Jul 11 17:51:45 2026#53459');
113
+ // The pid now names a DIFFERENT process (different jiffies) — no match.
114
+ const result = migrateLegacyPidIdentities(() => `${BOOT_A}#99999`);
115
+ assert.deepEqual(result.migrated, []);
116
+ assert.equal(getRow('n1')?.pi_pid_identity, 'Sat Jul 11 17:51:45 2026#53459', 'reused pid left for liveness to clear');
117
+ });
118
+ test('migrateLegacyPidIdentities: an already boot-scoped row is skipped (not legacy)', () => {
119
+ createNode(node('n1'));
120
+ setPidIdentity('n1', 111, `${BOOT_A}#53459`); // already new-format
121
+ let captured = false;
122
+ const result = migrateLegacyPidIdentities(() => {
123
+ captured = true;
124
+ return `${BOOT_A}#53459`;
125
+ });
126
+ assert.deepEqual(result.migrated, []);
127
+ assert.equal(captured, false, 'a non-legacy row is filtered out before any ps probe');
128
+ });
129
+ test('migrateLegacyPidIdentities: no readable boot_id (current identity still legacy) leaves the row untouched', () => {
130
+ createNode(node('n1'));
131
+ setPidIdentity('n1', 111, 'Sat Jul 11 17:51:45 2026#53459');
132
+ // Platform still composes legacy-shaped identities (no boot_id) — nothing to
133
+ // upgrade to; the legacy compare lane legitimately stays in play.
134
+ const result = migrateLegacyPidIdentities(() => 'Sun Jul 12 09:00:00 2026#53459');
135
+ assert.deepEqual(result.migrated, []);
136
+ assert.equal(getRow('n1')?.pi_pid_identity, 'Sat Jul 11 17:51:45 2026#53459', 'no boot_id → not migrated');
137
+ });
83
138
  test('reconcileBootLiveness: first-ever call seeds the marker and clears nothing', () => {
84
139
  const m = node('n1');
85
140
  createNode(m);
86
141
  recordPid('n1', 12345);
87
142
  const result = reconcileBootLiveness(ident('boot-A', 1_000_000));
88
- assert.deepEqual(result, { changed: false, cleared: [] });
143
+ assert.deepEqual(result, { changed: false, cleared: [], sameBootProven: false });
89
144
  assert.equal(getRow('n1')?.pi_pid, 12345, 'a first-ever run never clears an existing pid');
90
145
  });
91
146
  test('reconcileBootLiveness: same boot (same boot_id) leaves pids untouched — MUST NOT weaken the normal daemon-restart case', () => {
@@ -94,7 +149,7 @@ test('reconcileBootLiveness: same boot (same boot_id) leaves pids untouched —
94
149
  recordPid('n1', 12345);
95
150
  reconcileBootLiveness(ident('boot-A', 1_000_000)); // seed
96
151
  const result = reconcileBootLiveness(ident('boot-A', 1_000_000 + 3_000)); // 3s later, same boot
97
- assert.deepEqual(result, { changed: false, cleared: [] });
152
+ assert.deepEqual(result, { changed: false, cleared: [], sameBootProven: true });
98
153
  assert.equal(getRow('n1')?.pi_pid, 12345, 'a live pid on the same boot is never cleared');
99
154
  });
100
155
  test('reconcileBootLiveness: a boot change clears every recorded pid, regardless of status', () => {
@@ -135,6 +190,86 @@ test('reconcileBootLiveness: after reconciling, the SAME boot no longer re-trigg
135
190
  reconcileBootLiveness(ident('boot-B', 1_000_000 + 60 * 60_000)); // boot change → clears + persists boot B
136
191
  recordPid('n1', 999); // a fresh broker re-records its pid post-revive
137
192
  const result = reconcileBootLiveness(ident('boot-B', 1_000_000 + 60 * 60_000 + 2_000)); // still boot B, 2s later
138
- assert.deepEqual(result, { changed: false, cleared: [] });
193
+ assert.deepEqual(result, { changed: false, cleared: [], sameBootProven: true });
139
194
  assert.equal(getRow('n1')?.pi_pid, 999, 'a pid recorded AFTER reconciliation, within the same boot, is never touched');
140
195
  });
196
+ // ---------------------------------------------------------------------------
197
+ // bootProvenSame + the daemon migration gate (Major-1 regression)
198
+ //
199
+ // The legacy identity migration blesses a recorded pid as a current-boot
200
+ // process on a bare ticks match, so it may run ONLY when same-boot provenance
201
+ // is POSITIVELY proven (exact boot_id equal on both sides). `!bootChanged` is
202
+ // NOT that proof — it is also true with no/malformed marker or the wall-clock
203
+ // epoch fallback, where an unrelated cross-boot process can collide on
204
+ // pid+ticks and be permanently baptized.
205
+ // ---------------------------------------------------------------------------
206
+ test('bootProvenSame: exact equal boot_id on both sides is proof', () => {
207
+ assert.equal(bootProvenSame(ident('boot-A', 2_000), ident('boot-A', 1_000)), true);
208
+ });
209
+ test('bootProvenSame: no history is NOT proof (absent/malformed marker)', () => {
210
+ assert.equal(bootProvenSame(ident('boot-A', 1_000), null), false);
211
+ });
212
+ test('bootProvenSame: a missing boot_id on EITHER side is NOT proof (epoch fallback)', () => {
213
+ assert.equal(bootProvenSame(ident(null, 1_000), ident('boot-A', 1_000)), false, 'current lacks boot_id');
214
+ assert.equal(bootProvenSame(ident('boot-A', 1_000), ident(null, 1_000)), false, 'known lacks boot_id');
215
+ assert.equal(bootProvenSame(ident(null, 1_000), ident(null, 1_000)), false, 'both lack boot_id');
216
+ });
217
+ test('bootProvenSame: a different boot_id is not proof (it is a change)', () => {
218
+ assert.equal(bootProvenSame(ident('boot-B', 1_000), ident('boot-A', 1_000)), false);
219
+ });
220
+ // Drive the ACTUAL production startup composition (reconcile, then gate the
221
+ // migration on proven same-boot provenance) rather than a test-side copy, so a
222
+ // revert that inverts/drops the gate or reorders migration ahead of
223
+ // reconciliation is caught here. `runDaemon` calls this same helper.
224
+ function daemonBootStartup(current, captureIdentity) {
225
+ return reconcileAndMigrateBoot(migrateLegacyPidIdentities, current, captureIdentity);
226
+ }
227
+ const LEGACY = 'Sat Jul 11 17:51:45 2026#53459';
228
+ test('migration gate: a cross-boot equal-ticks legacy row is NOT migrated when the marker is ABSENT (first-ever startup)', () => {
229
+ createNode(node('n1'));
230
+ setPidIdentity('n1', 111, LEGACY);
231
+ // No marker yet. A current-boot stranger process happens to hold pid 111 with
232
+ // the same ticks — an equal-ticks collision that would falsely migrate if the
233
+ // sweep ran. It must NOT: provenance is unproven.
234
+ const result = daemonBootStartup(ident(BOOT_A, 1_000_000), () => `${BOOT_A}#53459`);
235
+ assert.deepEqual(result.migrated, [], 'no marker → unproven → migration skipped');
236
+ assert.equal(getRow('n1')?.pi_pid_identity, LEGACY, 'the stranger is NOT baptized as the recorded process');
237
+ });
238
+ test('migration gate: a cross-boot equal-ticks legacy row is NOT migrated under the epoch fallback (no boot_id)', () => {
239
+ createNode(node('n1'));
240
+ setPidIdentity('n1', 111, LEGACY);
241
+ reconcileBootLiveness(ident(null, 1_000_000)); // seed a no-boot_id marker
242
+ // A transient current boot_id read failure: reconcile sees null on both sides,
243
+ // the epochs are within tolerance so no change is DETECTED, but same-boot is
244
+ // NOT PROVEN. A cross-boot pid+ticks collision must not be migrated.
245
+ const result = daemonBootStartup(ident(null, 1_000_000 + 5_000), () => `${BOOT_A}#53459`);
246
+ assert.deepEqual(result.migrated, [], 'epoch fallback → unproven → migration skipped');
247
+ assert.equal(getRow('n1')?.pi_pid_identity, LEGACY, 'not migrated on an unproven boot');
248
+ });
249
+ test('migration gate: a legacy row IS migrated when same-boot provenance is PROVEN (exact boot_id match)', () => {
250
+ createNode(node('n1'));
251
+ setPidIdentity('n1', 111, LEGACY);
252
+ reconcileBootLiveness(ident(BOOT_A, 1_000_000)); // seed an exact-boot_id marker
253
+ // Same boot proven (boot_id equal on both sides); pid 111 still names the same
254
+ // live process (matching ticks) — the in-place upgrade completes.
255
+ const result = daemonBootStartup(ident(BOOT_A, 1_000_000 + 3_000), (pid) => pid === 111 ? `${BOOT_A}#53459` : undefined);
256
+ assert.deepEqual(result.migrated, ['n1']);
257
+ assert.equal(getRow('n1')?.pi_pid_identity, `${BOOT_A}#53459`, 'proven same boot → migrated in place');
258
+ });
259
+ test('migrateLegacyPidIdentities CAS: a concurrent re-record between probe and update is not clobbered', () => {
260
+ createNode(node('n1'));
261
+ setPidIdentity('n1', 111, LEGACY);
262
+ // The capture models the old broker still probing as its live legacy self,
263
+ // but a concurrent revive atomically re-records a FRESH (pid, identity) pair
264
+ // before the sweep's compare-and-swap update lands.
265
+ const result = migrateLegacyPidIdentities((pid) => {
266
+ if (pid === 111) {
267
+ setPidIdentity('n1', 222, `${BOOT_A}#77777`); // concurrent revive wins the row
268
+ return `${BOOT_A}#53459`; // stale probe result for the OLD pid
269
+ }
270
+ return undefined;
271
+ });
272
+ assert.deepEqual(result.migrated, [], 'CAS matched zero rows — no migration counted');
273
+ assert.equal(getRow('n1')?.pi_pid, 222, 'the concurrent revive pid stands');
274
+ assert.equal(getRow('n1')?.pi_pid_identity, `${BOOT_A}#77777`, 'the fresh identity is NOT overwritten by the stale one');
275
+ });
@@ -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
+ });