@crouton-kit/crouter 0.3.13 → 0.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/dist/build-root.d.ts +8 -0
  2. package/dist/build-root.js +30 -0
  3. package/dist/builtin-personas/design/base.md +3 -7
  4. package/dist/builtin-personas/design/orchestrator.md +4 -3
  5. package/dist/builtin-personas/developer/base.md +3 -7
  6. package/dist/builtin-personas/developer/orchestrator.md +5 -4
  7. package/dist/builtin-personas/explore/base.md +3 -7
  8. package/dist/builtin-personas/explore/orchestrator.md +1 -5
  9. package/dist/builtin-personas/general/base.md +2 -4
  10. package/dist/builtin-personas/general/orchestrator.md +2 -4
  11. package/dist/builtin-personas/lifecycle/resident.md +2 -0
  12. package/dist/builtin-personas/lifecycle/terminal.md +6 -0
  13. package/dist/builtin-personas/orchestration-kernel.md +42 -3
  14. package/dist/builtin-personas/plan/base.md +3 -5
  15. package/dist/builtin-personas/plan/orchestrator.md +5 -4
  16. package/dist/builtin-personas/plan/reviewers/architecture-fit/base.md +9 -0
  17. package/dist/builtin-personas/plan/reviewers/code-smells/base.md +9 -0
  18. package/dist/builtin-personas/plan/reviewers/pattern-consistency/base.md +9 -0
  19. package/dist/builtin-personas/plan/reviewers/requirements-coverage/base.md +9 -0
  20. package/dist/builtin-personas/plan/reviewers/security/base.md +9 -0
  21. package/dist/builtin-personas/review/base.md +3 -5
  22. package/dist/builtin-personas/review/orchestrator.md +2 -6
  23. package/dist/builtin-personas/runtime-base.md +3 -19
  24. package/dist/builtin-personas/spec/base.md +3 -5
  25. package/dist/builtin-personas/spec/orchestrator.md +4 -3
  26. package/dist/builtin-personas/spine/has-manager.md +10 -0
  27. package/dist/builtin-personas/spine/no-manager.md +2 -0
  28. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +96 -0
  29. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +49 -0
  30. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +49 -0
  31. package/dist/builtin-skills/skills/planning/SKILL.md +1 -1
  32. package/dist/builtin-skills/skills/spec/SKILL.md +2 -2
  33. package/dist/cli.js +6 -29
  34. package/dist/commands/__tests__/human.test.js +73 -2
  35. package/dist/commands/attention.js +76 -7
  36. package/dist/commands/canvas-prune.d.ts +2 -0
  37. package/dist/commands/canvas-prune.js +66 -0
  38. package/dist/commands/canvas.js +5 -8
  39. package/dist/commands/chord.d.ts +2 -0
  40. package/dist/commands/chord.js +143 -0
  41. package/dist/commands/daemon.js +8 -5
  42. package/dist/commands/dashboard.js +2 -0
  43. package/dist/commands/human/prompts.js +28 -27
  44. package/dist/commands/human/queue.d.ts +1 -0
  45. package/dist/commands/human/queue.js +105 -2
  46. package/dist/commands/human/shared.d.ts +28 -18
  47. package/dist/commands/human/shared.js +53 -60
  48. package/dist/commands/human.js +6 -14
  49. package/dist/commands/node.d.ts +11 -0
  50. package/dist/commands/node.js +381 -87
  51. package/dist/commands/pkg/market-inspect.js +6 -4
  52. package/dist/commands/pkg/market-manage.js +10 -6
  53. package/dist/commands/pkg/market.js +2 -4
  54. package/dist/commands/pkg/plugin-inspect.js +6 -4
  55. package/dist/commands/pkg/plugin-manage.js +12 -7
  56. package/dist/commands/pkg/plugin.js +2 -4
  57. package/dist/commands/pkg.js +0 -4
  58. package/dist/commands/push.js +178 -15
  59. package/dist/commands/revive.js +5 -3
  60. package/dist/commands/skill/author.js +6 -4
  61. package/dist/commands/skill/find.js +8 -5
  62. package/dist/commands/skill/read.js +2 -0
  63. package/dist/commands/skill/state.js +6 -4
  64. package/dist/commands/skill.js +0 -6
  65. package/dist/commands/sys/config.js +21 -7
  66. package/dist/commands/sys/doctor.js +2 -0
  67. package/dist/commands/sys/update.js +4 -0
  68. package/dist/commands/sys.js +0 -6
  69. package/dist/commands/tmux-spread.d.ts +2 -0
  70. package/dist/commands/tmux-spread.js +130 -0
  71. package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
  72. package/dist/core/__tests__/child-followup.test.d.ts +1 -0
  73. package/dist/core/__tests__/child-followup.test.js +83 -0
  74. package/dist/core/__tests__/close.test.d.ts +1 -0
  75. package/dist/core/__tests__/close.test.js +148 -0
  76. package/dist/core/__tests__/context-intro.test.d.ts +1 -0
  77. package/dist/core/__tests__/context-intro.test.js +196 -0
  78. package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
  79. package/dist/core/__tests__/daemon-boot.test.js +93 -0
  80. package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
  81. package/dist/core/__tests__/daemon-liveness.test.js +223 -0
  82. package/dist/core/__tests__/focuses.test.d.ts +1 -0
  83. package/dist/core/__tests__/focuses.test.js +259 -0
  84. package/dist/core/__tests__/fork.test.d.ts +1 -0
  85. package/dist/core/__tests__/fork.test.js +91 -0
  86. package/dist/core/__tests__/home-session.test.d.ts +1 -0
  87. package/dist/core/__tests__/home-session.test.js +153 -0
  88. package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
  89. package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
  90. package/dist/core/__tests__/keystone.test.d.ts +1 -0
  91. package/dist/core/__tests__/keystone.test.js +185 -0
  92. package/dist/core/__tests__/kickoff.test.d.ts +1 -0
  93. package/dist/core/__tests__/kickoff.test.js +89 -0
  94. package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
  95. package/dist/core/__tests__/lifecycle.test.js +178 -0
  96. package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
  97. package/dist/core/__tests__/listing-completeness.test.js +31 -0
  98. package/dist/core/__tests__/memory.test.d.ts +1 -0
  99. package/dist/core/__tests__/memory.test.js +152 -0
  100. package/dist/core/__tests__/migration.test.d.ts +1 -0
  101. package/dist/core/__tests__/migration.test.js +238 -0
  102. package/dist/core/__tests__/pane-column.test.d.ts +1 -0
  103. package/dist/core/__tests__/pane-column.test.js +153 -0
  104. package/dist/core/__tests__/passive-subscription.test.d.ts +1 -0
  105. package/dist/core/__tests__/passive-subscription.test.js +164 -0
  106. package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
  107. package/dist/core/__tests__/persona-compose.test.js +53 -0
  108. package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
  109. package/dist/core/__tests__/persona-subkind.test.js +62 -0
  110. package/dist/core/__tests__/persona.test.d.ts +1 -0
  111. package/dist/core/__tests__/persona.test.js +107 -0
  112. package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
  113. package/dist/core/__tests__/placement-focus.test.js +244 -0
  114. package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
  115. package/dist/core/__tests__/placement-reconcile.test.js +212 -0
  116. package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
  117. package/dist/core/__tests__/placement-revive.test.js +238 -0
  118. package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
  119. package/dist/core/__tests__/placement-teardown.test.js +183 -0
  120. package/dist/core/__tests__/prune.test.d.ts +1 -0
  121. package/dist/core/__tests__/prune.test.js +116 -0
  122. package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
  123. package/dist/core/__tests__/push-final-guard.test.js +71 -0
  124. package/dist/core/__tests__/relaunch.test.d.ts +1 -0
  125. package/dist/core/__tests__/relaunch.test.js +328 -0
  126. package/dist/core/__tests__/reset.test.js +26 -7
  127. package/dist/core/__tests__/revive.test.d.ts +1 -0
  128. package/dist/core/__tests__/revive.test.js +217 -0
  129. package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
  130. package/dist/core/__tests__/spawn-root.test.js +73 -0
  131. package/dist/core/__tests__/steer-note.test.d.ts +1 -0
  132. package/dist/core/__tests__/steer-note.test.js +39 -0
  133. package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
  134. package/dist/core/__tests__/stop-guard.test.js +82 -0
  135. package/dist/core/__tests__/subcommand-tier.test.d.ts +1 -0
  136. package/dist/core/__tests__/subcommand-tier.test.js +99 -0
  137. package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
  138. package/dist/core/__tests__/tmux-surface.test.js +106 -0
  139. package/dist/core/__tests__/unknown-path.test.js +8 -2
  140. package/dist/core/canvas/attention.d.ts +10 -0
  141. package/dist/core/canvas/attention.js +40 -0
  142. package/dist/core/canvas/canvas.d.ts +66 -7
  143. package/dist/core/canvas/canvas.js +209 -21
  144. package/dist/core/canvas/db.d.ts +8 -0
  145. package/dist/core/canvas/db.js +206 -4
  146. package/dist/core/canvas/focuses.d.ts +22 -0
  147. package/dist/core/canvas/focuses.js +80 -0
  148. package/dist/core/canvas/index.d.ts +3 -0
  149. package/dist/core/canvas/index.js +3 -0
  150. package/dist/core/canvas/labels.d.ts +27 -0
  151. package/dist/core/canvas/labels.js +36 -0
  152. package/dist/core/canvas/paths.d.ts +4 -0
  153. package/dist/core/canvas/paths.js +6 -0
  154. package/dist/core/canvas/render.js +25 -10
  155. package/dist/core/canvas/telemetry.d.ts +14 -0
  156. package/dist/core/canvas/telemetry.js +35 -0
  157. package/dist/core/canvas/types.d.ts +115 -12
  158. package/dist/core/command.d.ts +25 -1
  159. package/dist/core/command.js +48 -7
  160. package/dist/core/config.js +36 -2
  161. package/dist/core/feed/feed.js +14 -12
  162. package/dist/core/feed/inbox.d.ts +3 -1
  163. package/dist/core/feed/inbox.js +45 -5
  164. package/dist/core/feed/passive.d.ts +17 -0
  165. package/dist/core/feed/passive.js +92 -0
  166. package/dist/core/help.d.ts +59 -13
  167. package/dist/core/help.js +73 -28
  168. package/dist/core/personas/index.d.ts +1 -1
  169. package/dist/core/personas/index.js +1 -1
  170. package/dist/core/personas/loader.d.ts +40 -1
  171. package/dist/core/personas/loader.js +63 -1
  172. package/dist/core/personas/resolve.d.ts +13 -6
  173. package/dist/core/personas/resolve.js +46 -34
  174. package/dist/core/runtime/bearings.d.ts +20 -0
  175. package/dist/core/runtime/bearings.js +92 -0
  176. package/dist/core/runtime/close.d.ts +14 -0
  177. package/dist/core/runtime/close.js +151 -0
  178. package/dist/core/runtime/demote.d.ts +14 -0
  179. package/dist/core/runtime/demote.js +120 -0
  180. package/dist/core/runtime/front-door.js +1 -1
  181. package/dist/core/runtime/kickoff.d.ts +32 -6
  182. package/dist/core/runtime/kickoff.js +111 -37
  183. package/dist/core/runtime/launch.d.ts +29 -6
  184. package/dist/core/runtime/launch.js +85 -13
  185. package/dist/core/runtime/lifecycle.d.ts +13 -0
  186. package/dist/core/runtime/lifecycle.js +86 -0
  187. package/dist/core/runtime/memory.d.ts +43 -0
  188. package/dist/core/runtime/memory.js +165 -0
  189. package/dist/core/runtime/naming.d.ts +22 -0
  190. package/dist/core/runtime/naming.js +166 -0
  191. package/dist/core/runtime/nodes.d.ts +32 -1
  192. package/dist/core/runtime/nodes.js +60 -10
  193. package/dist/core/runtime/persona.d.ts +25 -0
  194. package/dist/core/runtime/persona.js +139 -0
  195. package/dist/core/runtime/placement.d.ts +287 -0
  196. package/dist/core/runtime/placement.js +663 -0
  197. package/dist/core/runtime/presence.d.ts +7 -32
  198. package/dist/core/runtime/presence.js +90 -110
  199. package/dist/core/runtime/promote.d.ts +18 -7
  200. package/dist/core/runtime/promote.js +70 -65
  201. package/dist/core/runtime/reset.d.ts +47 -4
  202. package/dist/core/runtime/reset.js +223 -52
  203. package/dist/core/runtime/revive.d.ts +26 -2
  204. package/dist/core/runtime/revive.js +166 -39
  205. package/dist/core/runtime/roadmap.d.ts +5 -4
  206. package/dist/core/runtime/roadmap.js +9 -16
  207. package/dist/core/runtime/spawn.d.ts +20 -5
  208. package/dist/core/runtime/spawn.js +169 -44
  209. package/dist/core/runtime/stop-guard.d.ts +1 -1
  210. package/dist/core/runtime/stop-guard.js +18 -8
  211. package/dist/core/runtime/tmux.d.ts +106 -21
  212. package/dist/core/runtime/tmux.js +249 -45
  213. package/dist/core/spawn.js +15 -0
  214. package/dist/daemon/crtrd.d.ts +12 -1
  215. package/dist/daemon/crtrd.js +152 -34
  216. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
  217. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
  218. package/dist/pi-extensions/canvas-commands.d.ts +34 -0
  219. package/dist/pi-extensions/canvas-commands.js +103 -0
  220. package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
  221. package/dist/pi-extensions/canvas-context-intro.js +164 -0
  222. package/dist/pi-extensions/canvas-goal-capture.d.ts +21 -0
  223. package/dist/pi-extensions/canvas-goal-capture.js +67 -0
  224. package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
  225. package/dist/pi-extensions/canvas-nav.d.ts +12 -4
  226. package/dist/pi-extensions/canvas-nav.js +586 -262
  227. package/dist/pi-extensions/canvas-passive-context.d.ts +32 -0
  228. package/dist/pi-extensions/canvas-passive-context.js +114 -0
  229. package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
  230. package/dist/pi-extensions/canvas-stophook.js +344 -228
  231. package/dist/types.d.ts +28 -0
  232. package/dist/types.js +16 -0
  233. package/package.json +1 -1
@@ -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 (derived from meta)
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, status, cwd, parent, created)
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, status=excluded.status, cwd=excluded.cwd,
36
- parent=excluded.parent`)
37
- .run(meta.node_id, meta.name, meta.kind, meta.mode, meta.lifecycle, meta.status, meta.cwd, meta.parent ?? null, meta.created);
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, write meta.json, index the row. */
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
- upsertRow(meta);
61
- return meta;
132
+ seedRow(meta);
133
+ return getNode(meta.node_id);
62
134
  }
63
- /** The canonical node record (from meta.json), or null if unknown. */
135
+ /** The canonical node record: durable identity (meta.json) authoritative
136
+ * runtime (the row). Null if unknown. */
64
137
  export function getNode(nodeId) {
65
- return readMeta(nodeId);
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
- /** Merge a patch into a node's meta.json and re-index its row. */
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 next;
174
+ return getNode(nodeId);
83
175
  }
84
- /** Convenience for the most common mutation. */
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
- updateNode(nodeId, { status });
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
- if (meta.parent)
208
- recordSpawn(meta.node_id, meta.parent);
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
  }
@@ -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. */
@@ -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 { canvasDbPath, ensureHome } from './paths.js';
9
- const SCHEMA = `
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.exec(SCHEMA);
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
+ }
@@ -1,4 +1,7 @@
1
1
  export * from './types.js';
2
+ export * from './labels.js';
2
3
  export * from './paths.js';
3
4
  export * from './canvas.js';
5
+ export * from './focuses.js';
6
+ export * from './telemetry.js';
4
7
  export { openDb, closeDb } from './db.js';