@martintrojer/murmur 0.1.4 → 0.2.0

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.
@@ -11,248 +11,463 @@ function stateDir() {
11
11
  return process.env.MURMUR_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "murmur");
12
12
  }
13
13
  function dbPath() {
14
- return join(stateDir(), "events.db");
14
+ return join(stateDir(), "state.db");
15
15
  }
16
16
 
17
17
  // src/identity.ts
18
- function loadIdentity() {
19
- const path = join2(stateDir(), "identity.json");
20
- return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
18
+ function identityPath() {
19
+ return join2(stateDir(), "identity.json");
21
20
  }
22
- function ensureIdentity(displayName = hostname()) {
23
- const existing = loadIdentity();
24
- if (existing) return existing;
25
- const identity = { host_id: randomUUID(), display_name: displayName };
26
- mkdirSync(stateDir(), { recursive: true });
27
- writeFileSync(join2(stateDir(), "identity.json"), `${JSON.stringify(identity, null, 2)}
28
- `);
21
+ var cache = null;
22
+ function loadIdentity() {
23
+ const path = identityPath();
24
+ if (cache?.path === path) return cache.identity;
25
+ const identity = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
26
+ cache = { path, identity };
29
27
  return identity;
30
28
  }
31
29
 
32
30
  // src/store.ts
33
- import { rmSync } from "fs";
31
+ import { randomUUID as randomUUID2 } from "crypto";
32
+ import { mkdirSync as mkdirSync2, rmSync } from "fs";
33
+ import { dirname } from "path";
34
34
  import Database from "better-sqlite3";
35
- var DEFAULT_RETENTION_MS = 7 * 864e5;
36
- var STORE_VERSION = 2;
37
- function resetIfStale(path) {
38
- let salvaged = [];
35
+
36
+ // src/ids.ts
37
+ function asSessionId(raw) {
38
+ return raw;
39
+ }
40
+ function asWindowId(raw) {
41
+ return raw;
42
+ }
43
+ function asPaneId(raw) {
44
+ return raw;
45
+ }
46
+
47
+ // src/mux.ts
48
+ import { execFileSync } from "child_process";
49
+ function pidAlive(pid) {
50
+ try {
51
+ process.kill(pid, 0);
52
+ return true;
53
+ } catch (error) {
54
+ return error.code !== "ESRCH";
55
+ }
56
+ }
57
+
58
+ // src/version.ts
59
+ import { createRequire } from "module";
60
+ function readVersion() {
61
+ const require2 = createRequire(import.meta.url);
62
+ for (const candidate of ["../package.json", "../../package.json"]) {
63
+ try {
64
+ return require2(candidate).version;
65
+ } catch {
66
+ }
67
+ }
68
+ throw new Error("cannot locate package.json to read the murmur version");
69
+ }
70
+ var MURMUR_VERSION = readVersion();
71
+
72
+ // src/view.ts
73
+ var RENDER_PRIORITY = [
74
+ "crashed",
75
+ "blocked",
76
+ "done",
77
+ "running",
78
+ "idle"
79
+ ];
80
+ var ORDER = new Map(RENDER_PRIORITY.map((state, index) => [state, index]));
81
+
82
+ // src/store.ts
83
+ var SCHEMA_USER_VERSION = 3;
84
+ var SCHEMA = `
85
+ CREATE TABLE agents (
86
+ agent_id TEXT NOT NULL PRIMARY KEY,
87
+ pane TEXT NOT NULL UNIQUE,
88
+ owner_pid INTEGER NOT NULL CHECK (owner_pid > 0),
89
+ activity TEXT NOT NULL CHECK (activity IN ('running', 'stopped')),
90
+ session TEXT NOT NULL,
91
+ window TEXT NOT NULL,
92
+ session_name TEXT,
93
+ window_name TEXT,
94
+ agent_name TEXT,
95
+ pi_session TEXT,
96
+ workstream TEXT,
97
+ role TEXT,
98
+ cli TEXT NOT NULL,
99
+ driver TEXT NOT NULL CHECK (driver IN ('human', 'orchestrated')),
100
+ claimed_at INTEGER NOT NULL,
101
+ updated_at INTEGER NOT NULL
102
+ ) STRICT;
103
+
104
+ CREATE TABLE attention (
105
+ pane TEXT NOT NULL,
106
+ kind TEXT NOT NULL CHECK (kind IN ('done', 'blocked', 'crashed')),
107
+ message TEXT NOT NULL,
108
+ source TEXT NOT NULL,
109
+ session TEXT NOT NULL,
110
+ window TEXT NOT NULL,
111
+ session_name TEXT,
112
+ window_name TEXT,
113
+ requested_at INTEGER NOT NULL,
114
+ PRIMARY KEY (pane, kind)
115
+ ) STRICT;
116
+
117
+ CREATE TABLE peers (
118
+ name TEXT NOT NULL PRIMARY KEY,
119
+ target TEXT NOT NULL,
120
+ host_id TEXT,
121
+ display_name TEXT,
122
+ snapshot TEXT,
123
+ snapshot_at INTEGER,
124
+ fetched_at INTEGER,
125
+ last_attempt_at INTEGER,
126
+ last_error TEXT,
127
+ murmur_version TEXT,
128
+ snapshot_version INTEGER
129
+ ) STRICT;
130
+ `;
131
+ function salvagePeers(path) {
39
132
  try {
40
133
  const existing = new Database(path, { fileMustExist: true });
41
- const version = existing.pragma("user_version", { simple: true }) ?? 0;
42
- if (version === STORE_VERSION) {
134
+ try {
135
+ const version = existing.pragma("user_version", { simple: true }) ?? 0;
136
+ if (version === SCHEMA_USER_VERSION) return [];
137
+ return existing.prepare("SELECT name, target FROM peers").all();
138
+ } catch {
139
+ return [];
140
+ } finally {
43
141
  existing.close();
44
- return salvaged;
45
142
  }
143
+ } catch {
144
+ return [];
145
+ }
146
+ }
147
+ function needsReset(path) {
148
+ try {
149
+ const existing = new Database(path, { fileMustExist: true });
46
150
  try {
47
- salvaged = existing.prepare("SELECT name, target, host_id, display_name FROM peers").all();
48
- } catch {
151
+ return (existing.pragma("user_version", { simple: true }) ?? 0) !== SCHEMA_USER_VERSION;
152
+ } finally {
153
+ existing.close();
49
154
  }
50
- existing.close();
51
155
  } catch {
52
- return salvaged;
156
+ return false;
53
157
  }
54
- for (const suffix of ["", "-wal", "-shm"]) rmSync(`${path}${suffix}`, { force: true });
55
- return salvaged;
56
158
  }
57
- function eventValues(event) {
58
- return [
59
- event.host_id,
60
- event.seq,
61
- event.ts,
62
- event.agent_id,
63
- event.session,
64
- event.window,
65
- event.pane,
66
- event.session_name,
67
- event.window_name,
68
- event.agent_name,
69
- event.pi_session,
70
- event.workstream,
71
- event.role,
72
- event.cli,
73
- event.driver,
74
- event.kind,
75
- event.state,
76
- event.message,
77
- event.pid,
78
- Number(event.synthetic),
79
- event.reason,
80
- JSON.stringify(event.extra)
81
- ];
159
+ function toAttention(row) {
160
+ return {
161
+ kind: row.kind,
162
+ message: row.message,
163
+ source: row.source,
164
+ requested_at: row.requested_at
165
+ };
82
166
  }
83
- function toEvent(row) {
167
+ function toAgent(row) {
84
168
  return {
85
- ...row,
169
+ agent_id: row.agent_id,
170
+ activity: row.activity,
171
+ agent_name: row.agent_name,
172
+ pi_session: row.pi_session,
173
+ workstream: row.workstream,
174
+ role: row.role,
175
+ cli: row.cli,
86
176
  driver: row.driver,
87
- synthetic: row.synthetic === 1,
88
- extra: JSON.parse(row.extra)
177
+ claimed_at: row.claimed_at,
178
+ updated_at: row.updated_at
89
179
  };
90
180
  }
181
+ var PRIORITY = new Map(RENDER_PRIORITY.map((kind, index) => [kind, index]));
182
+ function attentionOrder(left, right) {
183
+ return (PRIORITY.get(left.kind) ?? 99) - (PRIORITY.get(right.kind) ?? 99);
184
+ }
91
185
  function openStore() {
92
- const identity = ensureIdentity();
93
186
  const path = dbPath();
94
- const salvagedPeers = resetIfStale(path);
187
+ mkdirSync2(dirname(path), { recursive: true });
188
+ const salvaged = salvagePeers(path);
189
+ if (needsReset(path)) {
190
+ for (const suffix of ["", "-wal", "-shm"]) rmSync(`${path}${suffix}`, { force: true });
191
+ }
95
192
  const database = new Database(path);
96
193
  database.pragma("journal_mode = WAL");
97
- database.pragma(`user_version = ${STORE_VERSION}`);
98
- database.exec(`
99
- CREATE TABLE IF NOT EXISTS events (
100
- host_id TEXT NOT NULL,
101
- seq INTEGER NOT NULL,
102
- ts INTEGER NOT NULL,
103
- agent_id TEXT NOT NULL,
104
- session TEXT NOT NULL,
105
- window TEXT NOT NULL,
106
- pane TEXT NOT NULL,
107
- session_name TEXT,
108
- window_name TEXT,
109
- agent_name TEXT,
110
- pi_session TEXT,
111
- workstream TEXT,
112
- role TEXT,
113
- cli TEXT,
114
- driver TEXT,
115
- kind TEXT NOT NULL,
116
- state TEXT NOT NULL,
117
- message TEXT NOT NULL,
118
- pid INTEGER,
119
- synthetic INTEGER NOT NULL,
120
- reason TEXT NOT NULL,
121
- extra TEXT NOT NULL,
122
- PRIMARY KEY (host_id, seq)
123
- );
124
- CREATE INDEX IF NOT EXISTS events_agent_seq ON events (agent_id, seq);
125
- CREATE TABLE IF NOT EXISTS peers (
126
- name TEXT PRIMARY KEY,
127
- target TEXT NOT NULL,
128
- host_id TEXT,
129
- display_name TEXT,
130
- watermark INTEGER NOT NULL,
131
- fetched_at INTEGER,
132
- -- When a jump last proved this peer's tmux was not answering. Reader
133
- -- state, not an event: this node cannot author facts about another
134
- -- node's agents, and a jump is a local observation, not something the
135
- -- peer said. Cleared by the next successful collect.
136
- tmux_down_at INTEGER
137
- );
138
- `);
139
- try {
140
- database.exec("ALTER TABLE peers ADD COLUMN tmux_down_at INTEGER");
141
- } catch {
142
- }
143
- if (salvagedPeers.length > 0) {
144
- const restore = database.prepare(
145
- `INSERT OR IGNORE INTO peers (name, target, host_id, display_name, watermark, fetched_at)
146
- VALUES (?, ?, ?, ?, 0, NULL)`
147
- );
148
- for (const peer of salvagedPeers) {
149
- restore.run(peer.name, peer.target, peer.host_id ?? null, peer.display_name ?? null);
150
- }
194
+ database.pragma("busy_timeout = 5000");
195
+ const version = database.pragma("user_version", { simple: true }) ?? 0;
196
+ if (version !== SCHEMA_USER_VERSION) {
197
+ database.exec(SCHEMA);
198
+ database.pragma(`user_version = ${SCHEMA_USER_VERSION}`);
199
+ const restore = database.prepare("INSERT OR IGNORE INTO peers (name, target) VALUES (?, ?)");
200
+ for (const peer of salvaged) restore.run(peer.name, peer.target);
151
201
  }
152
- const eventColumns = `
153
- host_id, seq, ts, agent_id, session, window, pane,
154
- session_name, window_name, agent_name, pi_session,
155
- workstream, role, cli, driver, kind, state, message, pid,
156
- synthetic, reason, extra`;
157
- const eventPlaceholders = new Array(22).fill("?").join(", ");
158
- const insertEvent = database.prepare(
159
- `INSERT INTO events (${eventColumns}) VALUES (${eventPlaceholders})`
160
- );
161
- const ingestEvent = database.prepare(
162
- `INSERT OR IGNORE INTO events (${eventColumns}) VALUES (${eventPlaceholders})`
202
+ const selectAgentByPane = database.prepare("SELECT * FROM agents WHERE pane = ?");
203
+ const insertAgent = database.prepare(`
204
+ INSERT INTO agents (agent_id, pane, owner_pid, activity, session, window,
205
+ session_name, window_name, agent_name, pi_session,
206
+ workstream, role, cli, driver, claimed_at, updated_at)
207
+ VALUES (@agent_id, @pane, @owner_pid, @activity, @session, @window,
208
+ @session_name, @window_name, @agent_name, @pi_session,
209
+ @workstream, @role, @cli, @driver, @claimed_at, @updated_at)
210
+ `);
211
+ const retainAgent = database.prepare(`
212
+ UPDATE agents
213
+ SET session = @session, window = @window, session_name = @session_name,
214
+ window_name = @window_name, agent_name = @agent_name,
215
+ pi_session = @pi_session, workstream = @workstream, role = @role,
216
+ cli = @cli, driver = @driver, updated_at = @updated_at
217
+ WHERE agent_id = @agent_id
218
+ `);
219
+ const deleteAgentByPane = database.prepare("DELETE FROM agents WHERE pane = ?");
220
+ const deleteAttentionForPane = database.prepare("DELETE FROM attention WHERE pane = ?");
221
+ const updateActivity = database.prepare(`
222
+ UPDATE agents
223
+ SET activity = @activity, session = @session, window = @window,
224
+ session_name = @session_name, window_name = @window_name,
225
+ updated_at = @updated_at
226
+ WHERE agent_id = @agent_id AND owner_pid = @owner_pid
227
+ `);
228
+ const deleteAgentOwned = database.prepare(
229
+ "DELETE FROM agents WHERE agent_id = ? AND owner_pid = ?"
163
230
  );
164
- const selectMaxSeq = database.prepare(
165
- "SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE host_id = ?"
231
+ const upsertAttention = database.prepare(`
232
+ INSERT INTO attention (pane, kind, message, source, session, window,
233
+ session_name, window_name, requested_at)
234
+ VALUES (@pane, @kind, @message, @source, @session, @window,
235
+ @session_name, @window_name, @requested_at)
236
+ ON CONFLICT (pane, kind) DO UPDATE SET
237
+ message = excluded.message,
238
+ source = excluded.source,
239
+ session = excluded.session,
240
+ window = excluded.window,
241
+ session_name = excluded.session_name,
242
+ window_name = excluded.window_name
243
+ `);
244
+ const selectAgents = database.prepare("SELECT * FROM agents");
245
+ const selectAttention = database.prepare("SELECT * FROM attention");
246
+ const setActivityByPane = database.prepare(
247
+ "UPDATE agents SET activity = ?, updated_at = ? WHERE pane = ?"
166
248
  );
167
- const append = database.transaction((event) => {
168
- const row = selectMaxSeq.get(identity.host_id);
169
- const stored = {
170
- ...event,
171
- host_id: identity.host_id,
172
- seq: row.seq + 1,
173
- ts: event.ts ?? Date.now(),
174
- session_name: event.session_name ?? null,
175
- window_name: event.window_name ?? null,
176
- agent_name: event.agent_name ?? null,
177
- pi_session: event.pi_session ?? null
249
+ const claimAgent = database.transaction((claim) => {
250
+ const now = claim.now ?? Date.now();
251
+ const isAlive = claim.isAlive ?? pidAlive;
252
+ const { location, meta, owner_pid } = claim;
253
+ const incumbent = selectAgentByPane.get(location.pane);
254
+ const values = {
255
+ pane: location.pane,
256
+ owner_pid,
257
+ session: location.session,
258
+ window: location.window,
259
+ session_name: location.session_name,
260
+ window_name: location.window_name,
261
+ agent_name: meta.agent_name,
262
+ pi_session: meta.pi_session,
263
+ workstream: meta.workstream,
264
+ role: meta.role,
265
+ cli: meta.cli,
266
+ driver: meta.driver,
267
+ updated_at: now
178
268
  };
179
- insertEvent.run(...eventValues(stored));
180
- return stored;
181
- });
182
- const ingest = database.transaction((events) => {
183
- let inserted = 0;
184
- for (const event of events) inserted += ingestEvent.run(...eventValues(event)).changes;
185
- return inserted;
269
+ if (!incumbent) {
270
+ const agentId2 = randomUUID2();
271
+ insertAgent.run({ ...values, agent_id: agentId2, activity: "stopped", claimed_at: now });
272
+ return { outcome: "claimed", agent_id: agentId2 };
273
+ }
274
+ if (incumbent.owner_pid === owner_pid) {
275
+ retainAgent.run({ ...values, agent_id: incumbent.agent_id });
276
+ return { outcome: "retained", agent_id: incumbent.agent_id };
277
+ }
278
+ if (isAlive(incumbent.owner_pid)) {
279
+ return { outcome: "refused", held_by_pid: incumbent.owner_pid };
280
+ }
281
+ deleteAgentByPane.run(location.pane);
282
+ deleteAttentionForPane.run(location.pane);
283
+ const agentId = randomUUID2();
284
+ insertAgent.run({ ...values, agent_id: agentId, activity: "stopped", claimed_at: now });
285
+ return { outcome: "replaced", agent_id: agentId, previous_agent_id: incumbent.agent_id };
286
+ }).immediate;
287
+ const reconcileLocal = database.transaction((world) => {
288
+ const summary = { crashed: [], removed: [], attention_removed: [] };
289
+ if (world.panes === null) return summary;
290
+ const live = world.panes;
291
+ const isAlive = world.isAlive ?? pidAlive;
292
+ const now = world.now ?? Date.now();
293
+ const alreadyCrashed = new Set(
294
+ selectAttention.all().filter((row) => row.kind === "crashed").map((row) => row.pane)
295
+ );
296
+ for (const row of selectAgents.all()) {
297
+ const pane = asPaneId(row.pane);
298
+ if (!live.has(pane)) {
299
+ deleteAgentByPane.run(row.pane);
300
+ deleteAttentionForPane.run(row.pane);
301
+ summary.removed.push(pane);
302
+ continue;
303
+ }
304
+ if (isAlive(row.owner_pid)) continue;
305
+ if (row.activity === "running") {
306
+ setActivityByPane.run("stopped", now, row.pane);
307
+ upsertAttention.run({
308
+ pane: row.pane,
309
+ kind: "crashed",
310
+ message: "",
311
+ source: "murmur",
312
+ session: row.session,
313
+ window: row.window,
314
+ session_name: row.session_name,
315
+ window_name: row.window_name,
316
+ requested_at: now
317
+ });
318
+ summary.crashed.push(pane);
319
+ } else if (!alreadyCrashed.has(row.pane)) {
320
+ deleteAgentByPane.run(row.pane);
321
+ summary.removed.push(pane);
322
+ }
323
+ }
324
+ for (const row of selectAttention.all()) {
325
+ const pane = asPaneId(row.pane);
326
+ if (live.has(pane)) continue;
327
+ deleteAttentionForPane.run(row.pane);
328
+ if (!summary.attention_removed.includes(pane)) summary.attention_removed.push(pane);
329
+ }
330
+ return summary;
331
+ }).immediate;
332
+ const readLocalPanes = database.transaction(() => {
333
+ const agents = selectAgents.all();
334
+ const attention = selectAttention.all();
335
+ const panes = /* @__PURE__ */ new Map();
336
+ const locate = (row) => {
337
+ const existing = panes.get(row.pane);
338
+ if (existing) return existing;
339
+ const created = {
340
+ pane: asPaneId(row.pane),
341
+ session: asSessionId(row.session),
342
+ window: asWindowId(row.window),
343
+ session_name: row.session_name,
344
+ window_name: row.window_name,
345
+ agent: null,
346
+ attention: []
347
+ };
348
+ panes.set(row.pane, created);
349
+ return created;
350
+ };
351
+ for (const row of agents) locate(row).agent = toAgent(row);
352
+ for (const row of attention) locate(row).attention.push(toAttention(row));
353
+ for (const pane of panes.values()) pane.attention.sort(attentionOrder);
354
+ return [...panes.values()].sort((left, right) => left.pane.localeCompare(right.pane));
186
355
  });
356
+ function peerRecord(row) {
357
+ let snapshot = null;
358
+ if (row.snapshot !== null) {
359
+ try {
360
+ snapshot = JSON.parse(row.snapshot);
361
+ } catch {
362
+ snapshot = null;
363
+ }
364
+ }
365
+ return {
366
+ name: row.name,
367
+ target: row.target,
368
+ host_id: row.host_id,
369
+ display_name: row.display_name,
370
+ snapshot,
371
+ snapshot_at: row.snapshot_at,
372
+ fetched_at: row.fetched_at,
373
+ last_attempt_at: row.last_attempt_at,
374
+ last_error: row.last_error,
375
+ murmur_version: row.murmur_version,
376
+ snapshot_version: row.snapshot_version
377
+ };
378
+ }
187
379
  return {
188
- append,
189
- ingest,
190
- eventsSince(hostId, seq) {
191
- const rows = database.prepare("SELECT * FROM events WHERE host_id = ? AND seq > ? ORDER BY seq").all(hostId, seq);
192
- return rows.map(toEvent);
193
- },
194
- allEvents() {
195
- const rows = database.prepare("SELECT * FROM events ORDER BY ts, host_id, seq").all();
196
- return rows.map(toEvent);
380
+ claimAgent,
381
+ reconcileLocal,
382
+ setActivity(update) {
383
+ return updateActivity.run({
384
+ activity: update.activity,
385
+ session: update.location.session,
386
+ window: update.location.window,
387
+ session_name: update.location.session_name,
388
+ window_name: update.location.window_name,
389
+ updated_at: update.now ?? Date.now(),
390
+ agent_id: update.agent_id,
391
+ owner_pid: update.owner_pid
392
+ }).changes === 1;
197
393
  },
198
- latestForAgent(hostId, agentId) {
199
- const row = database.prepare(
200
- `SELECT * FROM events
201
- WHERE host_id = ? AND agent_id = ?
202
- ORDER BY seq DESC LIMIT 1`
203
- ).get(hostId, agentId);
204
- return row ? toEvent(row) : null;
394
+ releaseAgent(release) {
395
+ return deleteAgentOwned.run(release.agent_id, release.owner_pid).changes === 1;
205
396
  },
206
- maxSeq(hostId) {
207
- return selectMaxSeq.get(hostId).seq;
397
+ requestAttention(request) {
398
+ upsertAttention.run({
399
+ pane: request.location.pane,
400
+ kind: request.kind,
401
+ message: request.message,
402
+ source: request.source,
403
+ session: request.location.session,
404
+ window: request.location.window,
405
+ session_name: request.location.session_name,
406
+ window_name: request.location.window_name,
407
+ requested_at: request.now ?? Date.now()
408
+ });
208
409
  },
209
- prune(horizonMs = Number(process.env.MURMUR_RETENTION_MS ?? DEFAULT_RETENTION_MS)) {
210
- return database.prepare(`
211
- DELETE FROM events
212
- WHERE ts < ?
213
- AND (host_id, seq) NOT IN (
214
- SELECT host_id, seq FROM (
215
- SELECT host_id, seq,
216
- ROW_NUMBER() OVER (PARTITION BY agent_id ORDER BY ts DESC, seq DESC) rn
217
- FROM events
218
- ) WHERE rn = 1
219
- )
220
- `).run(Date.now() - horizonMs).changes;
221
- },
222
- peers() {
223
- return database.prepare("SELECT * FROM peers ORDER BY name").all();
410
+ acknowledgePane(pane) {
411
+ return deleteAttentionForPane.run(pane).changes;
224
412
  },
225
- forgetAgent(agentId) {
226
- return database.prepare("DELETE FROM events WHERE agent_id = ?").run(agentId).changes;
413
+ localPanes() {
414
+ return readLocalPanes();
227
415
  },
228
- forgetHost(hostId) {
229
- return database.prepare("DELETE FROM events WHERE host_id = ?").run(hostId).changes;
416
+ buildLocalSnapshot(identity, world) {
417
+ reconcileLocal(world);
418
+ return {
419
+ murmur_snapshot: 1,
420
+ host_id: identity.host_id,
421
+ display_name: identity.display_name,
422
+ murmur_version: MURMUR_VERSION,
423
+ generated_at: world.now ?? Date.now(),
424
+ // Rule 3: a pane with no agent and no attention must not be published.
425
+ // A no-op against today's `readLocalPanes`, which builds a pane entry
426
+ // only from a row and so cannot produce an empty one -- kept because the
427
+ // rule belongs to the DOCUMENT, and the validator rejects such an entry
428
+ // outright. Without it, one narrowing of the local read would make this
429
+ // node reachable-but-broken on every peer that collects it, and the
430
+ // symptom would show up on the other machines.
431
+ panes: readLocalPanes().filter((pane) => pane.agent !== null || pane.attention.length > 0)
432
+ };
230
433
  },
231
- upsertPeer(peer) {
232
- const current = database.prepare("SELECT * FROM peers WHERE name = ?").get(peer.name);
233
- database.prepare(`
234
- INSERT INTO peers (name, target, host_id, display_name, watermark, fetched_at, tmux_down_at)
235
- VALUES (?, ?, ?, ?, ?, ?, ?)
236
- ON CONFLICT(name) DO UPDATE SET
237
- target = excluded.target,
238
- host_id = excluded.host_id,
239
- display_name = excluded.display_name,
240
- watermark = excluded.watermark,
241
- fetched_at = excluded.fetched_at,
242
- tmux_down_at = excluded.tmux_down_at
243
- `).run(
244
- peer.name,
245
- peer.target,
246
- peer.host_id !== void 0 ? peer.host_id : current?.host_id ?? null,
247
- peer.display_name !== void 0 ? peer.display_name : current?.display_name ?? null,
248
- peer.watermark !== void 0 ? peer.watermark : current?.watermark ?? 0,
249
- peer.fetched_at !== void 0 ? peer.fetched_at : current?.fetched_at ?? null,
250
- peer.tmux_down_at !== void 0 ? peer.tmux_down_at : current?.tmux_down_at ?? null
434
+ peers() {
435
+ return database.prepare("SELECT * FROM peers ORDER BY name").all().map(
436
+ peerRecord
251
437
  );
252
438
  },
439
+ addPeer(name, target) {
440
+ database.prepare(
441
+ `INSERT INTO peers (name, target) VALUES (?, ?)
442
+ ON CONFLICT(name) DO UPDATE SET target = excluded.target`
443
+ ).run(name, target);
444
+ },
253
445
  removePeer(name) {
254
446
  return database.prepare("DELETE FROM peers WHERE name = ?").run(name).changes > 0;
255
447
  },
448
+ replacePeerSnapshot(name, fetch) {
449
+ if (!fetch.ok) {
450
+ database.prepare("UPDATE peers SET last_attempt_at = ?, last_error = ? WHERE name = ?").run(fetch.at, fetch.error, name);
451
+ return;
452
+ }
453
+ database.prepare(
454
+ `UPDATE peers
455
+ SET snapshot = ?, snapshot_at = ?, fetched_at = ?, last_attempt_at = ?,
456
+ last_error = NULL, host_id = ?, display_name = ?,
457
+ murmur_version = ?, snapshot_version = ?
458
+ WHERE name = ?`
459
+ ).run(
460
+ JSON.stringify(fetch.snapshot),
461
+ fetch.snapshot.generated_at,
462
+ fetch.at,
463
+ fetch.at,
464
+ fetch.snapshot.host_id,
465
+ fetch.snapshot.display_name,
466
+ fetch.snapshot.murmur_version,
467
+ fetch.snapshot.murmur_snapshot,
468
+ name
469
+ );
470
+ },
256
471
  close() {
257
472
  database.close();
258
473
  }