@crouton-kit/crouter 0.3.13 → 0.3.15

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 (233) hide show
  1. package/dist/build-root.d.ts +8 -0
  2. package/dist/build-root.js +30 -0
  3. package/dist/builtin-personas/design/base.md +3 -7
  4. package/dist/builtin-personas/design/orchestrator.md +4 -3
  5. package/dist/builtin-personas/developer/base.md +3 -7
  6. package/dist/builtin-personas/developer/orchestrator.md +5 -4
  7. package/dist/builtin-personas/explore/base.md +3 -7
  8. package/dist/builtin-personas/explore/orchestrator.md +1 -5
  9. package/dist/builtin-personas/general/base.md +2 -4
  10. package/dist/builtin-personas/general/orchestrator.md +2 -4
  11. package/dist/builtin-personas/lifecycle/resident.md +2 -0
  12. package/dist/builtin-personas/lifecycle/terminal.md +6 -0
  13. package/dist/builtin-personas/orchestration-kernel.md +42 -3
  14. package/dist/builtin-personas/plan/base.md +3 -5
  15. package/dist/builtin-personas/plan/orchestrator.md +5 -4
  16. package/dist/builtin-personas/plan/reviewers/architecture-fit/base.md +9 -0
  17. package/dist/builtin-personas/plan/reviewers/code-smells/base.md +9 -0
  18. package/dist/builtin-personas/plan/reviewers/pattern-consistency/base.md +9 -0
  19. package/dist/builtin-personas/plan/reviewers/requirements-coverage/base.md +9 -0
  20. package/dist/builtin-personas/plan/reviewers/security/base.md +9 -0
  21. package/dist/builtin-personas/review/base.md +3 -5
  22. package/dist/builtin-personas/review/orchestrator.md +2 -6
  23. package/dist/builtin-personas/runtime-base.md +3 -19
  24. package/dist/builtin-personas/spec/base.md +3 -5
  25. package/dist/builtin-personas/spec/orchestrator.md +4 -3
  26. package/dist/builtin-personas/spine/has-manager.md +10 -0
  27. package/dist/builtin-personas/spine/no-manager.md +2 -0
  28. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +96 -0
  29. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +49 -0
  30. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +49 -0
  31. package/dist/builtin-skills/skills/planning/SKILL.md +1 -1
  32. package/dist/builtin-skills/skills/spec/SKILL.md +2 -2
  33. package/dist/cli.js +6 -29
  34. package/dist/commands/__tests__/human.test.js +73 -2
  35. package/dist/commands/attention.js +76 -7
  36. package/dist/commands/canvas-prune.d.ts +2 -0
  37. package/dist/commands/canvas-prune.js +66 -0
  38. package/dist/commands/canvas.js +5 -8
  39. package/dist/commands/chord.d.ts +2 -0
  40. package/dist/commands/chord.js +143 -0
  41. package/dist/commands/daemon.js +8 -5
  42. package/dist/commands/dashboard.js +2 -0
  43. package/dist/commands/human/prompts.js +28 -27
  44. package/dist/commands/human/queue.d.ts +1 -0
  45. package/dist/commands/human/queue.js +105 -2
  46. package/dist/commands/human/shared.d.ts +28 -18
  47. package/dist/commands/human/shared.js +53 -60
  48. package/dist/commands/human.js +6 -14
  49. package/dist/commands/node.d.ts +11 -0
  50. package/dist/commands/node.js +381 -87
  51. package/dist/commands/pkg/market-inspect.js +6 -4
  52. package/dist/commands/pkg/market-manage.js +10 -6
  53. package/dist/commands/pkg/market.js +2 -4
  54. package/dist/commands/pkg/plugin-inspect.js +6 -4
  55. package/dist/commands/pkg/plugin-manage.js +12 -7
  56. package/dist/commands/pkg/plugin.js +2 -4
  57. package/dist/commands/pkg.js +0 -4
  58. package/dist/commands/push.js +178 -15
  59. package/dist/commands/revive.js +5 -3
  60. package/dist/commands/skill/author.js +6 -4
  61. package/dist/commands/skill/find.js +8 -5
  62. package/dist/commands/skill/read.js +2 -0
  63. package/dist/commands/skill/state.js +6 -4
  64. package/dist/commands/skill.js +0 -6
  65. package/dist/commands/sys/config.js +21 -7
  66. package/dist/commands/sys/doctor.js +2 -0
  67. package/dist/commands/sys/update.js +4 -0
  68. package/dist/commands/sys.js +0 -6
  69. package/dist/commands/tmux-spread.d.ts +2 -0
  70. package/dist/commands/tmux-spread.js +130 -0
  71. package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
  72. package/dist/core/__tests__/child-followup.test.d.ts +1 -0
  73. package/dist/core/__tests__/child-followup.test.js +83 -0
  74. package/dist/core/__tests__/close.test.d.ts +1 -0
  75. package/dist/core/__tests__/close.test.js +148 -0
  76. package/dist/core/__tests__/context-intro.test.d.ts +1 -0
  77. package/dist/core/__tests__/context-intro.test.js +196 -0
  78. package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
  79. package/dist/core/__tests__/daemon-boot.test.js +93 -0
  80. package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
  81. package/dist/core/__tests__/daemon-liveness.test.js +223 -0
  82. package/dist/core/__tests__/focuses.test.d.ts +1 -0
  83. package/dist/core/__tests__/focuses.test.js +259 -0
  84. package/dist/core/__tests__/fork.test.d.ts +1 -0
  85. package/dist/core/__tests__/fork.test.js +91 -0
  86. package/dist/core/__tests__/home-session.test.d.ts +1 -0
  87. package/dist/core/__tests__/home-session.test.js +153 -0
  88. package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
  89. package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
  90. package/dist/core/__tests__/keystone.test.d.ts +1 -0
  91. package/dist/core/__tests__/keystone.test.js +185 -0
  92. package/dist/core/__tests__/kickoff.test.d.ts +1 -0
  93. package/dist/core/__tests__/kickoff.test.js +89 -0
  94. package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
  95. package/dist/core/__tests__/lifecycle.test.js +178 -0
  96. package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
  97. package/dist/core/__tests__/listing-completeness.test.js +31 -0
  98. package/dist/core/__tests__/memory.test.d.ts +1 -0
  99. package/dist/core/__tests__/memory.test.js +152 -0
  100. package/dist/core/__tests__/migration.test.d.ts +1 -0
  101. package/dist/core/__tests__/migration.test.js +238 -0
  102. package/dist/core/__tests__/pane-column.test.d.ts +1 -0
  103. package/dist/core/__tests__/pane-column.test.js +153 -0
  104. package/dist/core/__tests__/passive-subscription.test.d.ts +1 -0
  105. package/dist/core/__tests__/passive-subscription.test.js +164 -0
  106. package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
  107. package/dist/core/__tests__/persona-compose.test.js +53 -0
  108. package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
  109. package/dist/core/__tests__/persona-subkind.test.js +62 -0
  110. package/dist/core/__tests__/persona.test.d.ts +1 -0
  111. package/dist/core/__tests__/persona.test.js +107 -0
  112. package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
  113. package/dist/core/__tests__/placement-focus.test.js +244 -0
  114. package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
  115. package/dist/core/__tests__/placement-reconcile.test.js +212 -0
  116. package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
  117. package/dist/core/__tests__/placement-revive.test.js +238 -0
  118. package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
  119. package/dist/core/__tests__/placement-teardown.test.js +183 -0
  120. package/dist/core/__tests__/prune.test.d.ts +1 -0
  121. package/dist/core/__tests__/prune.test.js +116 -0
  122. package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
  123. package/dist/core/__tests__/push-final-guard.test.js +71 -0
  124. package/dist/core/__tests__/relaunch.test.d.ts +1 -0
  125. package/dist/core/__tests__/relaunch.test.js +328 -0
  126. package/dist/core/__tests__/reset.test.js +26 -7
  127. package/dist/core/__tests__/revive.test.d.ts +1 -0
  128. package/dist/core/__tests__/revive.test.js +217 -0
  129. package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
  130. package/dist/core/__tests__/spawn-root.test.js +73 -0
  131. package/dist/core/__tests__/steer-note.test.d.ts +1 -0
  132. package/dist/core/__tests__/steer-note.test.js +39 -0
  133. package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
  134. package/dist/core/__tests__/stop-guard.test.js +82 -0
  135. package/dist/core/__tests__/subcommand-tier.test.d.ts +1 -0
  136. package/dist/core/__tests__/subcommand-tier.test.js +99 -0
  137. package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
  138. package/dist/core/__tests__/tmux-surface.test.js +106 -0
  139. package/dist/core/__tests__/unknown-path.test.js +8 -2
  140. package/dist/core/canvas/attention.d.ts +10 -0
  141. package/dist/core/canvas/attention.js +40 -0
  142. package/dist/core/canvas/canvas.d.ts +66 -7
  143. package/dist/core/canvas/canvas.js +209 -21
  144. package/dist/core/canvas/db.d.ts +8 -0
  145. package/dist/core/canvas/db.js +206 -4
  146. package/dist/core/canvas/focuses.d.ts +22 -0
  147. package/dist/core/canvas/focuses.js +80 -0
  148. package/dist/core/canvas/index.d.ts +3 -0
  149. package/dist/core/canvas/index.js +3 -0
  150. package/dist/core/canvas/labels.d.ts +27 -0
  151. package/dist/core/canvas/labels.js +36 -0
  152. package/dist/core/canvas/paths.d.ts +4 -0
  153. package/dist/core/canvas/paths.js +6 -0
  154. package/dist/core/canvas/render.js +25 -10
  155. package/dist/core/canvas/telemetry.d.ts +14 -0
  156. package/dist/core/canvas/telemetry.js +35 -0
  157. package/dist/core/canvas/types.d.ts +115 -12
  158. package/dist/core/command.d.ts +25 -1
  159. package/dist/core/command.js +48 -7
  160. package/dist/core/config.js +36 -2
  161. package/dist/core/feed/feed.js +14 -12
  162. package/dist/core/feed/inbox.d.ts +3 -1
  163. package/dist/core/feed/inbox.js +45 -5
  164. package/dist/core/feed/passive.d.ts +17 -0
  165. package/dist/core/feed/passive.js +92 -0
  166. package/dist/core/help.d.ts +59 -13
  167. package/dist/core/help.js +73 -28
  168. package/dist/core/personas/index.d.ts +1 -1
  169. package/dist/core/personas/index.js +1 -1
  170. package/dist/core/personas/loader.d.ts +40 -1
  171. package/dist/core/personas/loader.js +63 -1
  172. package/dist/core/personas/resolve.d.ts +13 -6
  173. package/dist/core/personas/resolve.js +46 -34
  174. package/dist/core/runtime/bearings.d.ts +20 -0
  175. package/dist/core/runtime/bearings.js +92 -0
  176. package/dist/core/runtime/close.d.ts +14 -0
  177. package/dist/core/runtime/close.js +151 -0
  178. package/dist/core/runtime/demote.d.ts +14 -0
  179. package/dist/core/runtime/demote.js +120 -0
  180. package/dist/core/runtime/front-door.js +1 -1
  181. package/dist/core/runtime/kickoff.d.ts +32 -6
  182. package/dist/core/runtime/kickoff.js +111 -37
  183. package/dist/core/runtime/launch.d.ts +29 -6
  184. package/dist/core/runtime/launch.js +85 -13
  185. package/dist/core/runtime/lifecycle.d.ts +13 -0
  186. package/dist/core/runtime/lifecycle.js +86 -0
  187. package/dist/core/runtime/memory.d.ts +43 -0
  188. package/dist/core/runtime/memory.js +165 -0
  189. package/dist/core/runtime/naming.d.ts +22 -0
  190. package/dist/core/runtime/naming.js +166 -0
  191. package/dist/core/runtime/nodes.d.ts +32 -1
  192. package/dist/core/runtime/nodes.js +60 -10
  193. package/dist/core/runtime/persona.d.ts +25 -0
  194. package/dist/core/runtime/persona.js +139 -0
  195. package/dist/core/runtime/placement.d.ts +287 -0
  196. package/dist/core/runtime/placement.js +663 -0
  197. package/dist/core/runtime/presence.d.ts +7 -32
  198. package/dist/core/runtime/presence.js +90 -110
  199. package/dist/core/runtime/promote.d.ts +18 -7
  200. package/dist/core/runtime/promote.js +70 -65
  201. package/dist/core/runtime/reset.d.ts +47 -4
  202. package/dist/core/runtime/reset.js +223 -52
  203. package/dist/core/runtime/revive.d.ts +26 -2
  204. package/dist/core/runtime/revive.js +166 -39
  205. package/dist/core/runtime/roadmap.d.ts +5 -4
  206. package/dist/core/runtime/roadmap.js +9 -16
  207. package/dist/core/runtime/spawn.d.ts +20 -5
  208. package/dist/core/runtime/spawn.js +169 -44
  209. package/dist/core/runtime/stop-guard.d.ts +1 -1
  210. package/dist/core/runtime/stop-guard.js +18 -8
  211. package/dist/core/runtime/tmux.d.ts +106 -21
  212. package/dist/core/runtime/tmux.js +249 -45
  213. package/dist/core/spawn.js +15 -0
  214. package/dist/daemon/crtrd.d.ts +12 -1
  215. package/dist/daemon/crtrd.js +152 -34
  216. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
  217. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
  218. package/dist/pi-extensions/canvas-commands.d.ts +34 -0
  219. package/dist/pi-extensions/canvas-commands.js +103 -0
  220. package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
  221. package/dist/pi-extensions/canvas-context-intro.js +164 -0
  222. package/dist/pi-extensions/canvas-goal-capture.d.ts +21 -0
  223. package/dist/pi-extensions/canvas-goal-capture.js +67 -0
  224. package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
  225. package/dist/pi-extensions/canvas-nav.d.ts +12 -4
  226. package/dist/pi-extensions/canvas-nav.js +586 -262
  227. package/dist/pi-extensions/canvas-passive-context.d.ts +32 -0
  228. package/dist/pi-extensions/canvas-passive-context.js +114 -0
  229. package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
  230. package/dist/pi-extensions/canvas-stophook.js +344 -228
  231. package/dist/types.d.ts +28 -0
  232. package/dist/types.js +16 -0
  233. package/package.json +1 -1
@@ -0,0 +1,238 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/placement-revive.test.ts
2
+ //
3
+ // STEP 5 of the placement/focus migration: placement-aware revive (§1.4) — THE
4
+ // step that kills the "unbidden windows in my session" bug. Two proofs:
5
+ //
6
+ // 1. The PURE bug-death test (§5.1, the crown jewel): reviveTarget(focus,
7
+ // focusPaneAlive, homeSession) — the target decision in isolation, no tmux.
8
+ // The load-bearing assertion is the once-focused-now-unfocused CHILD: its
9
+ // focus is gone (focusOf == null) and home_session = 'crtr' even though the
10
+ // OLD meta.tmux_session was tainted to a user session → backstage 'crtr',
11
+ // NOT the user session. That is the audit §F/H1 defect, structurally dead.
12
+ //
13
+ // 2. The gated real-tmux swap-out-skipped regression (§5.2, the definitive
14
+ // proof): focus a node into a user session, terminate its pi (the focus
15
+ // pane collapses — Step 5 has no remain-on-exit yet), wake it via
16
+ // reviveIntoPlacement → assert ZERO new windows appear in the user session
17
+ // across the whole sequence; the revived node lands in home_session.
18
+ //
19
+ // Plus: backstage-into-home_session (the taint is IGNORED), respawn-into-focus
20
+ // (F3 resume in place, no new window), and reconcileFocus following a manual
21
+ // move of the focus pane (Q4). The gated tests drive a benign `sleep` command,
22
+ // never a real pi — reviveNode delegates the placement decision to exactly the
23
+ // reviveIntoPlacement exercised here.
24
+ import { test, before, after, beforeEach } from 'node:test';
25
+ import assert from 'node:assert/strict';
26
+ import { mkdtempSync, rmSync } from 'node:fs';
27
+ import { tmpdir } from 'node:os';
28
+ import { join } from 'node:path';
29
+ import { spawnSync } from 'node:child_process';
30
+ import { createNode, getNode } from '../canvas/canvas.js';
31
+ import { openFocusRow, getFocusByNode, getFocusById } from '../canvas/focuses.js';
32
+ import { closeDb } from '../canvas/db.js';
33
+ import { reviveTarget, reviveIntoPlacement, reconcileFocus } from '../runtime/placement.js';
34
+ import { reviveInPlace } from '../runtime/revive.js';
35
+ let home;
36
+ function node(id, over = {}) {
37
+ return {
38
+ node_id: id,
39
+ name: id,
40
+ created: new Date().toISOString(),
41
+ cwd: '/tmp/work',
42
+ kind: 'developer',
43
+ mode: 'base',
44
+ lifecycle: 'terminal',
45
+ status: 'active',
46
+ ...over,
47
+ };
48
+ }
49
+ function focus(over = {}) {
50
+ return { focus_id: 'f1', pane: '%a', session: 'Suser', node_id: 'M', ...over };
51
+ }
52
+ function hasTmux() {
53
+ return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
54
+ }
55
+ before(() => {
56
+ home = mkdtempSync(join(tmpdir(), 'crtr-placement-revive-'));
57
+ process.env['CRTR_HOME'] = home;
58
+ });
59
+ beforeEach(() => {
60
+ closeDb();
61
+ rmSync(home, { recursive: true, force: true });
62
+ });
63
+ after(() => {
64
+ closeDb();
65
+ rmSync(home, { recursive: true, force: true });
66
+ delete process.env['CRTR_HOME'];
67
+ });
68
+ // ---------------------------------------------------------------------------
69
+ // 1. The PURE bug-death test (§5.1) — reviveTarget in isolation. No tmux.
70
+ // ---------------------------------------------------------------------------
71
+ test('reviveTarget: a node on a LIVE focus → resume IN PLACE in that focus pane', () => {
72
+ const d = reviveTarget(focus({ pane: '%a', session: 'Suser', node_id: 'M' }), true, 'crtr');
73
+ assert.deepEqual(d, { kind: 'focus-pane', pane: '%a', session: 'Suser' });
74
+ });
75
+ test('reviveTarget: a non-focused CHILD (home_session=crtr) → BACKSTAGE crtr (never a user session)', () => {
76
+ assert.deepEqual(reviveTarget(null, false, 'crtr'), { kind: 'backstage', session: 'crtr' });
77
+ });
78
+ test('reviveTarget: a ROOT (home_session = its own user session) → backstage THAT session (NOT the bug)', () => {
79
+ // A root legitimately lives in its own adopted user session; reviving it there
80
+ // is correct. The bug is a non-focused CHILD landing in a user session — not a
81
+ // root reviving into its own.
82
+ assert.deepEqual(reviveTarget(null, false, 'Suser-root'), { kind: 'backstage', session: 'Suser-root' });
83
+ });
84
+ test('THE BUG, structurally dead: a once-focused-now-unfocused CHILD → BACKSTAGE crtr, NOT the tainted user session', () => {
85
+ // The exact audit §F/H1 scenario. The child WAS focused, so its OLD
86
+ // meta.tmux_session was tainted to the user session — but the focus is gone
87
+ // (focusOf == null) and reviveTarget keys on home_session ('crtr'), NEVER on
88
+ // the tainted tmux_session. So the revive lands backstage in 'crtr'. THIS is
89
+ // the assertion that proves the "unbidden windows" bug cannot recur: even with
90
+ // a tainted tmux_session sitting in the row, the target is home_session only.
91
+ const d = reviveTarget(null, false, 'crtr');
92
+ assert.equal(d.kind, 'backstage');
93
+ assert.equal(d.session, 'crtr');
94
+ assert.notEqual(d.session, 'Suser', 'NOT the tainted user session');
95
+ });
96
+ test('A-MAJOR-1 dead (refresh-yield twin): a focused (tainted) child reviveInPlace propagates CRTR_ROOT_SESSION = home_session (backstage), so any child it spawns lands backstage — NOT the user session', () => {
97
+ // The reviveInPlace (refresh-yield) counterpart to the reviveTarget crown
98
+ // jewel above. A focused child M was tainted: meta.tmux_session = the USER
99
+ // session (focus taints it), home_session = the backstage `crtr`. Pre-fix,
100
+ // reviveInPlace re-execed M's pi with CRTR_ROOT_SESSION = meta.tmux_session =
101
+ // the user session, so any child M then spawned opened its window in the user's
102
+ // session (and re-tainted that grandchild's home_session). The fix sources
103
+ // CRTR_ROOT_SESSION from home_session — the taint-immune backstage. We capture
104
+ // the env reviveInPlace dispatches to the pane respawn and assert it carries
105
+ // the backstage, never the tainted session. Fails against the pre-fix code.
106
+ const back = `crtr-back-${process.pid}`;
107
+ const user = `crtr-user-${process.pid}`; // the focus taint that must be ignored
108
+ createNode(node('M', { home_session: back, tmux_session: user, window: '@7', pane: '%5' }));
109
+ let captured;
110
+ const spy = (opts) => {
111
+ captured = opts.env;
112
+ return true;
113
+ };
114
+ reviveInPlace('M', '%5', spy);
115
+ assert.equal(captured?.['CRTR_ROOT_SESSION'], back, 'children spawn into the backstage home_session');
116
+ assert.notEqual(captured?.['CRTR_ROOT_SESSION'], user, 'NEVER the tainted user session (A-MAJOR-1 dead)');
117
+ // The node's own LOCATION is unchanged — the re-exec is in place, the pane
118
+ // never moved, so it still sits in the (still-tainted) user session.
119
+ assert.equal(getNode('M').tmux_session, user, 'LOCATION preserved (in-place re-exec); only the child env is hardened');
120
+ });
121
+ test('reviveTarget: a focus whose pane has COLLAPSED (focusPaneAlive=false) → backstage, not a dead pane', () => {
122
+ // Step-5 limitation: a focused node that fully terminates has no remain-on-exit
123
+ // yet, so its focus pane collapses. focusPaneAlive=false → backstage (home),
124
+ // never the gone focus pane. Still SAFE: home_session, never a user session.
125
+ const d = reviveTarget(focus({ pane: '%gone', session: 'Suser', node_id: 'M' }), false, 'crtr');
126
+ assert.deepEqual(d, { kind: 'backstage', session: 'crtr' });
127
+ });
128
+ // ---------------------------------------------------------------------------
129
+ // 2. Gated real-tmux placement (skip when tmux is absent). Each test isolates
130
+ // two real sessions: `user` (the user's terminal) and `back` (the backstage that
131
+ // stands in for `crtr`). We drive reviveIntoPlacement with a benign `sleep`
132
+ // command, never a real pi.
133
+ // ---------------------------------------------------------------------------
134
+ function tmuxOut(args) {
135
+ return (spawnSync('tmux', args, { encoding: 'utf8' }).stdout ?? '').trim();
136
+ }
137
+ function windowIds(session) {
138
+ return tmuxOut(['list-windows', '-t', session, '-F', '#{window_id}']).split('\n').filter((s) => s !== '');
139
+ }
140
+ /** Hold two real, isolated sessions (`user` + `back`) open for `fn`, exposing the
141
+ * user session's first window + active pane, then tear both down. */
142
+ async function withUserAndBackstage(tag, fn) {
143
+ const user = `crtr-rev-user-${process.pid}-${tag}`;
144
+ const back = `crtr-rev-back-${process.pid}-${tag}`;
145
+ spawnSync('tmux', ['new-session', '-d', '-s', user, '-c', '/tmp', 'sleep 600']);
146
+ spawnSync('tmux', ['new-session', '-d', '-s', back, '-c', '/tmp', 'sleep 600']);
147
+ try {
148
+ const userWindow = windowIds(user)[0];
149
+ await fn({ user, back, userWindow });
150
+ }
151
+ finally {
152
+ spawnSync('tmux', ['kill-session', '-t', user], { stdio: 'ignore' });
153
+ spawnSync('tmux', ['kill-session', '-t', back], { stdio: 'ignore' });
154
+ }
155
+ }
156
+ /** Open a real, live extra pane inside `session:window` (a stand-in focus pane)
157
+ * running `sleep`, and return its durable `%pane_id`. */
158
+ function liveFocusPane(session, window) {
159
+ return tmuxOut(['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600']);
160
+ }
161
+ const launch = (over = {}) => ({
162
+ command: 'sleep 600',
163
+ env: {},
164
+ cwd: '/tmp',
165
+ name: 'm',
166
+ resuming: false,
167
+ ...over,
168
+ });
169
+ test('reviveIntoPlacement (backstage): a non-focused node opens in home_session, IGNORING its tainted tmux_session', { skip: !hasTmux() }, async () => {
170
+ await withUserAndBackstage('backstage', async ({ user, back }) => {
171
+ // A child whose tmux_session was TAINTED to the user session (as focus would
172
+ // have done) but which is NOT focused now. home_session = the backstage.
173
+ createNode(node('M', { home_session: back, tmux_session: user, window: null, pane: null }));
174
+ const userBefore = windowIds(user).length;
175
+ const backBefore = windowIds(back).length;
176
+ const placed = reviveIntoPlacement('M', launch());
177
+ assert.equal(windowIds(user).length, userBefore, 'ZERO new windows in the user session');
178
+ assert.equal(windowIds(back).length, backBefore + 1, 'exactly one new window in home_session (backstage)');
179
+ assert.equal(placed.session, back, 'placed into home_session, not the tainted user session');
180
+ assert.ok(placed.pane?.startsWith('%'), 'a durable pane id was recorded');
181
+ const m = getNode('M');
182
+ assert.equal(m.tmux_session, back, 'LOCATION repointed to the backstage; the taint is overwritten');
183
+ assert.equal(m.pane, placed.pane, 'the row pane matches the opened pane');
184
+ });
185
+ });
186
+ test('§5.2 swap-out-skipped regression: focus→terminate→wake puts ZERO new windows in the user session', { skip: !hasTmux() }, async () => {
187
+ await withUserAndBackstage('regress', async ({ user, back, userWindow }) => {
188
+ // The bug scenario, end to end:
189
+ // 1. M is a child focused into the user session: a real focus pane in
190
+ // `user`, a focus row on it, and (the taint) tmux_session=user.
191
+ // 2. Its pi terminates → the focus pane COLLAPSES (Step 5 has no
192
+ // remain-on-exit yet) — modelled by killing the focus pane.
193
+ // 3. The daemon/reviveNode wakes M → reviveIntoPlacement.
194
+ // The fix: M lands in home_session (backstage), NOT a new window in `user`.
195
+ const focusPane = liveFocusPane(user, userWindow);
196
+ createNode(node('M', { home_session: back, tmux_session: user, window: userWindow, pane: focusPane }));
197
+ openFocusRow('f1', focusPane, user, 'M');
198
+ const userBefore = windowIds(user).length;
199
+ const backBefore = windowIds(back).length;
200
+ // Terminate: the focus pane collapses (no remain-on-exit in Step 5).
201
+ spawnSync('tmux', ['kill-pane', '-t', focusPane], { stdio: 'ignore' });
202
+ const placed = reviveIntoPlacement('M', launch({ resuming: true }));
203
+ assert.equal(windowIds(user).length, userBefore, 'ZERO new windows opened in the user session across the sequence');
204
+ assert.equal(windowIds(back).length, backBefore + 1, 'the revived node landed in home_session (backstage)');
205
+ assert.equal(placed.session, back, 'reviveIntoPlacement targeted home_session, not the tainted user session');
206
+ assert.equal(getNode('M').tmux_session, back, 'LOCATION repointed to the backstage');
207
+ });
208
+ });
209
+ test('reviveIntoPlacement (focus-pane): a node on a LIVE focus resumes IN PLACE — no new window anywhere', { skip: !hasTmux() }, async () => {
210
+ await withUserAndBackstage('inplace', async ({ user, back, userWindow }) => {
211
+ const focusPane = liveFocusPane(user, userWindow);
212
+ createNode(node('M', { home_session: back, tmux_session: user, window: userWindow, pane: focusPane }));
213
+ openFocusRow('f1', focusPane, user, 'M');
214
+ const userBefore = windowIds(user).length;
215
+ const backBefore = windowIds(back).length;
216
+ const placed = reviveIntoPlacement('M', launch({ resuming: true }));
217
+ assert.equal(placed.pane, focusPane, 'resumed into the SAME focus pane id (respawn-pane -k, no new pane)');
218
+ assert.equal(windowIds(user).length, userBefore, 'no new window in the user session (resume in place)');
219
+ assert.equal(windowIds(back).length, backBefore, 'no new window in the backstage either');
220
+ assert.equal(placed.session, user, 'the focus pane lives in the user session — the F3 resume stays there (desired)');
221
+ assert.equal(getFocusByNode('M')?.pane, focusPane, 'the focus row still anchors the same pane');
222
+ });
223
+ });
224
+ test('reconcileFocus: follows a manual move of the focus pane to another session (Q4)', { skip: !hasTmux() }, async () => {
225
+ await withUserAndBackstage('recfocus', async ({ user, back, userWindow }) => {
226
+ // Focus pane starts in `user`; the row caches session=user.
227
+ const focusPane = liveFocusPane(user, userWindow);
228
+ openFocusRow('f1', focusPane, user, 'M');
229
+ // The user moves the focus pane into the backstage session (join-pane). The
230
+ // pane id survives; the row's cached session is now stale.
231
+ const backWindow = windowIds(back)[0];
232
+ spawnSync('tmux', ['join-pane', '-s', focusPane, '-t', `${back}:${backWindow}`], { stdio: 'ignore' });
233
+ reconcileFocus('f1');
234
+ const f = getFocusById('f1');
235
+ assert.equal(f?.pane, focusPane, 'pane id is invariant across the move');
236
+ assert.equal(f?.session, back, 'reconcileFocus FOLLOWED the move to the backstage session');
237
+ });
238
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,183 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/placement-teardown.test.ts
2
+ //
3
+ // STEP 7 of the placement/focus migration: the lifecycle-successor + teardown
4
+ // verbs (§1.6/§2.3, flow (e)). Two PURE surfaces (no tmux — pane ids below are
5
+ // never realized, so closePane no-ops and only the DB effects are asserted):
6
+ //
7
+ // • handFocusToManager(focusId, managerId) — the §1.6 manager-takeover, a PURE
8
+ // DB occupant swap. Returns true (TAKEOVER) only when there is a distinct,
9
+ // not-already-focused manager; false (caller closes the focus) in each of the
10
+ // three guard cases. Each guard is asserted distinctly.
11
+ // • tearDownNode(nodeId) — close/reset teardown: close the focus row it
12
+ // occupies, null its LOCATION, and clear focus.ptr when it was the current
13
+ // focus.
14
+ import { test, before, after, beforeEach } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import { mkdtempSync, rmSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { spawnSync } from 'node:child_process';
20
+ import { createNode, getNode } from '../canvas/canvas.js';
21
+ import { openFocusRow, getFocusByNode, getFocusById } from '../canvas/focuses.js';
22
+ import { closeDb } from '../canvas/db.js';
23
+ import { handFocusToManager, tearDownNode } from '../runtime/placement.js';
24
+ import { setFocus, getFocus } from '../runtime/presence.js';
25
+ let home;
26
+ function node(id, over = {}) {
27
+ return {
28
+ node_id: id,
29
+ name: id,
30
+ created: new Date().toISOString(),
31
+ cwd: '/tmp/work',
32
+ kind: 'developer',
33
+ mode: 'base',
34
+ lifecycle: 'terminal',
35
+ status: 'active',
36
+ ...over,
37
+ };
38
+ }
39
+ function hasTmux() {
40
+ return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
41
+ }
42
+ function tmuxOut(args) {
43
+ return (spawnSync('tmux', args, { encoding: 'utf8' }).stdout ?? '').trim();
44
+ }
45
+ function paneSession(pane) {
46
+ return tmuxOut(['display-message', '-p', '-t', pane, '#{session_name}']);
47
+ }
48
+ function windowCount(session) {
49
+ return tmuxOut(['list-windows', '-t', session, '-F', '#{window_id}']).split('\n').filter((s) => s !== '').length;
50
+ }
51
+ before(() => {
52
+ home = mkdtempSync(join(tmpdir(), 'crtr-placement-teardown-'));
53
+ process.env['CRTR_HOME'] = home;
54
+ });
55
+ beforeEach(() => {
56
+ closeDb();
57
+ rmSync(home, { recursive: true, force: true });
58
+ });
59
+ after(() => {
60
+ closeDb();
61
+ rmSync(home, { recursive: true, force: true });
62
+ delete process.env['CRTR_HOME'];
63
+ });
64
+ // ---------------------------------------------------------------------------
65
+ // handFocusToManager (pure DB) — §1.6 manager-takeover + its three false-guards.
66
+ // ---------------------------------------------------------------------------
67
+ test('handFocusToManager: distinct, free manager → TAKEOVER (repoints the row, returns true)', () => {
68
+ openFocusRow('f', '%m', 'Sa', 'M');
69
+ createNode(node('mgr'));
70
+ assert.equal(handFocusToManager('f', 'mgr'), true, 'a valid manager takes the focus');
71
+ assert.equal(getFocusByNode('mgr')?.focus_id, 'f', 'the row now shows the manager');
72
+ assert.equal(getFocusByNode('M'), null, 'the finished node no longer occupies it');
73
+ // Non-vacuous: a no-op impl returns false / leaves M as occupant → both the
74
+ // return value and the occupant assert fail.
75
+ });
76
+ test('handFocusToManager: managerId null → false, occupant UNCHANGED', () => {
77
+ openFocusRow('f', '%m', 'Sa', 'M');
78
+ assert.equal(handFocusToManager('f', null), false, 'no manager → caller must close the focus');
79
+ assert.equal(getFocusById('f')?.node_id, 'M', 'occupant unchanged');
80
+ // Non-vacuous: an impl that skipped the null guard would either return true or
81
+ // setFocusOccupant(f, null) → NOT NULL violation; the clean false + unchanged
82
+ // occupant fail against both.
83
+ });
84
+ test('handFocusToManager: manager IS the focus occupant → false (no self-handoff)', () => {
85
+ openFocusRow('f', '%m', 'Sa', 'M');
86
+ assert.equal(handFocusToManager('f', 'M'), false, 'handing a node its own focus is a no-op false');
87
+ assert.equal(getFocusById('f')?.node_id, 'M', 'occupant unchanged');
88
+ // Non-vacuous: an impl missing the `managerId === f.node_id` guard would return
89
+ // true.
90
+ });
91
+ test('handFocusToManager: manager already focused ELSEWHERE → false, neither focus moved (UNIQUE node_id)', () => {
92
+ openFocusRow('fM', '%m', 'Sa', 'M');
93
+ openFocusRow('fMgr', '%g', 'Sb', 'mgr'); // mgr already occupies its own viewport
94
+ assert.equal(handFocusToManager('fM', 'mgr'), false, 'a busy manager is not moved — caller closes');
95
+ assert.equal(getFocusById('fM')?.node_id, 'M', "M's focus is not handed over");
96
+ assert.equal(getFocusByNode('mgr')?.focus_id, 'fMgr', "mgr's other focus is untouched");
97
+ // Non-vacuous: an impl missing the already-focused guard would setFocusOccupant
98
+ // (fM, mgr) → UNIQUE(node_id) violation (mgr already in fMgr), so the expected
99
+ // clean false + both rows intact fail.
100
+ });
101
+ // ---------------------------------------------------------------------------
102
+ // MAJOR 1 — the LIVE-vs-DORMANT manager split inside handFocusToManager.
103
+ // ---------------------------------------------------------------------------
104
+ test('handFocusToManager: LIVE backstage manager → swaps its pane INTO the focus slot (MAJOR 1)', { skip: !hasTmux() }, () => {
105
+ // Two real sessions: `user` (the viewport %m sits frozen in) + `back` (where
106
+ // the manager's pi runs live, the normal multi-child state). The swap is a
107
+ // real tmux op, so this needs a live server (gated like the other §5.2 tests).
108
+ const user = `crtr-hfm-user-${process.pid}`;
109
+ const back = `crtr-hfm-back-${process.pid}`;
110
+ spawnSync('tmux', ['new-session', '-d', '-s', user, '-c', '/tmp', 'sleep 600']);
111
+ spawnSync('tmux', ['new-session', '-d', '-s', back, '-c', '/tmp', 'sleep 600']);
112
+ try {
113
+ const userWindow = tmuxOut(['list-windows', '-t', user, '-F', '#{window_id}']).split('\n')[0];
114
+ const backWindow = tmuxOut(['list-windows', '-t', back, '-F', '#{window_id}']).split('\n')[0];
115
+ const focusPane = tmuxOut(['display-message', '-p', '-t', `${user}:${userWindow}`, '#{pane_id}']); // M's frozen focus pane (%m, the viewport)
116
+ const mgrPane = tmuxOut(['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${back}:${backWindow}`, 'sleep 600']); // mgr's LIVE backstage pane
117
+ createNode(node('M', { pane: focusPane, tmux_session: user, window: userWindow, status: 'done' }));
118
+ createNode(node('mgr', { pane: mgrPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid }));
119
+ openFocusRow('f', focusPane, user, 'M');
120
+ const userWinsBefore = windowCount(user);
121
+ assert.equal(handFocusToManager('f', 'mgr'), true, 'a LIVE manager takes the focus');
122
+ // Pure-DB takeover (also covered ungated above): occupant repointed M → mgr.
123
+ assert.equal(getFocusByNode('mgr')?.focus_id, 'f', 'the manager occupies the focus row');
124
+ assert.equal(getFocusByNode('M'), null, 'the finished node no longer occupies it');
125
+ // The synchronous swap (MAJOR 1): the focus row re-anchors to mgr's pane,
126
+ // which has physically moved into the user viewport; the old focus pane (%m)
127
+ // swapped down into the backstage slot; mgr's presence is the OLD focus slot.
128
+ assert.equal(getFocusById('f')?.pane, mgrPane, 'focus row re-anchored to the manager pane (swap)');
129
+ assert.equal(paneSession(mgrPane), user, 'the manager pane physically moved into the user viewport');
130
+ assert.equal(getNode('mgr').window, userWindow, 'manager presence window == the OLD focus slot window');
131
+ assert.equal(getNode('mgr').tmux_session, user, 'manager presence session == the viewport');
132
+ assert.equal(paneSession(focusPane), back, '%m (the dead node\'s pane) swapped DOWN into the backstage slot');
133
+ assert.equal(windowCount(user), userWinsBefore, 'no new user window — a swap, not an open (screen position invariant)');
134
+ // Non-vacuous: the pre-MAJOR-1 impl only repointed the occupant and deferred
135
+ // the physical revive to the daemon — but the daemon revives ONLY dormant
136
+ // managers, so a LIVE one is never brought in. That impl leaves the focus row
137
+ // pane == focusPane, mgrPane still in `back`, %m frozen in the viewport, and
138
+ // mgr's presence at the backstage — every swap assert above fails against it.
139
+ }
140
+ finally {
141
+ spawnSync('tmux', ['kill-session', '-t', user], { stdio: 'ignore' });
142
+ spawnSync('tmux', ['kill-session', '-t', back], { stdio: 'ignore' });
143
+ }
144
+ });
145
+ test('handFocusToManager: DORMANT manager (dead pane) → occupant repointed, NO swap, focus pane UNCHANGED', () => {
146
+ openFocusRow('f', '%focus', 'Suser', 'M');
147
+ // The manager has a pane recorded but it is NOT a live tmux pane (its dead-pi
148
+ // window collapsed). isNodePaneAlive(mgr) is therefore false, so the live-swap
149
+ // is skipped — the external daemon later respawns it into the frozen %focus.
150
+ createNode(node('mgr', { pane: '%mgr-dead', tmux_session: 'back', window: '@wb', pi_pid: process.pid }));
151
+ assert.equal(handFocusToManager('f', 'mgr'), true, 'still a takeover (occupant repointed)');
152
+ assert.equal(getFocusByNode('mgr')?.focus_id, 'f', 'the manager occupies the focus row (DB repoint)');
153
+ assert.equal(getFocusByNode('M'), null, 'the finished node no longer occupies it');
154
+ // The split: with no physical swap, the focus row stays anchored on the ORIGINAL
155
+ // frozen focus pane (the pane the daemon will respawn the manager INTO) and the
156
+ // manager's backstage presence is left untouched.
157
+ assert.equal(getFocusById('f')?.pane, '%focus', 'focus row pane NOT re-anchored to the manager pane (no swap)');
158
+ assert.equal(getNode('mgr').pane, '%mgr-dead', 'manager presence pane unchanged');
159
+ assert.equal(getNode('mgr').window, '@wb', 'manager presence window unchanged (never moved into a viewport)');
160
+ // Non-vacuous: an impl that re-anchored the focus row to the manager pane in
161
+ // the DB (or wrote the manager's presence to the viewport) regardless of a real
162
+ // physical swap would set f.pane='%mgr-dead' and mgr.window=the focus window —
163
+ // both asserts fail. Proves the swap gates on PANE LIVENESS, not merely on the
164
+ // manager having a pane.
165
+ });
166
+ // ---------------------------------------------------------------------------
167
+ // tearDownNode (pure DB; no tmux — pane is null so closePane never runs).
168
+ // ---------------------------------------------------------------------------
169
+ test('tearDownNode: closes the focus row, nulls the LOCATION, and clears focus.ptr', () => {
170
+ createNode(node('M', { pane: null, window: null }));
171
+ openFocusRow('fM', null, 'Sa', 'M');
172
+ setFocus('M'); // M is the current focus.ptr
173
+ tearDownNode('M');
174
+ assert.equal(getFocusByNode('M'), null, 'the focus row M occupied is closed');
175
+ const m = getNode('M');
176
+ assert.equal(m.pane ?? null, null, 'pane nulled');
177
+ assert.equal(m.window ?? null, null, 'window nulled');
178
+ assert.equal(m.tmux_session ?? null, null, 'session nulled');
179
+ const cur = getFocus();
180
+ assert.ok(cur === null || cur === '', 'focus.ptr cleared (M was the current focus)');
181
+ // Non-vacuous: an impl that skips closeFocusRow leaves fM → getFocusByNode('M')
182
+ // is non-null; one that skips the setFocus('') clear leaves focus.ptr at 'M'.
183
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,116 @@
1
+ import { test, before, after, beforeEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { createNode, getRow, subscribe, recordSpawn, subscribersOf, subscriptionsOf, pruneNodes, } from '../canvas/canvas.js';
7
+ import { closeDb, openDb } from '../canvas/db.js';
8
+ import { nodeDir } from '../canvas/paths.js';
9
+ let home;
10
+ const DAY = 86_400_000;
11
+ const old = () => new Date(Date.now() - 30 * DAY).toISOString();
12
+ const now = () => new Date().toISOString();
13
+ function node(id, over = {}) {
14
+ return {
15
+ node_id: id,
16
+ name: id,
17
+ created: now(),
18
+ cwd: '/tmp/work',
19
+ kind: 'general',
20
+ mode: 'base',
21
+ lifecycle: 'terminal',
22
+ status: 'active',
23
+ ...over,
24
+ };
25
+ }
26
+ function totalEdges() {
27
+ return openDb().prepare('SELECT COUNT(*) AS n FROM edges').get().n;
28
+ }
29
+ /** The standard fixture: three prunable terminal nodes (old), three survivors
30
+ * (two old-but-live, one recent-but-dead), and edges that touch a pruned node
31
+ * so cascade is observable. */
32
+ function seedFixture() {
33
+ createNode(node('old-dead', { status: 'dead', created: old() }));
34
+ createNode(node('old-done', { status: 'done', created: old() }));
35
+ createNode(node('old-canceled', { status: 'canceled', created: old() }));
36
+ createNode(node('old-active', { status: 'active', created: old() }));
37
+ createNode(node('old-idle', { status: 'idle', created: old() }));
38
+ createNode(node('recent-dead', { status: 'dead', created: now() }));
39
+ // A survivor subscribes to a pruned node (cascade on to_id) …
40
+ subscribe('old-active', 'old-dead', true);
41
+ // … and a pruned node's audit edge to another pruned node (cascade on both).
42
+ recordSpawn('old-done', 'old-dead');
43
+ }
44
+ before(() => {
45
+ home = mkdtempSync(join(tmpdir(), 'crtr-prune-'));
46
+ process.env['CRTR_HOME'] = home;
47
+ });
48
+ beforeEach(() => {
49
+ closeDb();
50
+ rmSync(home, { recursive: true, force: true });
51
+ });
52
+ after(() => {
53
+ closeDb();
54
+ rmSync(home, { recursive: true, force: true });
55
+ delete process.env['CRTR_HOME'];
56
+ });
57
+ test('prune removes terminal nodes past the TTL and keeps live + recent ones', () => {
58
+ seedFixture();
59
+ assert.equal(totalEdges(), 2);
60
+ const result = pruneNodes({ ttlDays: 14 });
61
+ // The three old terminal nodes are pruned; nothing else.
62
+ assert.equal(result.dryRun, false);
63
+ assert.deepEqual(result.pruned.map((p) => p.node_id).sort(), ['old-canceled', 'old-dead', 'old-done']);
64
+ // Pruned rows are gone …
65
+ for (const id of ['old-dead', 'old-done', 'old-canceled']) {
66
+ assert.equal(getRow(id), null, `${id} row should be pruned`);
67
+ }
68
+ // … and the survivors remain (live-but-old + recent-but-dead).
69
+ for (const id of ['old-active', 'old-idle', 'recent-dead']) {
70
+ assert.ok(getRow(id) !== null, `${id} should survive`);
71
+ }
72
+ });
73
+ test('pruning cascade-deletes the pruned nodes\u2019 edges', () => {
74
+ seedFixture();
75
+ assert.equal(totalEdges(), 2);
76
+ pruneNodes({ ttlDays: 14 });
77
+ // Both edges touched a pruned node (old-dead) → both cascade away.
78
+ assert.equal(totalEdges(), 0);
79
+ // The surviving subscriber's edge to the pruned publisher is gone.
80
+ assert.deepEqual(subscriptionsOf('old-active'), []);
81
+ assert.deepEqual(subscribersOf('old-dead'), []);
82
+ });
83
+ test('pruning removes each pruned node\u2019s on-disk dir, leaves survivors\u2019 dirs', () => {
84
+ seedFixture();
85
+ assert.ok(existsSync(nodeDir('old-dead')));
86
+ assert.ok(existsSync(nodeDir('old-active')));
87
+ pruneNodes({ ttlDays: 14 });
88
+ assert.ok(!existsSync(nodeDir('old-dead')), 'pruned dir should be removed');
89
+ assert.ok(!existsSync(nodeDir('old-done')));
90
+ assert.ok(!existsSync(nodeDir('old-canceled')));
91
+ assert.ok(existsSync(nodeDir('old-active')), 'survivor dir should remain');
92
+ assert.ok(existsSync(nodeDir('recent-dead')));
93
+ });
94
+ test('--dry-run reports candidates but deletes nothing', () => {
95
+ seedFixture();
96
+ const before = totalEdges();
97
+ const result = pruneNodes({ ttlDays: 14, dryRun: true });
98
+ // Candidates are reported …
99
+ assert.equal(result.dryRun, true);
100
+ assert.deepEqual(result.pruned.map((p) => p.node_id).sort(), ['old-canceled', 'old-dead', 'old-done']);
101
+ // … but NOTHING is deleted: rows, edges, and dirs all intact.
102
+ for (const id of ['old-dead', 'old-done', 'old-canceled']) {
103
+ assert.ok(getRow(id) !== null, `${id} must survive a dry run`);
104
+ assert.ok(existsSync(nodeDir(id)), `${id} dir must survive a dry run`);
105
+ }
106
+ assert.equal(totalEdges(), before);
107
+ });
108
+ test('prune and the daemon operate on disjoint status sets', () => {
109
+ // active|idle (the daemon's domain) are NEVER pruned, even when ancient.
110
+ createNode(node('ancient-active', { status: 'active', created: old() }));
111
+ createNode(node('ancient-idle', { status: 'idle', created: old() }));
112
+ const result = pruneNodes({ ttlDays: 14 });
113
+ assert.equal(result.pruned.length, 0);
114
+ assert.ok(getRow('ancient-active') !== null);
115
+ assert.ok(getRow('ancient-idle') !== null);
116
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/push-final-guard.test.ts
2
+ //
3
+ // N1 — a 2nd `crtr push final` in one turn is an illegal finalize-from-done that
4
+ // transition() throws on. The push leaf must surface that as a CLEAN user-facing
5
+ // InputError ('already_finalized'), NOT let the raw Error fall through to the
6
+ // `internal` "crtr bug" path. Verifies the guard fires before push() runs.
7
+ import { test, before, after, beforeEach } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { mkdtempSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { createNode, setStatus } from '../canvas/canvas.js';
13
+ import { closeDb } from '../canvas/db.js';
14
+ import { registerPush } from '../../commands/push.js';
15
+ import { InputError } from '../io.js';
16
+ let home;
17
+ const prevNodeId = process.env['CRTR_NODE_ID'];
18
+ function node(id) {
19
+ return {
20
+ node_id: id,
21
+ name: id,
22
+ created: new Date().toISOString(),
23
+ cwd: '/tmp/work',
24
+ kind: 'general',
25
+ mode: 'base',
26
+ lifecycle: 'terminal',
27
+ status: 'active',
28
+ };
29
+ }
30
+ /** The `final` tier leaf, fished out of the push branch. */
31
+ function finalLeaf() {
32
+ const leaf = registerPush().children.find((c) => c.name === 'final');
33
+ assert.ok(leaf !== undefined && leaf.kind === 'leaf', 'push final leaf exists');
34
+ return leaf;
35
+ }
36
+ before(() => {
37
+ home = mkdtempSync(join(tmpdir(), 'crtr-pushfinal-'));
38
+ process.env['CRTR_HOME'] = home;
39
+ });
40
+ beforeEach(() => {
41
+ closeDb();
42
+ rmSync(home, { recursive: true, force: true });
43
+ });
44
+ after(() => {
45
+ closeDb();
46
+ rmSync(home, { recursive: true, force: true });
47
+ delete process.env['CRTR_HOME'];
48
+ if (prevNodeId === undefined)
49
+ delete process.env['CRTR_NODE_ID'];
50
+ else
51
+ process.env['CRTR_NODE_ID'] = prevNodeId;
52
+ });
53
+ test('push final on an already-done node throws a clean InputError, not a raw transition error', async () => {
54
+ const id = 'doneNode';
55
+ createNode(node(id));
56
+ setStatus(id, 'done'); // simulate the first push final already finalized it
57
+ process.env['CRTR_NODE_ID'] = id;
58
+ await assert.rejects(() => finalLeaf().run({ body: 'second final result' }), (e) => {
59
+ assert.ok(e instanceof InputError, 'a clean command-level error (renders on stdout)');
60
+ assert.equal(e.payload.error, 'already_finalized');
61
+ assert.match(e.payload.message, /already done/);
62
+ return true;
63
+ });
64
+ });
65
+ test('push final on a dead node is also caught cleanly', async () => {
66
+ const id = 'deadNode';
67
+ createNode(node(id));
68
+ setStatus(id, 'dead');
69
+ process.env['CRTR_NODE_ID'] = id;
70
+ await assert.rejects(() => finalLeaf().run({ body: 'x' }), (e) => e instanceof InputError && e.payload.error === 'already_finalized');
71
+ });
@@ -0,0 +1 @@
1
+ export {};