@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
|
@@ -4,37 +4,86 @@
|
|
|
4
4
|
// Source-of-truth split: a node's meta.json is canonical for its own fields;
|
|
5
5
|
// the db row is a queryable index re-derivable from it. The subscribes_to edges
|
|
6
6
|
// are db-authoritative (mutable, many-writers — what WAL is for).
|
|
7
|
-
import { existsSync, readFileSync, writeFileSync, renameSync, readdirSync, } from 'node:fs';
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, readdirSync, rmSync, } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { openDb } from './db.js';
|
|
10
10
|
import { ensureHome, ensureNodeDirs, nodeMetaPath, nodeDir, nodesRoot, } from './paths.js';
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
12
|
-
// meta.json (source of truth)
|
|
12
|
+
// meta.json (durable identity — the source of truth for what PERSISTS)
|
|
13
|
+
//
|
|
14
|
+
// One authoritative store per fact: meta.json holds NodeIdentity only; the six
|
|
15
|
+
// runtime fields (status, intent, pi_pid, window, tmux_session, pane) are
|
|
16
|
+
// authoritative in the WAL'd `nodes` row, each mutated by one atomic setter
|
|
17
|
+
// below. getNode() hydrates the two back into the historical NodeMeta view.
|
|
13
18
|
// ---------------------------------------------------------------------------
|
|
19
|
+
/** The identity keys meta.json persists. Listed explicitly so no runtime field
|
|
20
|
+
* can ever leak onto disk even when a fully-hydrated NodeMeta is handed in. */
|
|
21
|
+
const IDENTITY_KEYS = [
|
|
22
|
+
'node_id', 'name', 'description', 'cycles', 'created', 'cwd', 'kind', 'mode',
|
|
23
|
+
'lifecycle', 'persona_ack', 'parent', 'spawned_by', 'passive_default',
|
|
24
|
+
'home_session', 'pi_session_id', 'pi_session_file', 'launch',
|
|
25
|
+
];
|
|
26
|
+
/** Project any node object down to its durable-identity subset. */
|
|
27
|
+
function toIdentity(m) {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const k of IDENTITY_KEYS) {
|
|
30
|
+
if (m[k] !== undefined)
|
|
31
|
+
out[k] = m[k];
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
14
35
|
function readMeta(nodeId) {
|
|
15
36
|
const p = nodeMetaPath(nodeId);
|
|
16
37
|
if (!existsSync(p))
|
|
17
38
|
return null;
|
|
39
|
+
// Legacy metas may still carry runtime fields on disk; toIdentity-on-read is
|
|
40
|
+
// unnecessary (callers go through getNode, which overlays the row), but the
|
|
41
|
+
// raw parse is typed as identity — extra props are ignored.
|
|
18
42
|
return JSON.parse(readFileSync(p, 'utf8'));
|
|
19
43
|
}
|
|
44
|
+
/** Serialize ONLY the identity subset → meta.json never holds runtime fields. */
|
|
20
45
|
function writeMeta(meta) {
|
|
21
46
|
const p = nodeMetaPath(meta.node_id);
|
|
22
47
|
const tmp = `${p}.tmp`;
|
|
23
|
-
writeFileSync(tmp, JSON.stringify(meta, null, 2));
|
|
48
|
+
writeFileSync(tmp, JSON.stringify(toIdentity(meta), null, 2));
|
|
24
49
|
renameSync(tmp, p);
|
|
25
50
|
}
|
|
26
51
|
// ---------------------------------------------------------------------------
|
|
27
|
-
// row index
|
|
52
|
+
// row index — identity columns are a derived projection of meta; runtime
|
|
53
|
+
// columns are authoritative. The two have DIFFERENT writers: upsertRow only
|
|
54
|
+
// ever touches identity (so a re-index never clobbers live runtime), while
|
|
55
|
+
// createNode seeds runtime once and the atomic setters own it thereafter.
|
|
28
56
|
// ---------------------------------------------------------------------------
|
|
57
|
+
/** Upsert the IDENTITY columns of a node's row. ON CONFLICT updates identity
|
|
58
|
+
* ONLY — runtime columns (status/intent/pi_pid/window/tmux_session/pane) are left
|
|
59
|
+
* exactly as they are, so re-indexing or an identity edit never disturbs live
|
|
60
|
+
* state. A fresh insert takes the schema defaults for runtime. */
|
|
29
61
|
function upsertRow(meta) {
|
|
30
62
|
openDb()
|
|
31
|
-
.prepare(`INSERT INTO nodes (node_id, name, kind, mode, lifecycle,
|
|
32
|
-
VALUES (?, ?, ?, ?, ?, ?, ?,
|
|
63
|
+
.prepare(`INSERT INTO nodes (node_id, name, kind, mode, lifecycle, cwd, parent, created)
|
|
64
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
33
65
|
ON CONFLICT(node_id) DO UPDATE SET
|
|
34
66
|
name=excluded.name, kind=excluded.kind, mode=excluded.mode,
|
|
35
|
-
lifecycle=excluded.lifecycle,
|
|
36
|
-
|
|
37
|
-
|
|
67
|
+
lifecycle=excluded.lifecycle, cwd=excluded.cwd, parent=excluded.parent`)
|
|
68
|
+
.run(meta.node_id, meta.name, meta.kind, meta.mode, meta.lifecycle, meta.cwd, meta.parent ?? null, meta.created);
|
|
69
|
+
}
|
|
70
|
+
/** Seed a node's row at BIRTH: identity columns + runtime columns taken from the
|
|
71
|
+
* incoming meta (defaults: status='active', the rest null). The only writer
|
|
72
|
+
* that sets runtime columns alongside identity in one statement — afterwards
|
|
73
|
+
* the atomic setters are the sole runtime writers. */
|
|
74
|
+
function seedRow(meta) {
|
|
75
|
+
openDb()
|
|
76
|
+
.prepare(`INSERT INTO nodes
|
|
77
|
+
(node_id, name, kind, mode, lifecycle, cwd, parent, created,
|
|
78
|
+
status, intent, pi_pid, "window", tmux_session, pane)
|
|
79
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
80
|
+
ON CONFLICT(node_id) DO UPDATE SET
|
|
81
|
+
name=excluded.name, kind=excluded.kind, mode=excluded.mode,
|
|
82
|
+
lifecycle=excluded.lifecycle, cwd=excluded.cwd, parent=excluded.parent,
|
|
83
|
+
status=excluded.status, intent=excluded.intent, pi_pid=excluded.pi_pid,
|
|
84
|
+
"window"=excluded."window", tmux_session=excluded.tmux_session,
|
|
85
|
+
pane=excluded.pane`)
|
|
86
|
+
.run(meta.node_id, meta.name, meta.kind, meta.mode, meta.lifecycle, meta.cwd, meta.parent ?? null, meta.created, meta.status ?? 'active', meta.intent ?? null, meta.pi_pid ?? null, meta.window ?? null, meta.tmux_session ?? null, meta.pane ?? null);
|
|
38
87
|
}
|
|
39
88
|
function rowFrom(r) {
|
|
40
89
|
return {
|
|
@@ -47,22 +96,52 @@ function rowFrom(r) {
|
|
|
47
96
|
cwd: r['cwd'],
|
|
48
97
|
parent: r['parent'] ?? null,
|
|
49
98
|
created: r['created'],
|
|
99
|
+
intent: r['intent'] ?? null,
|
|
100
|
+
pi_pid: r['pi_pid'] ?? null,
|
|
101
|
+
window: r['window'] ?? null,
|
|
102
|
+
tmux_session: r['tmux_session'] ?? null,
|
|
103
|
+
pane: r['pane'] ?? null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** The authoritative runtime fields for `nodeId`, read from its row. Null when
|
|
107
|
+
* no row exists yet (the hydration then falls back to whatever the meta held). */
|
|
108
|
+
function runtimeFromRow(nodeId) {
|
|
109
|
+
const r = openDb()
|
|
110
|
+
.prepare('SELECT status, intent, pi_pid, "window", tmux_session, pane FROM nodes WHERE node_id = ?')
|
|
111
|
+
.get(nodeId);
|
|
112
|
+
if (r === undefined)
|
|
113
|
+
return null;
|
|
114
|
+
return {
|
|
115
|
+
status: r['status'] ?? 'active',
|
|
116
|
+
intent: r['intent'] ?? null,
|
|
117
|
+
pi_pid: r['pi_pid'] ?? null,
|
|
118
|
+
window: r['window'] ?? null,
|
|
119
|
+
tmux_session: r['tmux_session'] ?? null,
|
|
120
|
+
pane: r['pane'] ?? null,
|
|
50
121
|
};
|
|
51
122
|
}
|
|
52
123
|
// ---------------------------------------------------------------------------
|
|
53
124
|
// Nodes
|
|
54
125
|
// ---------------------------------------------------------------------------
|
|
55
|
-
/** Create a node: scaffold its dirs,
|
|
126
|
+
/** Create a node: scaffold its dirs, persist identity to meta.json, and seed the
|
|
127
|
+
* row (identity + runtime from the incoming meta). Returns the hydrated view. */
|
|
56
128
|
export function createNode(meta) {
|
|
57
129
|
ensureHome();
|
|
58
130
|
ensureNodeDirs(meta.node_id);
|
|
59
131
|
writeMeta(meta);
|
|
60
|
-
|
|
61
|
-
return meta;
|
|
132
|
+
seedRow(meta);
|
|
133
|
+
return getNode(meta.node_id);
|
|
62
134
|
}
|
|
63
|
-
/** The canonical node record (
|
|
135
|
+
/** The canonical node record: durable identity (meta.json) ∪ authoritative
|
|
136
|
+
* runtime (the row). Null if unknown. */
|
|
64
137
|
export function getNode(nodeId) {
|
|
65
|
-
|
|
138
|
+
const ident = readMeta(nodeId);
|
|
139
|
+
if (ident === null)
|
|
140
|
+
return null;
|
|
141
|
+
const rt = runtimeFromRow(nodeId);
|
|
142
|
+
// The row is authoritative for runtime; overlay it over identity. When no row
|
|
143
|
+
// exists yet (rare — pre-rebuild), keep whatever the meta carried.
|
|
144
|
+
return { ...ident, ...(rt ?? {}) };
|
|
66
145
|
}
|
|
67
146
|
/** The indexed row (from the db) — cheap for queries that don't need full meta. */
|
|
68
147
|
export function getRow(nodeId) {
|
|
@@ -71,7 +150,20 @@ export function getRow(nodeId) {
|
|
|
71
150
|
.get(nodeId);
|
|
72
151
|
return r ? rowFrom(r) : null;
|
|
73
152
|
}
|
|
74
|
-
/**
|
|
153
|
+
/** The node row whose durable LOCATION pane is `pane`, or null. Lets placement
|
|
154
|
+
* resolve "who sits in this pane" by the first-class `%pane_id` handle (e.g.
|
|
155
|
+
* to adopt a caller's pane as a focus). pane is not UNIQUE in the schema, but a
|
|
156
|
+
* live pane backs at most one node, so this returns the single match. */
|
|
157
|
+
export function getRowByPane(pane) {
|
|
158
|
+
const r = openDb()
|
|
159
|
+
.prepare('SELECT * FROM nodes WHERE pane = ?')
|
|
160
|
+
.get(pane);
|
|
161
|
+
return r ? rowFrom(r) : null;
|
|
162
|
+
}
|
|
163
|
+
/** Merge an IDENTITY patch into a node's meta.json and re-index its identity
|
|
164
|
+
* columns. Identity has a single writer per node, so this read-modify-write is
|
|
165
|
+
* safe (the contended runtime fields were moved out — see the atomic setters
|
|
166
|
+
* below). Returns the hydrated view (runtime included). */
|
|
75
167
|
export function updateNode(nodeId, patch) {
|
|
76
168
|
const cur = readMeta(nodeId);
|
|
77
169
|
if (!cur)
|
|
@@ -79,11 +171,41 @@ export function updateNode(nodeId, patch) {
|
|
|
79
171
|
const next = { ...cur, ...patch, node_id: cur.node_id };
|
|
80
172
|
writeMeta(next);
|
|
81
173
|
upsertRow(next);
|
|
82
|
-
return
|
|
174
|
+
return getNode(nodeId);
|
|
83
175
|
}
|
|
84
|
-
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Atomic runtime setters — each one a single-statement UPDATE on the WAL'd row,
|
|
178
|
+
// the authoritative store for live state. No read-modify-write, so concurrent
|
|
179
|
+
// writers of DIFFERENT fields (the daemon stamping pi_pid while a node flips
|
|
180
|
+
// status) can never clobber each other: WAL serializes the two statements.
|
|
181
|
+
// `"window"` is quoted defensively — it is a SQLite keyword.
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
/** Set a node's status. Atomic single-column write. */
|
|
85
184
|
export function setStatus(nodeId, status) {
|
|
86
|
-
|
|
185
|
+
openDb().prepare('UPDATE nodes SET status = ? WHERE node_id = ?').run(status, nodeId);
|
|
186
|
+
}
|
|
187
|
+
/** Set a node's exit intent. Atomic single-column write. */
|
|
188
|
+
export function setIntent(nodeId, intent) {
|
|
189
|
+
openDb().prepare('UPDATE nodes SET intent = ? WHERE node_id = ?').run(intent ?? null, nodeId);
|
|
190
|
+
}
|
|
191
|
+
/** Set a node's tmux presence in one atomic write: the durable LOCATION anchor
|
|
192
|
+
* `pane` (the `%pane_id`) plus its derived cache (`tmux_session` + `window`).
|
|
193
|
+
* All three move together — `pane` joins the others inside the single UPDATE so
|
|
194
|
+
* a move never half-writes the location. `pane` is optional: a caller that does
|
|
195
|
+
* not yet track it (every caller, until the placement layer lands) writes null,
|
|
196
|
+
* which is harmless because nothing reads `pane` yet. */
|
|
197
|
+
export function setPresence(nodeId, presence) {
|
|
198
|
+
openDb()
|
|
199
|
+
.prepare('UPDATE nodes SET tmux_session = ?, "window" = ?, pane = ? WHERE node_id = ?')
|
|
200
|
+
.run(presence.tmux_session ?? null, presence.window ?? null, presence.pane ?? null, nodeId);
|
|
201
|
+
}
|
|
202
|
+
/** Record the live pi pid (daemon liveness signal). Atomic single-column write. */
|
|
203
|
+
export function recordPid(nodeId, pid) {
|
|
204
|
+
openDb().prepare('UPDATE nodes SET pi_pid = ? WHERE node_id = ?').run(pid, nodeId);
|
|
205
|
+
}
|
|
206
|
+
/** Clear the pi pid (window-backed relaunch, before the fresh pi re-records it). */
|
|
207
|
+
export function clearPid(nodeId) {
|
|
208
|
+
openDb().prepare('UPDATE nodes SET pi_pid = NULL WHERE node_id = ?').run(nodeId);
|
|
87
209
|
}
|
|
88
210
|
/** All rows, optionally filtered by status. */
|
|
89
211
|
export function listNodes(filter) {
|
|
@@ -192,19 +314,85 @@ export function hasActiveLiveSubscription(nodeId) {
|
|
|
192
314
|
// Index rebuild
|
|
193
315
|
// ---------------------------------------------------------------------------
|
|
194
316
|
/** Rebuild node rows from on-disk metas (the db node table is a derived index).
|
|
317
|
+
* Only the IDENTITY columns are rebuilt — they are a projection of meta. The
|
|
318
|
+
* runtime columns (status/intent/pi_pid/window/tmux_session/pane) are NOT in meta
|
|
319
|
+
* and NOT re-derivable from it: they describe live process/presence state, so
|
|
320
|
+
* an existing row keeps them and a freshly re-created row takes the schema's
|
|
321
|
+
* quiescent defaults (status='active', the rest null). The daemon reconciles
|
|
322
|
+
* liveness from tmux reality, not from a stale file.
|
|
195
323
|
* Edges are left intact — subscribes_to is db-authoritative; spawned_by is
|
|
196
|
-
* re-derived from each meta's `parent
|
|
324
|
+
* re-derived from each meta's `spawned_by` (fallback: `parent` for legacy metas). */
|
|
197
325
|
export function rebuildIndex() {
|
|
198
326
|
if (!existsSync(nodesRoot()))
|
|
199
327
|
return;
|
|
328
|
+
// Collect every on-disk meta first, then index in TWO passes. Under the
|
|
329
|
+
// edges→nodes FK (migration v4), a `spawned_by` edge insert whose endpoint
|
|
330
|
+
// row isn't present yet violates the constraint — so ALL node rows must exist
|
|
331
|
+
// before ANY edge is added.
|
|
332
|
+
const metas = [];
|
|
200
333
|
for (const id of readdirSync(nodesRoot())) {
|
|
201
334
|
if (!existsSync(join(nodeDir(id), 'meta.json')))
|
|
202
335
|
continue;
|
|
203
336
|
const meta = readMeta(id);
|
|
204
337
|
if (!meta)
|
|
205
338
|
continue;
|
|
339
|
+
metas.push(meta);
|
|
340
|
+
}
|
|
341
|
+
// Pass 1 — upsert every node row (the edge endpoints).
|
|
342
|
+
for (const meta of metas)
|
|
206
343
|
upsertRow(meta);
|
|
207
|
-
|
|
208
|
-
|
|
344
|
+
// Pass 2 — add the audit-only `spawned_by` provenance edges. Skip any whose
|
|
345
|
+
// provenance node has no on-disk meta (a deleted/pruned ancestor): the FK
|
|
346
|
+
// would reject it, and an orphan provenance edge is exactly what v4 makes
|
|
347
|
+
// unrepresentable.
|
|
348
|
+
const known = new Set(metas.map((m) => m.node_id));
|
|
349
|
+
for (const meta of metas) {
|
|
350
|
+
const prov = meta.spawned_by ?? meta.parent;
|
|
351
|
+
if (prov && known.has(prov))
|
|
352
|
+
recordSpawn(meta.node_id, prov);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/** Retention sweep: remove TERMINAL nodes (status dead | done | canceled) whose
|
|
356
|
+
* `created` is older than `ttlDays`, bounding the otherwise-unbounded growth of
|
|
357
|
+
* node rows + dirs. The edges→nodes FK (`ON DELETE CASCADE`, migration v4) GCs
|
|
358
|
+
* each pruned node's edges automatically; the on-disk `nodes/<id>/` dir is
|
|
359
|
+
* removed too. Live nodes are NEVER touched: active | idle are the daemon's
|
|
360
|
+
* domain, a DISJOINT status set, so prune and supervision can't interfere.
|
|
361
|
+
*
|
|
362
|
+
* The row deletes run in ONE transaction (so the sweep is all-or-nothing); the
|
|
363
|
+
* dir removals follow after COMMIT — the fs isn't transactional, and by then the
|
|
364
|
+
* rows are gone, so a re-run never re-finds a half-deleted node. `dryRun`
|
|
365
|
+
* reports the candidate set and deletes NOTHING. */
|
|
366
|
+
export function pruneNodes(opts) {
|
|
367
|
+
const dryRun = opts.dryRun ?? false;
|
|
368
|
+
const cutoff = new Date(Date.now() - opts.ttlDays * 86_400_000).toISOString();
|
|
369
|
+
const db = openDb();
|
|
370
|
+
const candidates = db
|
|
371
|
+
.prepare(`SELECT node_id, status, created FROM nodes
|
|
372
|
+
WHERE status IN ('dead', 'done', 'canceled') AND created < ?
|
|
373
|
+
ORDER BY created`)
|
|
374
|
+
.all(cutoff).map((r) => ({
|
|
375
|
+
node_id: r['node_id'],
|
|
376
|
+
status: r['status'],
|
|
377
|
+
created: r['created'],
|
|
378
|
+
}));
|
|
379
|
+
if (dryRun || candidates.length === 0)
|
|
380
|
+
return { pruned: candidates, dryRun };
|
|
381
|
+
// One transactioned sweep — delete the rows; the FK cascades their edges.
|
|
382
|
+
db.exec('BEGIN');
|
|
383
|
+
try {
|
|
384
|
+
const del = db.prepare('DELETE FROM nodes WHERE node_id = ?');
|
|
385
|
+
for (const c of candidates)
|
|
386
|
+
del.run(c.node_id);
|
|
387
|
+
db.exec('COMMIT');
|
|
388
|
+
}
|
|
389
|
+
catch (e) {
|
|
390
|
+
db.exec('ROLLBACK');
|
|
391
|
+
throw e;
|
|
392
|
+
}
|
|
393
|
+
// Remove each pruned node's on-disk dir (best-effort, after COMMIT).
|
|
394
|
+
for (const c of candidates) {
|
|
395
|
+
rmSync(nodeDir(c.node_id), { recursive: true, force: true });
|
|
209
396
|
}
|
|
397
|
+
return { pruned: candidates, dryRun };
|
|
210
398
|
}
|
package/dist/core/canvas/db.d.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
/** The ordered migration list. Index `i` is migration version `i + 1`; the db's
|
|
3
|
+
* `user_version` tracks how many have been applied. Append only. */
|
|
4
|
+
export declare const MIGRATIONS: ReadonlyArray<(db: DatabaseSync) => void>;
|
|
5
|
+
/** Bring `db` up to the latest schema version. Reads `user_version`, runs each
|
|
6
|
+
* pending migration in order, and bumps `user_version` after each so the work
|
|
7
|
+
* is gated and idempotent: re-running is a no-op once `user_version` reaches
|
|
8
|
+
* `MIGRATIONS.length`. Forward-only. */
|
|
9
|
+
export declare function migrate(db: DatabaseSync): void;
|
|
2
10
|
/** Open (or reuse) the canvas db at the current `CRTR_HOME`, initializing the
|
|
3
11
|
* schema and WAL on first open. Keyed by path so tests with distinct homes get
|
|
4
12
|
* independent handles. */
|
package/dist/core/canvas/db.js
CHANGED
|
@@ -5,8 +5,19 @@
|
|
|
5
5
|
// truth). The `subscribes_to` edges are the one genuinely-mutable part no meta
|
|
6
6
|
// owns, so the db is authoritative for them.
|
|
7
7
|
import { DatabaseSync } from 'node:sqlite';
|
|
8
|
-
import {
|
|
9
|
-
|
|
8
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
9
|
+
import { canvasDbPath, ensureHome, nodesRoot, nodeMetaPath } from './paths.js';
|
|
10
|
+
// --- Schema as a forward-only migration list ------------------------------
|
|
11
|
+
//
|
|
12
|
+
// The schema is the migration list: one place a schema change is expressed,
|
|
13
|
+
// one gate (`PRAGMA user_version`) that applies it. `migrate()` runs every
|
|
14
|
+
// pending step in order and bumps `user_version` after each, so a fresh db and
|
|
15
|
+
// the live fleet (all at `user_version 0`) converge on the same final shape.
|
|
16
|
+
// Migrations are append-only and forward-only — never edit a shipped step.
|
|
17
|
+
/** v1 — the baseline tables + indexes. `IF NOT EXISTS` makes this a no-op on
|
|
18
|
+
* any existing db (the live fleet already has these tables). */
|
|
19
|
+
function baselineSchema(db) {
|
|
20
|
+
db.exec(`
|
|
10
21
|
CREATE TABLE IF NOT EXISTS nodes (
|
|
11
22
|
node_id TEXT PRIMARY KEY,
|
|
12
23
|
name TEXT NOT NULL,
|
|
@@ -31,7 +42,196 @@ CREATE TABLE IF NOT EXISTS edges (
|
|
|
31
42
|
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(type, to_id);
|
|
32
43
|
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(type, from_id);
|
|
33
44
|
CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status);
|
|
34
|
-
|
|
45
|
+
`);
|
|
46
|
+
}
|
|
47
|
+
/** v2 — additive runtime columns the keystone (Phase 2) will make
|
|
48
|
+
* authoritative: `intent, pi_pid, window, tmux_session`. `status` already
|
|
49
|
+
* lives in the baseline row, so it is NOT re-added here. All four default to
|
|
50
|
+
* NULL, so nothing observes a behavior change until a later phase reads them. */
|
|
51
|
+
function addRuntimeColumns(db) {
|
|
52
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN intent TEXT;`);
|
|
53
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN pi_pid INTEGER;`);
|
|
54
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN window TEXT;`);
|
|
55
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN tmux_session TEXT;`);
|
|
56
|
+
}
|
|
57
|
+
/** v3 — DATA backfill (keystone, Phase 2). The runtime fields
|
|
58
|
+
* (`intent, pi_pid, window, tmux_session`) become authoritative in the row;
|
|
59
|
+
* copy each existing node's values out of its meta.json into the row columns
|
|
60
|
+
* once, so the version boundary loses no live state. `status` already mirrors
|
|
61
|
+
* the row, so it is not re-copied.
|
|
62
|
+
*
|
|
63
|
+
* LAYERING NOTE (explicitly sanctioned by the runtime-fix plan): a *data*
|
|
64
|
+
* migration must read meta.json, which db.ts normally would not. Reading it
|
|
65
|
+
* directly here — via paths.ts, a one-time, clearly-labeled boot-time data
|
|
66
|
+
* migration — is the deliberate choice over splitting the `user_version`
|
|
67
|
+
* counter across two modules. Idempotent and gated: it runs exactly once at the
|
|
68
|
+
* v2→v3 boundary. An UPDATE for a node with no row yet hits 0 rows (harmless). */
|
|
69
|
+
function backfillRuntime(db) {
|
|
70
|
+
const root = nodesRoot();
|
|
71
|
+
if (!existsSync(root))
|
|
72
|
+
return;
|
|
73
|
+
const upd = db.prepare('UPDATE nodes SET intent = ?, pi_pid = ?, "window" = ?, tmux_session = ? WHERE node_id = ?');
|
|
74
|
+
for (const id of readdirSync(root)) {
|
|
75
|
+
const p = nodeMetaPath(id);
|
|
76
|
+
if (!existsSync(p))
|
|
77
|
+
continue;
|
|
78
|
+
let meta;
|
|
79
|
+
try {
|
|
80
|
+
meta = JSON.parse(readFileSync(p, 'utf8'));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
continue; // a single unreadable meta never aborts the migration
|
|
84
|
+
}
|
|
85
|
+
upd.run(meta['intent'] ?? null, meta['pi_pid'] ?? null, meta['window'] ?? null, meta['tmux_session'] ?? null, id);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** v4 — edges referential integrity (IRREVERSIBLE table rebuild). Rebuild the
|
|
89
|
+
* `edges` table so `from_id`/`to_id` are FOREIGN KEYs to `nodes(node_id)` with
|
|
90
|
+
* `ON DELETE CASCADE`: after this, deleting a node can NEVER orphan an edge —
|
|
91
|
+
* the schema GCs them, so prune (and any future delete) doesn't have to. The
|
|
92
|
+
* cargo-cult `PRAGMA foreign_keys = ON` (openDb) finally enforces something.
|
|
93
|
+
*
|
|
94
|
+
* GOTCHA — pre-existing orphan edges. Nothing ever deleted a node before this
|
|
95
|
+
* phase, but manual dir removals / failed spawns can have left `edges` whose
|
|
96
|
+
* endpoint has no `nodes` row. Copying those into the FK-constrained table
|
|
97
|
+
* would violate the constraint, so the rebuild runs with `foreign_keys = OFF`
|
|
98
|
+
* (it MUST be toggled in autocommit — a no-op inside a txn) and the
|
|
99
|
+
* INSERT…SELECT FILTERS orphans: only edges whose BOTH endpoints have a row
|
|
100
|
+
* survive. `PRAGMA foreign_key_check` then confirms the rebuilt table is clean,
|
|
101
|
+
* and a row-count assertion guards that every NON-orphan edge is preserved.
|
|
102
|
+
*
|
|
103
|
+
* CRASH-SAFETY — the whole rebuild is one transaction. A throw ROLLs it back to
|
|
104
|
+
* the pre-v4 state (the `edges_new` scratch table and all copies vanish) and
|
|
105
|
+
* `user_version` stays at 3, so the next open re-runs v4 cleanly: no half-state.
|
|
106
|
+
* Even a crash between COMMIT and the version bump is safe — re-running v4 over
|
|
107
|
+
* an already-rebuilt (clean) `edges` is idempotent in effect. */
|
|
108
|
+
function edgesForeignKeyCascade(db) {
|
|
109
|
+
// Degenerate db with no `edges` table (a real db always has it — v1 created it
|
|
110
|
+
// — but a hand-seeded fixture may not). Nothing to rebuild; create the
|
|
111
|
+
// FK-shaped table fresh so the schema still converges, then we're done.
|
|
112
|
+
const hasEdges = db
|
|
113
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'edges'")
|
|
114
|
+
.get() !== undefined;
|
|
115
|
+
if (!hasEdges) {
|
|
116
|
+
db.exec(`
|
|
117
|
+
CREATE TABLE edges (
|
|
118
|
+
type TEXT NOT NULL,
|
|
119
|
+
from_id TEXT NOT NULL,
|
|
120
|
+
to_id TEXT NOT NULL,
|
|
121
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
122
|
+
created TEXT NOT NULL,
|
|
123
|
+
PRIMARY KEY (type, from_id, to_id),
|
|
124
|
+
FOREIGN KEY (from_id) REFERENCES nodes(node_id) ON DELETE CASCADE,
|
|
125
|
+
FOREIGN KEY (to_id) REFERENCES nodes(node_id) ON DELETE CASCADE
|
|
126
|
+
);
|
|
127
|
+
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(type, to_id);
|
|
128
|
+
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(type, from_id);
|
|
129
|
+
`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// FK enforcement must be toggled OUTSIDE a transaction (a no-op within one).
|
|
133
|
+
db.exec('PRAGMA foreign_keys = OFF;');
|
|
134
|
+
try {
|
|
135
|
+
// Non-orphan edges that MUST survive the rebuild (both endpoints present).
|
|
136
|
+
const expected = db
|
|
137
|
+
.prepare(`SELECT COUNT(*) AS n FROM edges
|
|
138
|
+
WHERE from_id IN (SELECT node_id FROM nodes)
|
|
139
|
+
AND to_id IN (SELECT node_id FROM nodes)`)
|
|
140
|
+
.get().n;
|
|
141
|
+
db.exec('BEGIN');
|
|
142
|
+
try {
|
|
143
|
+
db.exec(`
|
|
144
|
+
CREATE TABLE edges_new (
|
|
145
|
+
type TEXT NOT NULL,
|
|
146
|
+
from_id TEXT NOT NULL,
|
|
147
|
+
to_id TEXT NOT NULL,
|
|
148
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
149
|
+
created TEXT NOT NULL,
|
|
150
|
+
PRIMARY KEY (type, from_id, to_id),
|
|
151
|
+
FOREIGN KEY (from_id) REFERENCES nodes(node_id) ON DELETE CASCADE,
|
|
152
|
+
FOREIGN KEY (to_id) REFERENCES nodes(node_id) ON DELETE CASCADE
|
|
153
|
+
);
|
|
154
|
+
INSERT INTO edges_new (type, from_id, to_id, active, created)
|
|
155
|
+
SELECT type, from_id, to_id, active, created FROM edges
|
|
156
|
+
WHERE from_id IN (SELECT node_id FROM nodes)
|
|
157
|
+
AND to_id IN (SELECT node_id FROM nodes);
|
|
158
|
+
DROP TABLE edges;
|
|
159
|
+
ALTER TABLE edges_new RENAME TO edges;
|
|
160
|
+
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(type, to_id);
|
|
161
|
+
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(type, from_id);
|
|
162
|
+
`);
|
|
163
|
+
// Row-count assertion: every non-orphan edge preserved across the rebuild.
|
|
164
|
+
const after = db.prepare('SELECT COUNT(*) AS n FROM edges').get().n;
|
|
165
|
+
if (after !== expected) {
|
|
166
|
+
throw new Error(`edges FK rebuild lost rows: expected ${expected} non-orphan edge(s), got ${after}`);
|
|
167
|
+
}
|
|
168
|
+
// Belt-and-suspenders: the rebuilt table must hold no FK violations (the
|
|
169
|
+
// orphan filter above should guarantee this).
|
|
170
|
+
const violations = db.prepare('PRAGMA foreign_key_check').all();
|
|
171
|
+
if (violations.length > 0) {
|
|
172
|
+
throw new Error(`edges FK rebuild left ${violations.length} foreign-key violation(s)`);
|
|
173
|
+
}
|
|
174
|
+
db.exec('COMMIT');
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
db.exec('ROLLBACK');
|
|
178
|
+
throw e;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
db.exec('PRAGMA foreign_keys = ON;');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** v5 — additive runtime column `pane`: LOCATION's authoritative handle, the
|
|
186
|
+
* durable tmux `%pane_id` a node's pane is anchored on. Unlike the derived
|
|
187
|
+
* `window`/`tmux_session` cache (v2), the pane id survives a user
|
|
188
|
+
* `move-pane`/`join-pane`/`break-pane` and window renumbering, so a later step
|
|
189
|
+
* reconciles window/session FROM it and uses pane-existence for liveness.
|
|
190
|
+
* Defaults NULL — nothing reads it until the placement layer lands, so this
|
|
191
|
+
* observes no behavior change. Additive, forward-only. */
|
|
192
|
+
function addPaneColumn(db) {
|
|
193
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN pane TEXT;`);
|
|
194
|
+
}
|
|
195
|
+
/** v6 — the `focuses` table: durable, PLURAL on-screen viewports, one row per
|
|
196
|
+
* viewport (Q7 widens canvas.db from "topology" to "topology + focuses"). Each
|
|
197
|
+
* row is anchored on the durable tmux `%pane_id`; `session` is a derived cache
|
|
198
|
+
* reconciled from the pane; `node_id` is UNIQUE so a node occupies at most one
|
|
199
|
+
* focus (Q5). Additive, forward-only — nothing reads it as authority yet (Step 4
|
|
200
|
+
* populates it in lockstep with the legacy `focus.ptr` via a transitional
|
|
201
|
+
* dual-write; the switch to table-as-authority lands in Step 6). */
|
|
202
|
+
function addFocusesTable(db) {
|
|
203
|
+
db.exec(`
|
|
204
|
+
CREATE TABLE IF NOT EXISTS focuses (
|
|
205
|
+
focus_id TEXT PRIMARY KEY, -- stable internal id for the viewport
|
|
206
|
+
pane TEXT, -- the durable %pane_id realizing the focus
|
|
207
|
+
session TEXT, -- derived cache of the user session (reconciled from pane)
|
|
208
|
+
node_id TEXT NOT NULL UNIQUE -- the node shown; UNIQUE → a node occupies <=1 focus
|
|
209
|
+
);
|
|
210
|
+
`);
|
|
211
|
+
}
|
|
212
|
+
/** The ordered migration list. Index `i` is migration version `i + 1`; the db's
|
|
213
|
+
* `user_version` tracks how many have been applied. Append only. */
|
|
214
|
+
export const MIGRATIONS = [
|
|
215
|
+
/* v1 */ baselineSchema,
|
|
216
|
+
/* v2 */ addRuntimeColumns,
|
|
217
|
+
/* v3 */ backfillRuntime,
|
|
218
|
+
/* v4 */ edgesForeignKeyCascade,
|
|
219
|
+
/* v5 */ addPaneColumn,
|
|
220
|
+
/* v6 */ addFocusesTable,
|
|
221
|
+
];
|
|
222
|
+
/** Bring `db` up to the latest schema version. Reads `user_version`, runs each
|
|
223
|
+
* pending migration in order, and bumps `user_version` after each so the work
|
|
224
|
+
* is gated and idempotent: re-running is a no-op once `user_version` reaches
|
|
225
|
+
* `MIGRATIONS.length`. Forward-only. */
|
|
226
|
+
export function migrate(db) {
|
|
227
|
+
let v = db.prepare('PRAGMA user_version').get()
|
|
228
|
+
.user_version;
|
|
229
|
+
for (; v < MIGRATIONS.length; v++) {
|
|
230
|
+
MIGRATIONS[v](db);
|
|
231
|
+
// `user_version` takes no bound parameters; v is a controlled integer.
|
|
232
|
+
db.exec(`PRAGMA user_version = ${v + 1}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
35
235
|
const handles = new Map();
|
|
36
236
|
/** Open (or reuse) the canvas db at the current `CRTR_HOME`, initializing the
|
|
37
237
|
* schema and WAL on first open. Keyed by path so tests with distinct homes get
|
|
@@ -44,9 +244,11 @@ export function openDb() {
|
|
|
44
244
|
ensureHome();
|
|
45
245
|
const db = new DatabaseSync(path);
|
|
46
246
|
db.exec('PRAGMA journal_mode = WAL;');
|
|
247
|
+
// Load-bearing as of migration v4: the edges→nodes FK (ON DELETE CASCADE)
|
|
248
|
+
// needs this ON at the deleting connection so a node delete reaps its edges.
|
|
47
249
|
db.exec('PRAGMA foreign_keys = ON;');
|
|
48
250
|
db.exec('PRAGMA busy_timeout = 5000;');
|
|
49
|
-
db
|
|
251
|
+
migrate(db);
|
|
50
252
|
handles.set(path, db);
|
|
51
253
|
return db;
|
|
52
254
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FocusRow } from './types.js';
|
|
2
|
+
/** INSERT a viewport. Throws on UNIQUE(node_id) if `node_id` already occupies
|
|
3
|
+
* another focus (or on PK conflict if `focus_id` exists) — by design. */
|
|
4
|
+
export declare function openFocusRow(focus_id: string, pane: string | null, session: string | null, node_id: string): void;
|
|
5
|
+
/** Hot-swap a focus's occupant — single-statement UPDATE. Respects
|
|
6
|
+
* UNIQUE(node_id): if `node_id` already occupies ANOTHER focus this throws
|
|
7
|
+
* (correct — vacate-first is retargetFocus's job, Step 6, not this setter's). */
|
|
8
|
+
export declare function setFocusOccupant(focus_id: string, node_id: string): void;
|
|
9
|
+
/** Re-point a focus's durable pane + its derived session cache — for
|
|
10
|
+
* reconcileFocus / the daemon (Step 6). Single-statement UPDATE. */
|
|
11
|
+
export declare function setFocusPane(focus_id: string, pane: string | null, session: string | null): void;
|
|
12
|
+
/** DELETE a viewport. */
|
|
13
|
+
export declare function closeFocusRow(focus_id: string): void;
|
|
14
|
+
/** The focus a node occupies (≤1, UNIQUE node_id), or null. */
|
|
15
|
+
export declare function getFocusByNode(node_id: string): FocusRow | null;
|
|
16
|
+
/** The focus realized by a given pane (`%id`), or null. */
|
|
17
|
+
export declare function getFocusByPane(pane: string): FocusRow | null;
|
|
18
|
+
/** A focus by its stable id, or null. (Used by the transitional focus.ptr
|
|
19
|
+
* dual-write bridge to read back its single canonical row; removed in Step 8.) */
|
|
20
|
+
export declare function getFocusById(focus_id: string): FocusRow | null;
|
|
21
|
+
/** Every focus row, ordered by id. */
|
|
22
|
+
export declare function listFocuses(): FocusRow[];
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// focuses.ts — the FOCUS table data-access layer (canvas.db, migration v6).
|
|
2
|
+
//
|
|
3
|
+
// Part of the canvas data-access layer: Q7 widens canvas.db from "topology" to
|
|
4
|
+
// "topology + focuses", so the focus-row SQL lives here beside the node+edge
|
|
5
|
+
// model, never in the runtime layer. A FOCUS is one durable on-screen viewport
|
|
6
|
+
// bound to one node; the table is PLURAL (many focuses across windows/sessions),
|
|
7
|
+
// the generalization of the old single `focus.ptr`.
|
|
8
|
+
//
|
|
9
|
+
// placement.ts COMPOSES over these atomic setters/reads (the same way it calls
|
|
10
|
+
// setPresence) — it never runs raw focus SQL itself.
|
|
11
|
+
//
|
|
12
|
+
// Each setter is a single atomic statement. UNIQUE(node_id) upholds "a node
|
|
13
|
+
// occupies at most one focus" (Q5): a second focus row (or an occupant UPDATE)
|
|
14
|
+
// for an already-focused node throws — that is correct, the Q5 vacate-first
|
|
15
|
+
// orchestration is retargetFocus's job (Step 6), not these setters'.
|
|
16
|
+
import { openDb } from './db.js';
|
|
17
|
+
function focusFrom(r) {
|
|
18
|
+
return {
|
|
19
|
+
focus_id: r['focus_id'],
|
|
20
|
+
pane: r['pane'] ?? null,
|
|
21
|
+
session: r['session'] ?? null,
|
|
22
|
+
node_id: r['node_id'],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Atomic setters — each one a single-statement INSERT/UPDATE/DELETE.
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
/** INSERT a viewport. Throws on UNIQUE(node_id) if `node_id` already occupies
|
|
29
|
+
* another focus (or on PK conflict if `focus_id` exists) — by design. */
|
|
30
|
+
export function openFocusRow(focus_id, pane, session, node_id) {
|
|
31
|
+
openDb()
|
|
32
|
+
.prepare('INSERT INTO focuses (focus_id, pane, session, node_id) VALUES (?, ?, ?, ?)')
|
|
33
|
+
.run(focus_id, pane, session, node_id);
|
|
34
|
+
}
|
|
35
|
+
/** Hot-swap a focus's occupant — single-statement UPDATE. Respects
|
|
36
|
+
* UNIQUE(node_id): if `node_id` already occupies ANOTHER focus this throws
|
|
37
|
+
* (correct — vacate-first is retargetFocus's job, Step 6, not this setter's). */
|
|
38
|
+
export function setFocusOccupant(focus_id, node_id) {
|
|
39
|
+
openDb().prepare('UPDATE focuses SET node_id = ? WHERE focus_id = ?').run(node_id, focus_id);
|
|
40
|
+
}
|
|
41
|
+
/** Re-point a focus's durable pane + its derived session cache — for
|
|
42
|
+
* reconcileFocus / the daemon (Step 6). Single-statement UPDATE. */
|
|
43
|
+
export function setFocusPane(focus_id, pane, session) {
|
|
44
|
+
openDb()
|
|
45
|
+
.prepare('UPDATE focuses SET pane = ?, session = ? WHERE focus_id = ?')
|
|
46
|
+
.run(pane, session, focus_id);
|
|
47
|
+
}
|
|
48
|
+
/** DELETE a viewport. */
|
|
49
|
+
export function closeFocusRow(focus_id) {
|
|
50
|
+
openDb().prepare('DELETE FROM focuses WHERE focus_id = ?').run(focus_id);
|
|
51
|
+
}
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Reads.
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
/** The focus a node occupies (≤1, UNIQUE node_id), or null. */
|
|
56
|
+
export function getFocusByNode(node_id) {
|
|
57
|
+
const r = openDb()
|
|
58
|
+
.prepare('SELECT * FROM focuses WHERE node_id = ?')
|
|
59
|
+
.get(node_id);
|
|
60
|
+
return r ? focusFrom(r) : null;
|
|
61
|
+
}
|
|
62
|
+
/** The focus realized by a given pane (`%id`), or null. */
|
|
63
|
+
export function getFocusByPane(pane) {
|
|
64
|
+
const r = openDb()
|
|
65
|
+
.prepare('SELECT * FROM focuses WHERE pane = ?')
|
|
66
|
+
.get(pane);
|
|
67
|
+
return r ? focusFrom(r) : null;
|
|
68
|
+
}
|
|
69
|
+
/** A focus by its stable id, or null. (Used by the transitional focus.ptr
|
|
70
|
+
* dual-write bridge to read back its single canonical row; removed in Step 8.) */
|
|
71
|
+
export function getFocusById(focus_id) {
|
|
72
|
+
const r = openDb()
|
|
73
|
+
.prepare('SELECT * FROM focuses WHERE focus_id = ?')
|
|
74
|
+
.get(focus_id);
|
|
75
|
+
return r ? focusFrom(r) : null;
|
|
76
|
+
}
|
|
77
|
+
/** Every focus row, ordered by id. */
|
|
78
|
+
export function listFocuses() {
|
|
79
|
+
return openDb().prepare('SELECT * FROM focuses ORDER BY focus_id').all().map(focusFrom);
|
|
80
|
+
}
|