@crouton-kit/crouter 0.3.14 → 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.
- package/dist/build-root.d.ts +8 -0
- package/dist/build-root.js +30 -0
- package/dist/builtin-personas/design/base.md +3 -7
- package/dist/builtin-personas/design/orchestrator.md +4 -3
- package/dist/builtin-personas/developer/base.md +3 -7
- package/dist/builtin-personas/developer/orchestrator.md +5 -4
- package/dist/builtin-personas/explore/base.md +3 -7
- package/dist/builtin-personas/explore/orchestrator.md +1 -5
- package/dist/builtin-personas/general/base.md +2 -4
- package/dist/builtin-personas/general/orchestrator.md +2 -4
- package/dist/builtin-personas/lifecycle/resident.md +2 -0
- package/dist/builtin-personas/lifecycle/terminal.md +6 -0
- package/dist/builtin-personas/orchestration-kernel.md +42 -3
- package/dist/builtin-personas/plan/base.md +3 -5
- package/dist/builtin-personas/plan/orchestrator.md +5 -4
- package/dist/builtin-personas/plan/reviewers/architecture-fit/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/code-smells/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/pattern-consistency/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/requirements-coverage/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/security/base.md +9 -0
- package/dist/builtin-personas/review/base.md +3 -5
- package/dist/builtin-personas/review/orchestrator.md +2 -6
- package/dist/builtin-personas/runtime-base.md +3 -19
- package/dist/builtin-personas/spec/base.md +3 -5
- package/dist/builtin-personas/spec/orchestrator.md +4 -3
- package/dist/builtin-personas/spine/has-manager.md +10 -0
- package/dist/builtin-personas/spine/no-manager.md +2 -0
- package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +96 -0
- package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +49 -0
- package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +49 -0
- package/dist/builtin-skills/skills/planning/SKILL.md +1 -1
- package/dist/builtin-skills/skills/spec/SKILL.md +2 -2
- package/dist/cli.js +6 -29
- package/dist/commands/attention.js +76 -7
- package/dist/commands/canvas-prune.d.ts +2 -0
- package/dist/commands/canvas-prune.js +66 -0
- package/dist/commands/canvas.js +5 -8
- package/dist/commands/chord.d.ts +2 -0
- package/dist/commands/chord.js +143 -0
- package/dist/commands/daemon.js +8 -5
- package/dist/commands/dashboard.js +2 -0
- package/dist/commands/human/prompts.js +28 -27
- package/dist/commands/human/queue.js +30 -14
- package/dist/commands/human/shared.d.ts +26 -21
- package/dist/commands/human/shared.js +44 -66
- package/dist/commands/human.js +4 -14
- package/dist/commands/node.d.ts +11 -0
- package/dist/commands/node.js +221 -98
- package/dist/commands/pkg/market-inspect.js +6 -4
- package/dist/commands/pkg/market-manage.js +10 -6
- package/dist/commands/pkg/market.js +2 -4
- package/dist/commands/pkg/plugin-inspect.js +6 -4
- package/dist/commands/pkg/plugin-manage.js +12 -7
- package/dist/commands/pkg/plugin.js +2 -4
- package/dist/commands/pkg.js +0 -4
- package/dist/commands/push.js +178 -15
- package/dist/commands/revive.js +5 -3
- package/dist/commands/skill/author.js +6 -4
- package/dist/commands/skill/find.js +8 -5
- package/dist/commands/skill/read.js +2 -0
- package/dist/commands/skill/state.js +6 -4
- package/dist/commands/skill.js +0 -6
- package/dist/commands/sys/config.js +21 -7
- package/dist/commands/sys/doctor.js +2 -0
- package/dist/commands/sys/update.js +4 -0
- package/dist/commands/sys.js +0 -6
- package/dist/commands/tmux-spread.d.ts +2 -0
- package/dist/commands/tmux-spread.js +130 -0
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
- package/dist/core/__tests__/child-followup.test.d.ts +1 -0
- package/dist/core/__tests__/child-followup.test.js +83 -0
- package/dist/core/__tests__/close.test.d.ts +1 -0
- package/dist/core/__tests__/close.test.js +148 -0
- package/dist/core/__tests__/context-intro.test.d.ts +1 -0
- package/dist/core/__tests__/context-intro.test.js +196 -0
- package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
- package/dist/core/__tests__/daemon-boot.test.js +93 -0
- package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
- package/dist/core/__tests__/daemon-liveness.test.js +223 -0
- package/dist/core/__tests__/focuses.test.d.ts +1 -0
- package/dist/core/__tests__/focuses.test.js +259 -0
- package/dist/core/__tests__/fork.test.d.ts +1 -0
- package/dist/core/__tests__/fork.test.js +91 -0
- package/dist/core/__tests__/home-session.test.d.ts +1 -0
- package/dist/core/__tests__/home-session.test.js +153 -0
- package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
- package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
- package/dist/core/__tests__/keystone.test.d.ts +1 -0
- package/dist/core/__tests__/keystone.test.js +185 -0
- package/dist/core/__tests__/kickoff.test.d.ts +1 -0
- package/dist/core/__tests__/kickoff.test.js +89 -0
- package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
- package/dist/core/__tests__/lifecycle.test.js +178 -0
- package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
- package/dist/core/__tests__/listing-completeness.test.js +31 -0
- package/dist/core/__tests__/memory.test.d.ts +1 -0
- package/dist/core/__tests__/memory.test.js +152 -0
- package/dist/core/__tests__/migration.test.d.ts +1 -0
- package/dist/core/__tests__/migration.test.js +238 -0
- package/dist/core/__tests__/pane-column.test.d.ts +1 -0
- package/dist/core/__tests__/pane-column.test.js +153 -0
- package/dist/core/__tests__/passive-subscription.test.js +24 -1
- package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
- package/dist/core/__tests__/persona-compose.test.js +53 -0
- package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
- package/dist/core/__tests__/persona-subkind.test.js +62 -0
- package/dist/core/__tests__/persona.test.d.ts +1 -0
- package/dist/core/__tests__/persona.test.js +107 -0
- package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
- package/dist/core/__tests__/placement-focus.test.js +244 -0
- package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
- package/dist/core/__tests__/placement-reconcile.test.js +212 -0
- package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
- package/dist/core/__tests__/placement-revive.test.js +238 -0
- package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
- package/dist/core/__tests__/placement-teardown.test.js +183 -0
- package/dist/core/__tests__/prune.test.d.ts +1 -0
- package/dist/core/__tests__/prune.test.js +116 -0
- package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
- package/dist/core/__tests__/push-final-guard.test.js +71 -0
- package/dist/core/__tests__/relaunch.test.d.ts +1 -0
- package/dist/core/__tests__/relaunch.test.js +328 -0
- package/dist/core/__tests__/reset.test.js +26 -7
- package/dist/core/__tests__/revive.test.d.ts +1 -0
- package/dist/core/__tests__/revive.test.js +217 -0
- package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
- package/dist/core/__tests__/spawn-root.test.js +73 -0
- package/dist/core/__tests__/steer-note.test.d.ts +1 -0
- package/dist/core/__tests__/steer-note.test.js +39 -0
- package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
- package/dist/core/__tests__/stop-guard.test.js +82 -0
- package/dist/core/__tests__/subcommand-tier.test.js +35 -33
- package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
- package/dist/core/__tests__/tmux-surface.test.js +106 -0
- package/dist/core/__tests__/unknown-path.test.js +8 -2
- package/dist/core/canvas/attention.d.ts +10 -0
- package/dist/core/canvas/attention.js +40 -0
- package/dist/core/canvas/canvas.d.ts +66 -7
- package/dist/core/canvas/canvas.js +209 -21
- package/dist/core/canvas/db.d.ts +8 -0
- package/dist/core/canvas/db.js +206 -4
- package/dist/core/canvas/focuses.d.ts +22 -0
- package/dist/core/canvas/focuses.js +80 -0
- package/dist/core/canvas/index.d.ts +3 -0
- package/dist/core/canvas/index.js +3 -0
- package/dist/core/canvas/labels.d.ts +27 -0
- package/dist/core/canvas/labels.js +36 -0
- package/dist/core/canvas/render.js +25 -10
- package/dist/core/canvas/telemetry.d.ts +14 -0
- package/dist/core/canvas/telemetry.js +35 -0
- package/dist/core/canvas/types.d.ts +115 -12
- package/dist/core/command.d.ts +25 -1
- package/dist/core/command.js +23 -15
- package/dist/core/config.js +36 -2
- package/dist/core/feed/feed.js +3 -3
- package/dist/core/feed/inbox.d.ts +3 -1
- package/dist/core/feed/inbox.js +45 -5
- package/dist/core/feed/passive.js +24 -11
- package/dist/core/help.d.ts +26 -13
- package/dist/core/help.js +44 -37
- package/dist/core/personas/index.d.ts +1 -1
- package/dist/core/personas/index.js +1 -1
- package/dist/core/personas/loader.d.ts +40 -1
- package/dist/core/personas/loader.js +63 -1
- package/dist/core/personas/resolve.d.ts +13 -6
- package/dist/core/personas/resolve.js +46 -34
- package/dist/core/runtime/bearings.d.ts +20 -0
- package/dist/core/runtime/bearings.js +92 -0
- package/dist/core/runtime/close.d.ts +14 -0
- package/dist/core/runtime/close.js +151 -0
- package/dist/core/runtime/demote.js +27 -10
- package/dist/core/runtime/front-door.js +1 -1
- package/dist/core/runtime/kickoff.d.ts +23 -6
- package/dist/core/runtime/kickoff.js +92 -36
- package/dist/core/runtime/launch.d.ts +24 -12
- package/dist/core/runtime/launch.js +75 -19
- package/dist/core/runtime/lifecycle.d.ts +13 -0
- package/dist/core/runtime/lifecycle.js +86 -0
- package/dist/core/runtime/memory.d.ts +43 -0
- package/dist/core/runtime/memory.js +165 -0
- package/dist/core/runtime/naming.d.ts +22 -0
- package/dist/core/runtime/naming.js +166 -0
- package/dist/core/runtime/nodes.d.ts +32 -1
- package/dist/core/runtime/nodes.js +60 -10
- package/dist/core/runtime/persona.d.ts +25 -0
- package/dist/core/runtime/persona.js +139 -0
- package/dist/core/runtime/placement.d.ts +287 -0
- package/dist/core/runtime/placement.js +663 -0
- package/dist/core/runtime/presence.d.ts +7 -15
- package/dist/core/runtime/presence.js +90 -66
- package/dist/core/runtime/promote.d.ts +14 -7
- package/dist/core/runtime/promote.js +57 -67
- package/dist/core/runtime/reset.d.ts +47 -4
- package/dist/core/runtime/reset.js +223 -52
- package/dist/core/runtime/revive.d.ts +26 -2
- package/dist/core/runtime/revive.js +166 -39
- package/dist/core/runtime/spawn.d.ts +20 -5
- package/dist/core/runtime/spawn.js +163 -43
- package/dist/core/runtime/stop-guard.d.ts +1 -1
- package/dist/core/runtime/stop-guard.js +18 -8
- package/dist/core/runtime/tmux.d.ts +100 -14
- package/dist/core/runtime/tmux.js +201 -28
- package/dist/core/spawn.js +15 -0
- package/dist/daemon/crtrd.d.ts +12 -1
- package/dist/daemon/crtrd.js +152 -34
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
- package/dist/pi-extensions/canvas-commands.js +16 -13
- package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
- package/dist/pi-extensions/canvas-context-intro.js +164 -0
- package/dist/pi-extensions/canvas-goal-capture.d.ts +3 -0
- package/dist/pi-extensions/canvas-goal-capture.js +15 -1
- package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
- package/dist/pi-extensions/canvas-nav.d.ts +12 -4
- package/dist/pi-extensions/canvas-nav.js +586 -262
- package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
- package/dist/pi-extensions/canvas-stophook.js +344 -228
- package/dist/types.d.ts +28 -0
- package/dist/types.js +16 -0
- package/package.json +1 -1
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/focuses.test.ts
|
|
2
|
+
//
|
|
3
|
+
// STEP 4 of the placement/focus migration: the `focuses` table + canvas setters
|
|
4
|
+
// + placement reads + the transitional focus.ptr dual-write bridge. Purely
|
|
5
|
+
// ADDITIVE: the table is populated in lockstep with the legacy `focus.ptr`, but
|
|
6
|
+
// nothing reads it as authority yet (that switch is Step 6). Covers:
|
|
7
|
+
// - migration v6 adds `focuses` to a fresh db (and a legacy v5 db migrates up);
|
|
8
|
+
// idempotent / forward-only on re-run + re-open
|
|
9
|
+
// - canvas setters/reads round-trip: open / setOccupant / setPane / close;
|
|
10
|
+
// getFocusByNode / getFocusByPane / getFocusById / listFocuses
|
|
11
|
+
// - UNIQUE(node_id): a second focus row (and an occupant UPDATE) for one node
|
|
12
|
+
// is rejected (upholds "a node occupies <=1 focus", Q5)
|
|
13
|
+
// - independent focus rows don't contend
|
|
14
|
+
// - placement focusOf / isFocused / focusByPane / focusedNodes / listFocuses
|
|
15
|
+
// agree with the rows
|
|
16
|
+
// - dual-write: setFocus populates the table; getFocus falls back to the table
|
|
17
|
+
// when focus.ptr is absent; setFocus('') clears both
|
|
18
|
+
import { test, before, beforeEach, after } from 'node:test';
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import { mkdtempSync, rmSync, existsSync, unlinkSync } from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
24
|
+
import { openFocusRow, setFocusOccupant, setFocusPane, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, listFocuses, } from '../canvas/focuses.js';
|
|
25
|
+
import { openDb, closeDb, migrate, MIGRATIONS } from '../canvas/db.js';
|
|
26
|
+
import { canvasDbPath, ensureHome, crtrHome } from '../canvas/paths.js';
|
|
27
|
+
import { focusOf, isFocused, focusByPane, focusedNodes, listFocuses as placementListFocuses, } from '../runtime/placement.js';
|
|
28
|
+
import { setFocus, getFocus } from '../runtime/presence.js';
|
|
29
|
+
let home;
|
|
30
|
+
// Saved/restored so the bridge always exercises its deterministic no-tmux path
|
|
31
|
+
// regardless of whether the suite is run from inside a tmux session.
|
|
32
|
+
let savedTmux;
|
|
33
|
+
function userVersion(db) {
|
|
34
|
+
return db.prepare('PRAGMA user_version').get().user_version;
|
|
35
|
+
}
|
|
36
|
+
function tableNames(db) {
|
|
37
|
+
return db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name);
|
|
38
|
+
}
|
|
39
|
+
before(() => {
|
|
40
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-focuses-'));
|
|
41
|
+
process.env['CRTR_HOME'] = home;
|
|
42
|
+
savedTmux = process.env['TMUX'];
|
|
43
|
+
delete process.env['TMUX'];
|
|
44
|
+
});
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
closeDb();
|
|
47
|
+
rmSync(home, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
after(() => {
|
|
50
|
+
closeDb();
|
|
51
|
+
rmSync(home, { recursive: true, force: true });
|
|
52
|
+
delete process.env['CRTR_HOME'];
|
|
53
|
+
if (savedTmux !== undefined)
|
|
54
|
+
process.env['TMUX'] = savedTmux;
|
|
55
|
+
});
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Migration v6 — the additive `focuses` table.
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
test('a fresh db migrates to the latest version and has the focuses table', () => {
|
|
60
|
+
const db = openDb();
|
|
61
|
+
assert.equal(userVersion(db), MIGRATIONS.length);
|
|
62
|
+
assert.ok(tableNames(db).includes('focuses'), 'focuses table exists on a fresh db');
|
|
63
|
+
});
|
|
64
|
+
test('the focuses table has the v6 shape (UNIQUE node_id, nullable pane/session)', () => {
|
|
65
|
+
const db = openDb();
|
|
66
|
+
const cols = db.prepare('PRAGMA table_info(focuses)').all();
|
|
67
|
+
const byName = new Map(cols.map((c) => [c.name, c]));
|
|
68
|
+
assert.equal(byName.get('focus_id')?.pk, 1, 'focus_id is the primary key');
|
|
69
|
+
assert.equal(byName.get('node_id')?.notnull, 1, 'node_id is NOT NULL');
|
|
70
|
+
assert.equal(byName.get('pane')?.notnull, 0, 'pane is nullable');
|
|
71
|
+
assert.equal(byName.get('session')?.notnull, 0, 'session is nullable');
|
|
72
|
+
// node_id carries a UNIQUE index.
|
|
73
|
+
const idx = db.prepare('PRAGMA index_list(focuses)').all();
|
|
74
|
+
assert.ok(idx.some((i) => i.unique === 1), 'a UNIQUE index exists (the node_id constraint)');
|
|
75
|
+
});
|
|
76
|
+
test('a v5 db migrates forward, adding focuses without disturbing existing data', () => {
|
|
77
|
+
// Hand-build a db at exactly v5: baseline + the four v2 runtime columns + the
|
|
78
|
+
// v5 pane column + edges WITH the v4 FK shape, user_version=5 — the shape just
|
|
79
|
+
// before v6.
|
|
80
|
+
ensureHome();
|
|
81
|
+
const raw = new DatabaseSync(canvasDbPath());
|
|
82
|
+
raw.exec(`
|
|
83
|
+
CREATE TABLE nodes (
|
|
84
|
+
node_id TEXT PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL,
|
|
85
|
+
mode TEXT NOT NULL DEFAULT 'base', lifecycle TEXT NOT NULL DEFAULT 'terminal',
|
|
86
|
+
status TEXT NOT NULL DEFAULT 'active', cwd TEXT NOT NULL, parent TEXT,
|
|
87
|
+
created TEXT NOT NULL,
|
|
88
|
+
intent TEXT, pi_pid INTEGER, window TEXT, tmux_session TEXT, pane TEXT
|
|
89
|
+
);
|
|
90
|
+
CREATE TABLE edges (
|
|
91
|
+
type TEXT NOT NULL, from_id TEXT NOT NULL, to_id TEXT NOT NULL,
|
|
92
|
+
active INTEGER NOT NULL DEFAULT 1, created TEXT NOT NULL,
|
|
93
|
+
PRIMARY KEY (type, from_id, to_id),
|
|
94
|
+
FOREIGN KEY (from_id) REFERENCES nodes(node_id) ON DELETE CASCADE,
|
|
95
|
+
FOREIGN KEY (to_id) REFERENCES nodes(node_id) ON DELETE CASCADE
|
|
96
|
+
);
|
|
97
|
+
PRAGMA user_version = 5;
|
|
98
|
+
`);
|
|
99
|
+
raw
|
|
100
|
+
.prepare('INSERT INTO nodes (node_id, name, kind, status, cwd, created, window, tmux_session, pane) VALUES (?,?,?,?,?,?,?,?,?)')
|
|
101
|
+
.run('legacy', 'Legacy', 'general', 'idle', '/tmp/work', '2020-01-01T00:00:00Z', '@9', 'crtr', '%3');
|
|
102
|
+
assert.equal(userVersion(raw), 5);
|
|
103
|
+
assert.ok(!tableNames(raw).includes('focuses'), 'precondition: no focuses table at v5');
|
|
104
|
+
raw.close();
|
|
105
|
+
// openDb() runs v6 forward.
|
|
106
|
+
const db = openDb();
|
|
107
|
+
assert.equal(userVersion(db), MIGRATIONS.length);
|
|
108
|
+
assert.ok(tableNames(db).includes('focuses'), 'focuses table added by v6');
|
|
109
|
+
// The pre-existing node row is intact (the v6 migration only adds a table).
|
|
110
|
+
const row = db
|
|
111
|
+
.prepare('SELECT status, window, tmux_session, pane FROM nodes WHERE node_id = ?')
|
|
112
|
+
.get('legacy');
|
|
113
|
+
assert.equal(row['status'], 'idle', 'existing status untouched');
|
|
114
|
+
assert.equal(row['window'], '@9', 'existing window untouched');
|
|
115
|
+
assert.equal(row['tmux_session'], 'crtr', 'existing session untouched');
|
|
116
|
+
assert.equal(row['pane'], '%3', 'existing pane untouched');
|
|
117
|
+
// The fresh focuses table is empty.
|
|
118
|
+
const n = db.prepare('SELECT COUNT(*) AS n FROM focuses').get().n;
|
|
119
|
+
assert.equal(n, 0, 'a fresh focuses table starts empty');
|
|
120
|
+
});
|
|
121
|
+
test('the v6 migration is idempotent / forward-only on re-run and re-open', () => {
|
|
122
|
+
const db = openDb();
|
|
123
|
+
assert.equal(userVersion(db), MIGRATIONS.length);
|
|
124
|
+
// Re-running migrate() is a no-op — the gate skips applied steps. The CREATE
|
|
125
|
+
// TABLE IF NOT EXISTS would be harmless anyway, but the gate must not re-fire.
|
|
126
|
+
assert.doesNotThrow(() => migrate(db));
|
|
127
|
+
assert.equal(userVersion(db), MIGRATIONS.length);
|
|
128
|
+
// Re-opening from disk is likewise a no-op.
|
|
129
|
+
closeDb();
|
|
130
|
+
const db2 = openDb();
|
|
131
|
+
assert.equal(userVersion(db2), MIGRATIONS.length);
|
|
132
|
+
assert.ok(tableNames(db2).includes('focuses'));
|
|
133
|
+
});
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Canvas setters / reads — open / setOccupant / setPane / close round-trip.
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
test('open / setOccupant / close round-trip with the reads', () => {
|
|
138
|
+
openDb();
|
|
139
|
+
openFocusRow('f1', '%a', 'Sa', 'A');
|
|
140
|
+
// getFocusByNode / getFocusByPane / getFocusById all resolve the same row.
|
|
141
|
+
const byNode = getFocusByNode('A');
|
|
142
|
+
assert.deepEqual(byNode, { focus_id: 'f1', pane: '%a', session: 'Sa', node_id: 'A' });
|
|
143
|
+
assert.deepEqual(getFocusByPane('%a'), byNode);
|
|
144
|
+
assert.deepEqual(getFocusById('f1'), byNode);
|
|
145
|
+
assert.deepEqual(listFocuses(), [byNode]);
|
|
146
|
+
// setFocusOccupant hot-swaps the occupant in place (same focus_id/pane).
|
|
147
|
+
setFocusOccupant('f1', 'B');
|
|
148
|
+
assert.equal(getFocusByNode('A'), null, 'A no longer occupies the focus');
|
|
149
|
+
assert.deepEqual(getFocusByNode('B'), { focus_id: 'f1', pane: '%a', session: 'Sa', node_id: 'B' });
|
|
150
|
+
// setFocusPane re-points the pane + session cache (reconcileFocus, Step 6).
|
|
151
|
+
setFocusPane('f1', '%a2', 'Sa2');
|
|
152
|
+
assert.deepEqual(getFocusById('f1'), { focus_id: 'f1', pane: '%a2', session: 'Sa2', node_id: 'B' });
|
|
153
|
+
assert.equal(getFocusByPane('%a'), null, 'the old pane no longer resolves');
|
|
154
|
+
assert.deepEqual(getFocusByPane('%a2')?.node_id, 'B');
|
|
155
|
+
// closeFocusRow deletes the viewport.
|
|
156
|
+
closeFocusRow('f1');
|
|
157
|
+
assert.equal(getFocusByNode('B'), null);
|
|
158
|
+
assert.equal(getFocusById('f1'), null);
|
|
159
|
+
assert.deepEqual(listFocuses(), []);
|
|
160
|
+
});
|
|
161
|
+
test('UNIQUE(node_id): a second focus row for the same node is rejected', () => {
|
|
162
|
+
openDb();
|
|
163
|
+
openFocusRow('f1', '%a', 'Sa', 'A');
|
|
164
|
+
// A second viewport occupied by the SAME node violates UNIQUE(node_id) — this
|
|
165
|
+
// is the constraint that upholds "a node occupies <=1 focus" (Q5).
|
|
166
|
+
assert.throws(() => openFocusRow('f2', '%b', 'Sb', 'A'), /UNIQUE|constraint/i);
|
|
167
|
+
// The first row is untouched; no stray second row was created.
|
|
168
|
+
assert.deepEqual(listFocuses().map((f) => f.focus_id), ['f1']);
|
|
169
|
+
});
|
|
170
|
+
test('UNIQUE(node_id): hot-swapping an occupant onto an already-focused node is rejected', () => {
|
|
171
|
+
openDb();
|
|
172
|
+
openFocusRow('f1', '%a', 'Sa', 'A');
|
|
173
|
+
openFocusRow('f2', '%b', 'Sb', 'B');
|
|
174
|
+
// B already occupies f2 — moving it onto f1 via setFocusOccupant must throw
|
|
175
|
+
// (the Q5 vacate-first is retargetFocus's job, Step 6, not this setter's).
|
|
176
|
+
assert.throws(() => setFocusOccupant('f1', 'B'), /UNIQUE|constraint/i);
|
|
177
|
+
assert.deepEqual(getFocusByNode('A'), { focus_id: 'f1', pane: '%a', session: 'Sa', node_id: 'A' });
|
|
178
|
+
assert.deepEqual(getFocusByNode('B'), { focus_id: 'f2', pane: '%b', session: 'Sb', node_id: 'B' });
|
|
179
|
+
});
|
|
180
|
+
test('independent focus rows do not contend', () => {
|
|
181
|
+
openDb();
|
|
182
|
+
openFocusRow('f1', '%a', 'Sa', 'A');
|
|
183
|
+
openFocusRow('f2', '%b', 'Sb', 'B');
|
|
184
|
+
// Mutating one viewport leaves the other entirely intact.
|
|
185
|
+
setFocusOccupant('f1', 'C');
|
|
186
|
+
setFocusPane('f1', '%a2', 'Sa2');
|
|
187
|
+
assert.deepEqual(getFocusByNode('B'), { focus_id: 'f2', pane: '%b', session: 'Sb', node_id: 'B' });
|
|
188
|
+
closeFocusRow('f1');
|
|
189
|
+
assert.deepEqual(getFocusByNode('B'), { focus_id: 'f2', pane: '%b', session: 'Sb', node_id: 'B' });
|
|
190
|
+
assert.deepEqual(listFocuses().map((f) => f.focus_id), ['f2']);
|
|
191
|
+
});
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Placement reads — focusOf / isFocused / focusByPane / focusedNodes / listFocuses
|
|
194
|
+
// agree with the rows.
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
test('placement focus reads agree with the focus rows', () => {
|
|
197
|
+
openDb();
|
|
198
|
+
openFocusRow('f1', '%a', 'Sa', 'A');
|
|
199
|
+
openFocusRow('f2', '%b', 'Sb', 'B');
|
|
200
|
+
assert.deepEqual(focusOf('A'), { focus_id: 'f1', pane: '%a', session: 'Sa', node_id: 'A' });
|
|
201
|
+
assert.equal(focusOf('Z'), null, 'an unfocused node has no focus');
|
|
202
|
+
assert.equal(isFocused('A'), true);
|
|
203
|
+
assert.equal(isFocused('Z'), false);
|
|
204
|
+
assert.deepEqual(focusByPane('%b'), { focus_id: 'f2', pane: '%b', session: 'Sb', node_id: 'B' });
|
|
205
|
+
assert.deepEqual(focusedNodes(), new Set(['A', 'B']));
|
|
206
|
+
assert.deepEqual(placementListFocuses().map((f) => f.node_id), ['A', 'B']);
|
|
207
|
+
});
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Dual-write bridge — setFocus populates the table; getFocus falls back to the
|
|
210
|
+
// table when focus.ptr is absent; setFocus('') clears both.
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
function focusPtrPath() {
|
|
213
|
+
return join(crtrHome(), 'focus.ptr');
|
|
214
|
+
}
|
|
215
|
+
test('setFocus populates the focuses table in lockstep with focus.ptr', () => {
|
|
216
|
+
openDb();
|
|
217
|
+
setFocus('A');
|
|
218
|
+
assert.equal(getFocus(), 'A', 'focus.ptr reads back');
|
|
219
|
+
const row = getFocusByNode('A');
|
|
220
|
+
assert.ok(row, 'a canonical focus row mirrors the current focus');
|
|
221
|
+
assert.equal(row?.node_id, 'A');
|
|
222
|
+
assert.equal(isFocused('A'), true, 'placement.isFocused agrees');
|
|
223
|
+
assert.deepEqual(focusOf('A')?.node_id, 'A', 'placement.focusOf agrees with getFocus');
|
|
224
|
+
// Re-focusing a different node re-points the SAME canonical row (no stray rows,
|
|
225
|
+
// UNIQUE(node_id) upheld).
|
|
226
|
+
setFocus('B');
|
|
227
|
+
assert.equal(getFocus(), 'B');
|
|
228
|
+
assert.equal(getFocusByNode('A'), null, 'the old occupant is dropped');
|
|
229
|
+
assert.equal(getFocusByNode('B')?.node_id, 'B');
|
|
230
|
+
assert.equal(listFocuses().length, 1, 'still exactly one canonical row');
|
|
231
|
+
});
|
|
232
|
+
test('getFocus falls back to the table when focus.ptr is absent', () => {
|
|
233
|
+
openDb();
|
|
234
|
+
setFocus('A'); // writes both focus.ptr and the canonical row
|
|
235
|
+
// Simulate a missing pointer (a writer that reached only the table, or a lost
|
|
236
|
+
// file): delete focus.ptr and confirm getFocus recovers the focus from the row.
|
|
237
|
+
if (existsSync(focusPtrPath()))
|
|
238
|
+
unlinkSync(focusPtrPath());
|
|
239
|
+
assert.equal(getFocus(), 'A', 'getFocus recovers the focus from the table');
|
|
240
|
+
});
|
|
241
|
+
test("setFocus('') clears both the pointer and the canonical focus row", () => {
|
|
242
|
+
openDb();
|
|
243
|
+
setFocus('A');
|
|
244
|
+
assert.equal(getFocus(), 'A');
|
|
245
|
+
assert.ok(getFocusByNode('A'), 'precondition: row present');
|
|
246
|
+
setFocus('');
|
|
247
|
+
assert.equal(getFocus(), null, 'getFocus is null after clear (ptr empty, no row)');
|
|
248
|
+
assert.equal(getFocusByNode('A'), null, 'the canonical row was closed');
|
|
249
|
+
assert.deepEqual(listFocuses(), [], 'no focus rows remain');
|
|
250
|
+
});
|
|
251
|
+
test('a focus row written directly (no focus.ptr) is visible through getFocus + placement', () => {
|
|
252
|
+
openDb();
|
|
253
|
+
// A writer that reached only the table (the canonical bridge row), with no
|
|
254
|
+
// focus.ptr on disk at all.
|
|
255
|
+
openFocusRow('__focus_ptr__', null, null, 'X');
|
|
256
|
+
assert.ok(!existsSync(focusPtrPath()), 'precondition: no focus.ptr file');
|
|
257
|
+
assert.equal(getFocus(), 'X', 'getFocus falls back to the canonical row');
|
|
258
|
+
assert.equal(isFocused('X'), true);
|
|
259
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/fork.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Covers `crtr node new --fork-from`: the spawn-time branch where a new node is
|
|
4
|
+
// born as a COPY of an existing pi conversation. Two pure layers are unit-tested
|
|
5
|
+
// here — `buildPiArgv` emitting `--fork` (vs `--session`), and `resolveForkSource`
|
|
6
|
+
// turning a node id / path / uuid into the `--fork <path|id>` argument. The tmux
|
|
7
|
+
// spawn itself is exercised elsewhere (it needs a live pi + tmux).
|
|
8
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { createNode } from '../canvas/canvas.js';
|
|
14
|
+
import { closeDb } from '../canvas/db.js';
|
|
15
|
+
import { buildPiArgv } from '../runtime/launch.js';
|
|
16
|
+
import { resolveForkSource } from '../runtime/spawn.js';
|
|
17
|
+
let home;
|
|
18
|
+
function node(id, over = {}) {
|
|
19
|
+
return {
|
|
20
|
+
node_id: id,
|
|
21
|
+
name: id,
|
|
22
|
+
created: new Date().toISOString(),
|
|
23
|
+
cwd: '/tmp/work',
|
|
24
|
+
kind: 'developer',
|
|
25
|
+
mode: 'base',
|
|
26
|
+
lifecycle: 'terminal',
|
|
27
|
+
status: 'active',
|
|
28
|
+
...over,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
before(() => {
|
|
32
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-fork-'));
|
|
33
|
+
process.env['CRTR_HOME'] = home;
|
|
34
|
+
});
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
closeDb();
|
|
37
|
+
rmSync(home, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
after(() => {
|
|
40
|
+
closeDb();
|
|
41
|
+
rmSync(home, { recursive: true, force: true });
|
|
42
|
+
delete process.env['CRTR_HOME'];
|
|
43
|
+
});
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// buildPiArgv — `--fork` is the spawn-time branch (not `--session`)
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
test('buildPiArgv emits --fork <src> and delivers the kickoff prompt', () => {
|
|
48
|
+
const m = node('n');
|
|
49
|
+
const { argv } = buildPiArgv(m, { prompt: 'continue from here', forkFrom: '/abs/src.jsonl' });
|
|
50
|
+
const i = argv.indexOf('--fork');
|
|
51
|
+
assert.ok(i >= 0, 'argv carries --fork');
|
|
52
|
+
assert.equal(argv[i + 1], '/abs/src.jsonl', 'forks from the resolved source');
|
|
53
|
+
assert.ok(!argv.includes('--session'), 'a fork never also resumes');
|
|
54
|
+
assert.equal(argv[argv.length - 1], 'continue from here', 'the kickoff prompt is the last positional');
|
|
55
|
+
});
|
|
56
|
+
test('buildPiArgv prefers --fork over --session when both are somehow set', () => {
|
|
57
|
+
const m = node('n');
|
|
58
|
+
const { argv } = buildPiArgv(m, { forkFrom: '/abs/src.jsonl', resumeSessionPath: '/abs/own.jsonl' });
|
|
59
|
+
assert.ok(argv.includes('--fork'), 'fork wins');
|
|
60
|
+
assert.ok(!argv.includes('--session'), 'resume is suppressed when forking');
|
|
61
|
+
});
|
|
62
|
+
test('buildPiArgv without forkFrom is unchanged (fresh launch, no --fork)', () => {
|
|
63
|
+
const m = node('n');
|
|
64
|
+
const { argv } = buildPiArgv(m, { prompt: 'go' });
|
|
65
|
+
assert.ok(!argv.includes('--fork'), 'no fork on an ordinary fresh launch');
|
|
66
|
+
assert.ok(!argv.includes('--session'), 'no resume either');
|
|
67
|
+
});
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// resolveForkSource — node id / path / uuid → the `--fork` argument
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
test('resolveForkSource resolves a node id to its absolute session FILE', () => {
|
|
72
|
+
createNode(node('src', { pi_session_id: 'uuid-1', pi_session_file: '/abs/src.jsonl' }));
|
|
73
|
+
assert.equal(resolveForkSource('src'), '/abs/src.jsonl');
|
|
74
|
+
});
|
|
75
|
+
test('resolveForkSource falls back to the bare session id when no file captured', () => {
|
|
76
|
+
createNode(node('src', { pi_session_id: 'uuid-1' }));
|
|
77
|
+
assert.equal(resolveForkSource('src'), 'uuid-1');
|
|
78
|
+
});
|
|
79
|
+
test('resolveForkSource throws when the node has no pi session to fork yet', () => {
|
|
80
|
+
createNode(node('fresh'));
|
|
81
|
+
assert.throws(() => resolveForkSource('fresh'), /no pi session yet/);
|
|
82
|
+
});
|
|
83
|
+
test('resolveForkSource passes a path straight through', () => {
|
|
84
|
+
assert.equal(resolveForkSource('/some/where/sess.jsonl'), '/some/where/sess.jsonl');
|
|
85
|
+
});
|
|
86
|
+
test('resolveForkSource passes an unknown bare/partial uuid through to pi', () => {
|
|
87
|
+
assert.equal(resolveForkSource('019e8ce3-322e'), '019e8ce3-322e');
|
|
88
|
+
});
|
|
89
|
+
test('resolveForkSource rejects an empty reference', () => {
|
|
90
|
+
assert.throws(() => resolveForkSource(' '), /requires a node id/);
|
|
91
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/home-session.test.ts
|
|
2
|
+
//
|
|
3
|
+
// STEP 1 of the placement/focus migration: the durable REVIVE-HOME field.
|
|
4
|
+
// `home_session` separates a node's revive target from its live LOCATION so a
|
|
5
|
+
// later step can kill the focus taint. This step only ADDS + POPULATES +
|
|
6
|
+
// DEFAULTS the field — no behavior change. Covers:
|
|
7
|
+
// - home_session round-trips through meta.json (it IS durable identity)
|
|
8
|
+
// - the birth-session decision (`resolveBirthSession`) for the child /
|
|
9
|
+
// inline-root / --root cases each site sets home_session from
|
|
10
|
+
// - demote-recycle + relaunch (pane-recycle) births populate home_session
|
|
11
|
+
// - a legacy meta with NO home_session defaults to tmux_session ?? nodeSession()
|
|
12
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { createNode, getNode, updateNode } from '../canvas/canvas.js';
|
|
18
|
+
import { nodeMetaPath } from '../canvas/paths.js';
|
|
19
|
+
import { closeDb } from '../canvas/db.js';
|
|
20
|
+
import { resolveBirthSession, homeSessionOf } from '../runtime/nodes.js';
|
|
21
|
+
import { nodeSession } from '../runtime/tmux.js';
|
|
22
|
+
import { relaunchRoot } from '../runtime/reset.js';
|
|
23
|
+
import { demoteNode } from '../runtime/demote.js';
|
|
24
|
+
let home;
|
|
25
|
+
function node(id, over = {}) {
|
|
26
|
+
return {
|
|
27
|
+
node_id: id,
|
|
28
|
+
name: id,
|
|
29
|
+
created: new Date().toISOString(),
|
|
30
|
+
cwd: '/tmp/work',
|
|
31
|
+
kind: 'general',
|
|
32
|
+
mode: 'base',
|
|
33
|
+
lifecycle: 'resident',
|
|
34
|
+
status: 'active',
|
|
35
|
+
...over,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Make ensureDaemon (called by demoteNode) a no-op by faking a live daemon
|
|
39
|
+
* pidfile pointing at THIS test process — so no real daemon is ever spawned. */
|
|
40
|
+
function fakeLiveDaemon() {
|
|
41
|
+
writeFileSync(join(home, 'crtrd.pid'), String(process.pid), 'utf8');
|
|
42
|
+
}
|
|
43
|
+
before(() => {
|
|
44
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-homesession-'));
|
|
45
|
+
process.env['CRTR_HOME'] = home;
|
|
46
|
+
});
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
closeDb();
|
|
49
|
+
rmSync(home, { recursive: true, force: true });
|
|
50
|
+
delete process.env['CRTR_ROOT_SESSION'];
|
|
51
|
+
delete process.env['CRTR_NODE_SESSION'];
|
|
52
|
+
});
|
|
53
|
+
after(() => {
|
|
54
|
+
closeDb();
|
|
55
|
+
rmSync(home, { recursive: true, force: true });
|
|
56
|
+
delete process.env['CRTR_HOME'];
|
|
57
|
+
delete process.env['CRTR_ROOT_SESSION'];
|
|
58
|
+
delete process.env['CRTR_NODE_SESSION'];
|
|
59
|
+
});
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Persistence: home_session is DURABLE IDENTITY (meta.json), round-tripped by
|
|
62
|
+
// createNode / getNode / updateNode — and NOT a runtime field.
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
test('home_session round-trips through meta.json and the hydrated view', () => {
|
|
65
|
+
createNode(node('n', { home_session: 'crtr', tmux_session: 'user-sess', window: '@1' }));
|
|
66
|
+
// On disk: meta.json carries home_session (identity), NOT the runtime LOCATION.
|
|
67
|
+
const raw = JSON.parse(readFileSync(nodeMetaPath('n'), 'utf8'));
|
|
68
|
+
assert.equal(raw['home_session'], 'crtr', 'home_session persisted to meta.json (durable identity)');
|
|
69
|
+
assert.ok(!('tmux_session' in raw), 'tmux_session stays a runtime field, not in meta.json');
|
|
70
|
+
// Hydrated view returns it; the live LOCATION is independent.
|
|
71
|
+
const m = getNode('n');
|
|
72
|
+
assert.equal(m?.home_session, 'crtr', 'getNode hydrates home_session');
|
|
73
|
+
assert.equal(m?.tmux_session, 'user-sess', 'home_session is distinct from the live LOCATION');
|
|
74
|
+
});
|
|
75
|
+
test('updateNode patches home_session (the demote rewriter path) and preserves it', () => {
|
|
76
|
+
createNode(node('n', { home_session: 'crtr' }));
|
|
77
|
+
updateNode('n', { home_session: 'recycled-sess' });
|
|
78
|
+
assert.equal(getNode('n')?.home_session, 'recycled-sess', 'home_session rewritten by updateNode');
|
|
79
|
+
// An unrelated identity patch leaves home_session intact (RMW round-trip).
|
|
80
|
+
updateNode('n', { description: 'a-handle' });
|
|
81
|
+
assert.equal(getNode('n')?.home_session, 'recycled-sess', 'home_session survives an unrelated identity edit');
|
|
82
|
+
});
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// The legacy / back-compat DEFAULT: a meta with no home_session reads back
|
|
85
|
+
// tmux_session ?? nodeSession().
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
test('homeSessionOf: a present home_session is returned verbatim', () => {
|
|
88
|
+
createNode(node('n', { home_session: 'home-sess', tmux_session: 'live-sess' }));
|
|
89
|
+
assert.equal(homeSessionOf('n'), 'home-sess', 'the stored revive-home wins over the live LOCATION');
|
|
90
|
+
});
|
|
91
|
+
test('homeSessionOf: legacy meta (no home_session) defaults to tmux_session', () => {
|
|
92
|
+
createNode(node('legacy', { tmux_session: 'live-sess' }));
|
|
93
|
+
assert.equal(getNode('legacy')?.home_session, undefined, 'no home_session on a legacy node');
|
|
94
|
+
assert.equal(homeSessionOf('legacy'), 'live-sess', 'defaults to the last live LOCATION');
|
|
95
|
+
});
|
|
96
|
+
test('homeSessionOf: legacy meta with no LOCATION either defaults to nodeSession()', () => {
|
|
97
|
+
createNode(node('legacy', { tmux_session: null }));
|
|
98
|
+
assert.equal(homeSessionOf('legacy'), nodeSession(), 'falls through to the shared backstage');
|
|
99
|
+
});
|
|
100
|
+
test('homeSessionOf: the backstage default honors CRTR_NODE_SESSION', () => {
|
|
101
|
+
process.env['CRTR_NODE_SESSION'] = 'my-backstage';
|
|
102
|
+
createNode(node('legacy'));
|
|
103
|
+
assert.equal(homeSessionOf('legacy'), 'my-backstage', 'nodeSession() default is env-overridable');
|
|
104
|
+
});
|
|
105
|
+
test('homeSessionOf: unknown node falls back to the backstage', () => {
|
|
106
|
+
assert.equal(homeSessionOf('ghost'), nodeSession());
|
|
107
|
+
});
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// The birth-session decision each site sets home_session from. Pure, so the
|
|
110
|
+
// child / inline-root / --root births are testable without a live tmux (the
|
|
111
|
+
// real spawnChild/bootRoot are tmux + pi + process.exit coupled).
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
test('birth: a managed child homes to the shared backstage (nodeSession), never a user session', () => {
|
|
114
|
+
// No CRTR_ROOT_SESSION inherited, not adopting the caller.
|
|
115
|
+
assert.equal(resolveBirthSession({ adoptCaller: false, here: { session: 'user-sess' }, rootSession: undefined }), nodeSession(), 'a child ignores the caller session and homes to crtr');
|
|
116
|
+
});
|
|
117
|
+
test('birth: a managed child inherits CRTR_ROOT_SESSION as its backstage', () => {
|
|
118
|
+
assert.equal(resolveBirthSession({ adoptCaller: false, here: null, rootSession: 'crtr-subtree' }), 'crtr-subtree', 'the inherited root session is the child backstage');
|
|
119
|
+
});
|
|
120
|
+
test('birth: an independent --root inside tmux homes to the caller current session', () => {
|
|
121
|
+
assert.equal(resolveBirthSession({ adoptCaller: true, here: { session: 'user-sess' }, rootSession: 'crtr' }), 'user-sess', 'a --root adopts the caller session where the spawner is working');
|
|
122
|
+
});
|
|
123
|
+
test('birth: a --root NOT inside tmux falls back to the backstage', () => {
|
|
124
|
+
assert.equal(resolveBirthSession({ adoptCaller: true, here: null, rootSession: undefined }), nodeSession(), 'no caller session → the backstage');
|
|
125
|
+
});
|
|
126
|
+
test('birth: the inline front door (bootRoot) homes to its adopted session', () => {
|
|
127
|
+
// bootRoot adopts the caller session when inside tmux, else nodeSession().
|
|
128
|
+
assert.equal(resolveBirthSession({ adoptCaller: true, here: { session: 'term-sess' }, rootSession: undefined }), 'term-sess', 'inline root adopts the terminal it took over');
|
|
129
|
+
assert.equal(resolveBirthSession({ adoptCaller: true, here: null, rootSession: undefined }), nodeSession(), 'inline root with no tmux homes to the backstage');
|
|
130
|
+
});
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Pane-recycle births populate home_session. relaunchRoot (option C) is fully
|
|
133
|
+
// unit-testable (injected respawn, no tmux); demoteNode runs to completion with
|
|
134
|
+
// no tmux (respawn dispatch just fails) once the daemon spawn is neutralized.
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
test('relaunch birth: the fresh root homes to the recycled pane session', () => {
|
|
137
|
+
createNode(node('root', { parent: null, lifecycle: 'resident', tmux_session: 'crtr', window: '@7' }));
|
|
138
|
+
// No live tmux → paneLocation(pane) is null → loc falls back to the old root's
|
|
139
|
+
// tmux_session ('crtr'); home_session must adopt it.
|
|
140
|
+
const res = relaunchRoot('root', 'test-pane', { relaunchRootInPane: () => { } });
|
|
141
|
+
assert.ok(res !== null, 'relaunchRoot minted a fresh root');
|
|
142
|
+
assert.equal(getNode(res.newNodeId)?.home_session, 'crtr', 'fresh root homes to the recycled pane session');
|
|
143
|
+
});
|
|
144
|
+
test('demote birth: the recycled root populates home_session (backstage when no pane location)', async () => {
|
|
145
|
+
createNode(node('M', { parent: null, lifecycle: 'resident', tmux_session: 'crtr', window: '@3' }));
|
|
146
|
+
fakeLiveDaemon(); // createNode ensured the home dir; now neutralize ensureDaemon
|
|
147
|
+
// No live tmux → paneLocation('%0') is null → home_session defaults to the
|
|
148
|
+
// backstage. The respawn dispatch fails (no tmux), but the fresh root is still
|
|
149
|
+
// born — and must carry a populated home_session.
|
|
150
|
+
const res = await demoteNode('M', '%0');
|
|
151
|
+
assert.ok(res.newRoot !== null, 'demote minted a fresh root');
|
|
152
|
+
assert.equal(getNode(res.newRoot)?.home_session, nodeSession(), 'recycled root homes to the backstage');
|
|
153
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/human-cancel-guard.test.ts
|
|
2
|
+
//
|
|
3
|
+
// N2 — humanCancel on an already-canceled (but unresolved) interaction node must
|
|
4
|
+
// short-circuit to {canceled:false, reason:'already_resolved'} instead of falling
|
|
5
|
+
// through to transition('finalize'), which is illegal from status='canceled' and
|
|
6
|
+
// would throw. Guards the one-line hardening in queue.ts's status guard.
|
|
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 { humanCancel } from '../../commands/human/queue.js';
|
|
15
|
+
let home;
|
|
16
|
+
function node(id) {
|
|
17
|
+
return {
|
|
18
|
+
node_id: id,
|
|
19
|
+
name: id,
|
|
20
|
+
created: new Date().toISOString(),
|
|
21
|
+
cwd: '/tmp/work',
|
|
22
|
+
kind: 'general',
|
|
23
|
+
mode: 'base',
|
|
24
|
+
lifecycle: 'terminal',
|
|
25
|
+
status: 'active',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
before(() => {
|
|
29
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-humancancel-'));
|
|
30
|
+
process.env['CRTR_HOME'] = home;
|
|
31
|
+
});
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
closeDb();
|
|
34
|
+
rmSync(home, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
after(() => {
|
|
37
|
+
closeDb();
|
|
38
|
+
rmSync(home, { recursive: true, force: true });
|
|
39
|
+
delete process.env['CRTR_HOME'];
|
|
40
|
+
});
|
|
41
|
+
test('cancel on an already-canceled node is a no-op, never throws on the finalize', async () => {
|
|
42
|
+
const id = 'canceledJob';
|
|
43
|
+
createNode(node(id));
|
|
44
|
+
setStatus(id, 'canceled'); // canceled but no response.json written yet
|
|
45
|
+
const res = (await humanCancel.run({ job_id: id }));
|
|
46
|
+
assert.equal(res['canceled'], false);
|
|
47
|
+
assert.equal(res['reason'], 'already_resolved');
|
|
48
|
+
assert.equal(res['job_id'], id);
|
|
49
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|