dsh-agent-lines 0.0.0-stage → 0.3.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.
package/lib/store.js ADDED
@@ -0,0 +1,421 @@
1
+ /**
2
+ * SQLite-backed line registry, message queue, and lease store.
3
+ *
4
+ * All methods are synchronous: node:sqlite's DatabaseSync is synchronous and
5
+ * every critical section is short. Callers in the DSH Host must not hold a
6
+ * transaction across an await.
7
+ */
8
+ import { chmodSync, existsSync, mkdirSync, statfsSync } from 'node:fs'
9
+ import { dirname, resolve } from 'node:path'
10
+ import { randomUUID } from 'node:crypto'
11
+ import { homedir } from 'node:os'
12
+ import { DatabaseSync } from 'node:sqlite'
13
+ import {
14
+ LineError, STATES, assertMessageSize, lineId, makeIdempotencyKey, newMessageId, parseTarget,
15
+ } from './protocol.js'
16
+
17
+ const NETWORK_FS = new Map([
18
+ [0x6969, 'NFS'],
19
+ [0xff534d42, 'CIFS'],
20
+ [0x517b, 'SMB'],
21
+ [0xfe534d42, 'SMB2'],
22
+ [0x65735546, 'FUSE'],
23
+ ])
24
+
25
+ const SCHEMA = `
26
+ PRAGMA journal_mode = WAL;
27
+ PRAGMA synchronous = NORMAL;
28
+ PRAGMA busy_timeout = 5000;
29
+ PRAGMA foreign_keys = ON;
30
+
31
+ CREATE TABLE IF NOT EXISTS lines (
32
+ line_id TEXT PRIMARY KEY,
33
+ agent_id TEXT NOT NULL,
34
+ label TEXT NOT NULL,
35
+ role TEXT,
36
+ created_by TEXT,
37
+ session_id TEXT NOT NULL,
38
+ instance_id TEXT,
39
+ status TEXT NOT NULL DEFAULT 'offline',
40
+ last_seen INTEGER NOT NULL DEFAULT 0,
41
+ created_at INTEGER NOT NULL,
42
+ UNIQUE(agent_id, label)
43
+ );
44
+
45
+ CREATE TABLE IF NOT EXISTS messages (
46
+ id TEXT PRIMARY KEY,
47
+ idempotency_key TEXT NOT NULL UNIQUE,
48
+ from_agent TEXT NOT NULL,
49
+ from_line TEXT NOT NULL,
50
+ from_session TEXT NOT NULL,
51
+ to_agent TEXT NOT NULL,
52
+ to_line TEXT NOT NULL,
53
+ body TEXT NOT NULL,
54
+ created_at INTEGER NOT NULL,
55
+ expires_at INTEGER,
56
+ reply_to TEXT,
57
+ root_message_id TEXT,
58
+ hop INTEGER NOT NULL DEFAULT 0,
59
+ no_reply INTEGER NOT NULL DEFAULT 0,
60
+ state TEXT NOT NULL,
61
+ claimed_by TEXT,
62
+ claimed_at INTEGER,
63
+ delivered_at INTEGER,
64
+ acked_at INTEGER,
65
+ attempts INTEGER NOT NULL DEFAULT 0,
66
+ next_attempt_at INTEGER NOT NULL DEFAULT 0,
67
+ last_error TEXT
68
+ );
69
+
70
+ CREATE INDEX IF NOT EXISTS messages_to_state ON messages(to_agent, to_line, state, next_attempt_at, created_at);
71
+ CREATE INDEX IF NOT EXISTS messages_from ON messages(from_line, created_at);
72
+
73
+ CREATE TABLE IF NOT EXISTS leases (
74
+ line_id TEXT PRIMARY KEY,
75
+ instance_id TEXT NOT NULL,
76
+ expires_at INTEGER NOT NULL,
77
+ updated_at INTEGER NOT NULL
78
+ );
79
+
80
+ CREATE TABLE IF NOT EXISTS claim_requests (
81
+ id TEXT PRIMARY KEY,
82
+ agent_id TEXT NOT NULL,
83
+ line_id TEXT NOT NULL,
84
+ session_id TEXT NOT NULL,
85
+ state TEXT NOT NULL,
86
+ requested_at INTEGER NOT NULL,
87
+ resolved_at INTEGER,
88
+ error TEXT
89
+ );
90
+
91
+ CREATE INDEX IF NOT EXISTS claim_requests_pending ON claim_requests(agent_id, state, requested_at);
92
+
93
+ CREATE TABLE IF NOT EXISTS manager_bindings (
94
+ agent_id TEXT PRIMARY KEY,
95
+ line_id TEXT NOT NULL,
96
+ session_id TEXT NOT NULL,
97
+ updated_at INTEGER NOT NULL
98
+ );
99
+
100
+ PRAGMA user_version = 6;
101
+ `
102
+
103
+ export function defaultDbPath() {
104
+ return resolve(homedir(), '.dsh', 'agent-lines', 'lines.db')
105
+ }
106
+
107
+ function expandHome(path) {
108
+ const text = String(path ?? '').trim()
109
+ if (text === '~') return homedir()
110
+ if (text.startsWith('~/')) return resolve(homedir(), text.slice(2))
111
+ return resolve(text)
112
+ }
113
+
114
+ /** Reject known network filesystems: SQLite locking is not reliable over them. */
115
+ export function assertLocalPath(dbPath) {
116
+ const dir = dirname(resolve(dbPath))
117
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
118
+ try {
119
+ const { type } = statfsSync(dir)
120
+ const name = NETWORK_FS.get(type)
121
+ if (name !== undefined) {
122
+ throw new LineError('LINE_NETWORK_FILESYSTEM', `dbPath is on ${name}; use a local filesystem`, { dbPath, fs: name })
123
+ }
124
+ } catch (error) {
125
+ if (error instanceof LineError) throw error
126
+ // statfs is unavailable on some platforms; the local-only default still applies.
127
+ }
128
+ }
129
+
130
+ export class LineStore {
131
+ constructor(dbPath = defaultDbPath()) {
132
+ this.dbPath = expandHome(dbPath)
133
+ assertLocalPath(this.dbPath)
134
+ const fresh = !existsSync(this.dbPath)
135
+ this.db = new DatabaseSync(this.dbPath)
136
+ try { chmodSync(this.dbPath, 0o600) } catch { /* best effort on platforms without POSIX modes */ }
137
+ this.db.exec(SCHEMA)
138
+ const columns = this.db.prepare('PRAGMA table_info(lines)').all().map(column => column.name)
139
+ if (!columns.includes('role')) this.db.exec('ALTER TABLE lines ADD COLUMN role TEXT')
140
+ if (!columns.includes('created_by')) this.db.exec('ALTER TABLE lines ADD COLUMN created_by TEXT')
141
+ const messageColumns = this.db.prepare('PRAGMA table_info(messages)').all().map(column => column.name)
142
+ if (!messageColumns.includes('root_message_id')) this.db.exec('ALTER TABLE messages ADD COLUMN root_message_id TEXT')
143
+ if (!messageColumns.includes('hop')) this.db.exec('ALTER TABLE messages ADD COLUMN hop INTEGER NOT NULL DEFAULT 0')
144
+ if (!messageColumns.includes('no_reply')) this.db.exec('ALTER TABLE messages ADD COLUMN no_reply INTEGER NOT NULL DEFAULT 0')
145
+ this.db.exec('PRAGMA user_version = 6')
146
+ this.prepared = new Map()
147
+ this.closed = false
148
+ this.fresh = fresh
149
+ }
150
+
151
+ close() {
152
+ if (this.closed) return
153
+ this.closed = true
154
+ this.db.close()
155
+ }
156
+
157
+ #stmt(sql) {
158
+ let stmt = this.prepared.get(sql)
159
+ if (stmt === undefined) {
160
+ stmt = this.db.prepare(sql)
161
+ this.prepared.set(sql, stmt)
162
+ }
163
+ return stmt
164
+ }
165
+
166
+ #tx(fn) {
167
+ this.db.exec('BEGIN IMMEDIATE')
168
+ try {
169
+ const value = fn()
170
+ this.db.exec('COMMIT')
171
+ return value
172
+ } catch (error) {
173
+ try { this.db.exec('ROLLBACK') } catch { /* transaction already gone */ }
174
+ throw error
175
+ }
176
+ }
177
+
178
+ /** Read a line row by exact address, or undefined. */
179
+ getLineRow(agentId, line) {
180
+ return this.#stmt('SELECT * FROM lines WHERE agent_id = ? AND label = ?').get(agentId, line)
181
+ }
182
+
183
+ /** Register a line and acquire its lease. Throws LINE_LEASE_HELD when another live instance owns it. */
184
+ acquire({ agentId, line, sessionId, instanceId, ttlMs, role = null, createdBy = null, now = Date.now() }) {
185
+ const id = lineId(agentId, line)
186
+ return this.#tx(() => {
187
+ const existing = this.#stmt('SELECT * FROM lines WHERE line_id = ?').get(id)
188
+ const lease = this.#stmt('SELECT * FROM leases WHERE line_id = ?').get(id)
189
+ if (lease !== undefined && lease.expires_at > now && lease.instance_id !== instanceId) {
190
+ throw new LineError('LINE_LEASE_HELD', `Line ${id} is held by another process`, {
191
+ line: id, holder: lease.instance_id, expiresAt: lease.expires_at,
192
+ })
193
+ }
194
+ if (existing === undefined) {
195
+ this.#stmt(`INSERT INTO lines(line_id, agent_id, label, role, created_by, session_id, instance_id, status, last_seen, created_at)
196
+ VALUES(?, ?, ?, ?, ?, ?, ?, 'online', ?, ?)`).run(id, agentId, line, role, createdBy, sessionId, instanceId, now, now)
197
+ } else {
198
+ this.#stmt(`UPDATE lines SET role = ?, created_by = ?, session_id = ?, instance_id = ?, status = 'online', last_seen = ? WHERE line_id = ?`)
199
+ .run(role, createdBy, sessionId, instanceId, now, id)
200
+ }
201
+ this.#stmt(`INSERT INTO leases(line_id, instance_id, expires_at, updated_at) VALUES(?, ?, ?, ?)
202
+ ON CONFLICT(line_id) DO UPDATE SET instance_id = excluded.instance_id, expires_at = excluded.expires_at, updated_at = excluded.updated_at`)
203
+ .run(id, instanceId, now + ttlMs, now)
204
+ return { lineId: id, instanceId, expiresAt: now + ttlMs }
205
+ })
206
+ }
207
+
208
+ /** Extend the lease. Throws LINE_LEASE_LOST when this instance no longer owns the line. */
209
+ heartbeat({ agentId, line, instanceId, ttlMs, now = Date.now() }) {
210
+ const id = lineId(agentId, line)
211
+ return this.#tx(() => {
212
+ const result = this.#stmt('UPDATE leases SET expires_at = ?, updated_at = ? WHERE line_id = ? AND instance_id = ?')
213
+ .run(now + ttlMs, now, id, instanceId)
214
+ if (result.changes !== 1) throw new LineError('LINE_LEASE_LOST', `Lease for ${id} is no longer held by this process`, { line: id })
215
+ this.#stmt(`UPDATE lines SET status = 'online', last_seen = ? WHERE line_id = ?`).run(now, id)
216
+ return now + ttlMs
217
+ })
218
+ }
219
+
220
+ /** Release a lease if this instance owns it; safe to call during shutdown. */
221
+ release({ agentId, line, instanceId, now = Date.now() }) {
222
+ const id = lineId(agentId, line)
223
+ this.#tx(() => {
224
+ this.#stmt('DELETE FROM leases WHERE line_id = ? AND instance_id = ?').run(id, instanceId)
225
+ this.#stmt(`UPDATE lines SET status = 'offline', last_seen = ?
226
+ WHERE line_id = ? AND NOT EXISTS (SELECT 1 FROM leases WHERE line_id = ? AND expires_at > ?)`)
227
+ .run(now, id, id, now)
228
+ })
229
+ }
230
+
231
+ /** Resolve a target to an existing line, or throw LINE_NOT_FOUND. */
232
+ resolveTarget(agentId, target) {
233
+ const parsed = parseTarget(target, agentId)
234
+ const row = this.#stmt('SELECT * FROM lines WHERE line_id = ?').get(parsed.lineId)
235
+ if (row === undefined) throw new LineError('LINE_NOT_FOUND', `No such line: ${parsed.lineId}`, { line: parsed.lineId })
236
+ return { ...parsed, row }
237
+ }
238
+
239
+ /** Enqueue a message from one registered line to another. */
240
+ send({ agentId, fromLine, to, text, idempotencyKey, replyTo, noReply = false, maxReplyDepth = 8, maxMessageBytes = 65536, ttlMs, now = Date.now() }) {
241
+ const from = this.#stmt('SELECT * FROM lines WHERE agent_id = ? AND label = ?').get(agentId, fromLine)
242
+ if (from === undefined) throw new LineError('LINE_NOT_FOUND', `Sender line is not registered: ${agentId}#${fromLine}`)
243
+ const target = this.resolveTarget(agentId, to)
244
+ const body = assertMessageSize(text, maxMessageBytes)
245
+ let parent
246
+ if (replyTo !== undefined && replyTo !== null && String(replyTo).trim() !== '') {
247
+ parent = this.#stmt('SELECT * FROM messages WHERE id = ?').get(String(replyTo).trim())
248
+ if (parent === undefined) throw new LineError('LINE_REPLY_TARGET_NOT_FOUND', `No message to reply to: ${replyTo}`)
249
+ if (Number(parent.no_reply) === 1) throw new LineError('LINE_NO_REPLY', `Message ${parent.id} is marked noReply`)
250
+ }
251
+ const rootMessageId = parent === undefined ? null : (parent.root_message_id ?? parent.id)
252
+ const hop = parent === undefined ? 0 : Number(parent.hop) + 1
253
+ if (hop > maxReplyDepth) {
254
+ throw new LineError('LINE_REPLY_DEPTH_EXCEEDED', `Reply depth ${hop} exceeds maxReplyDepth ${maxReplyDepth}`, { hop, maxReplyDepth, rootMessageId })
255
+ }
256
+ const key = makeIdempotencyKey({ fromLine, to: target.lineId, text: body, replyTo, key: idempotencyKey })
257
+ const existing = this.#stmt('SELECT * FROM messages WHERE idempotency_key = ?').get(key)
258
+ if (existing !== undefined) return { ...existing, deduplicated: true }
259
+ const id = newMessageId()
260
+ const expiresAt = ttlMs === undefined || ttlMs === null || ttlMs <= 0 ? null : now + ttlMs
261
+ this.#stmt(`INSERT INTO messages(
262
+ id, idempotency_key, from_agent, from_line, from_session, to_agent, to_line, body,
263
+ created_at, expires_at, reply_to, root_message_id, hop, no_reply, state, attempts, next_attempt_at
264
+ ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?)`)
265
+ .run(id, key, from.agent_id, from.label, from.session_id, target.agentId, target.line, body, now, expiresAt, replyTo ?? null, rootMessageId, hop, noReply === true ? 1 : 0, now)
266
+ return { ...this.#stmt('SELECT * FROM messages WHERE id = ?').get(id), deduplicated: false }
267
+ }
268
+
269
+ /** Requeue messages whose claim outlived its claim timeout. */
270
+ #requeueStale(toAgent, toLine, now, claimTimeoutMs) {
271
+ this.#stmt(`UPDATE messages SET state = 'queued', claimed_by = NULL, claimed_at = NULL, next_attempt_at = ?
272
+ WHERE to_agent = ? AND to_line = ? AND state = 'claimed' AND claimed_at < ?`)
273
+ .run(now, toAgent, toLine, now - claimTimeoutMs)
274
+ this.#stmt(`UPDATE messages SET state = 'expired'
275
+ WHERE to_agent = ? AND to_line = ? AND state IN ('queued', 'claimed') AND expires_at IS NOT NULL AND expires_at <= ?`)
276
+ .run(toAgent, toLine, now)
277
+ }
278
+
279
+ /** Atomically claim up to `limit` queued messages for a line owned by `instanceId`. */
280
+ claim({ agentId, line, instanceId, limit = 20, claimTimeoutMs = 60000, now = Date.now() }) {
281
+ const from = lineId(agentId, line)
282
+ return this.#tx(() => {
283
+ const lease = this.#stmt('SELECT * FROM leases WHERE line_id = ?').get(from)
284
+ if (lease === undefined || lease.instance_id !== instanceId || lease.expires_at <= now) {
285
+ throw new LineError('LINE_LEASE_LOST', `Lease for ${from} is not held by this process`, { line: from })
286
+ }
287
+ this.#requeueStale(agentId, line, now, claimTimeoutMs)
288
+ const rows = this.#stmt(`SELECT * FROM messages
289
+ WHERE to_agent = ? AND to_line = ? AND state = 'queued' AND next_attempt_at <= ?
290
+ AND (expires_at IS NULL OR expires_at > ?)
291
+ ORDER BY created_at ASC LIMIT ?`).all(agentId, line, now, now, limit)
292
+ for (const row of rows) {
293
+ this.#stmt(`UPDATE messages SET state = 'claimed', claimed_by = ?, claimed_at = ?, attempts = attempts + 1
294
+ WHERE id = ? AND state = 'queued'`).run(instanceId, now, row.id)
295
+ }
296
+ return rows.map(row => ({ ...row, state: 'claimed', claimed_by: instanceId, claimed_at: now, attempts: row.attempts + 1 }))
297
+ })
298
+ }
299
+
300
+ markDelivered(id, instanceId, now = Date.now()) {
301
+ const result = this.#stmt(`UPDATE messages SET state = 'delivered', delivered_at = ?, last_error = NULL
302
+ WHERE id = ? AND state = 'claimed' AND claimed_by = ?`).run(now, id, instanceId)
303
+ if (result.changes !== 1) throw new LineError('LINE_DELIVERY_STATE', `Message ${id} is not claimed by this instance`)
304
+ }
305
+
306
+ markError(id, instanceId, error, { nextAttemptAt, maxAttempts = 8, now = Date.now() } = {}) {
307
+ return this.#tx(() => {
308
+ const row = this.#stmt('SELECT * FROM messages WHERE id = ? AND claimed_by = ?').get(id, instanceId)
309
+ if (row === undefined) return undefined
310
+ const terminal = row.attempts >= maxAttempts
311
+ this.#stmt(`UPDATE messages SET state = ?, claimed_by = NULL, claimed_at = NULL, next_attempt_at = ?, last_error = ?
312
+ WHERE id = ?`).run(terminal ? 'failed' : 'queued', terminal ? now : (nextAttemptAt ?? now), String(error).slice(0, 2000), id)
313
+ return { terminal, attempts: row.attempts }
314
+ })
315
+ }
316
+
317
+ markAck({ agentId, line, messageId, now = Date.now() }) {
318
+ const to = lineId(agentId, line)
319
+ const row = this.#stmt('SELECT * FROM messages WHERE id = ? AND to_agent = ? AND to_line = ?').get(messageId, agentId, line)
320
+ if (row === undefined) throw new LineError('LINE_MESSAGE_NOT_FOUND', `No message ${messageId} for ${to}`)
321
+ if (row.state === 'acked') return { ...row, alreadyAcked: true }
322
+ if (row.state !== 'delivered') throw new LineError('LINE_NOT_DELIVERED', `Message ${messageId} is ${row.state}; only delivered messages can be acked`)
323
+ this.#stmt(`UPDATE messages SET state = 'acked', acked_at = ? WHERE id = ?`).run(now, messageId)
324
+ return { ...this.#stmt('SELECT * FROM messages WHERE id = ?').get(messageId), alreadyAcked: false }
325
+ }
326
+
327
+ /** List every message addressed to a line, regardless of state (for CLI tail/doctor). */
328
+ messagesForLine({ agentId, line, limit = 100 }) {
329
+ return this.#stmt(`SELECT * FROM messages WHERE to_agent = ? AND to_line = ? ORDER BY created_at ASC LIMIT ?`).all(agentId, line, limit)
330
+ }
331
+
332
+ /** List delivered/acked messages addressed to a line. */
333
+ inbox({ agentId, line, limit = 20, includeAcked = false }) {
334
+ const states = includeAcked ? `state IN ('delivered', 'acked')` : `state = 'delivered'`
335
+ return this.#stmt(`SELECT * FROM messages WHERE to_agent = ? AND to_line = ? AND ${states} ORDER BY created_at ASC LIMIT ?`).all(agentId, line, limit)
336
+ }
337
+
338
+ /** Persist the manager Session chosen by a successful claim. */
339
+ setManagerBinding({ agentId, lineId: targetLine, sessionId, now = Date.now() }) {
340
+ this.#stmt(`INSERT INTO manager_bindings(agent_id, line_id, session_id, updated_at) VALUES(?, ?, ?, ?)
341
+ ON CONFLICT(agent_id) DO UPDATE SET line_id = excluded.line_id, session_id = excluded.session_id, updated_at = excluded.updated_at`)
342
+ .run(agentId, targetLine, sessionId, now)
343
+ }
344
+
345
+ /** Read the persisted manager Session, if any. */
346
+ getManagerBinding(agentId) {
347
+ return this.#stmt('SELECT * FROM manager_bindings WHERE agent_id = ?').get(agentId)
348
+ }
349
+
350
+ /** Enqueue a manager claim request. A pending request for the same session/line is reused. */
351
+ requestClaim({ agentId, lineId: targetLine, sessionId, now = Date.now() }) {
352
+ const existing = this.#stmt(`SELECT * FROM claim_requests
353
+ WHERE agent_id = ? AND line_id = ? AND session_id = ? AND state = 'pending'
354
+ ORDER BY requested_at DESC LIMIT 1`).get(agentId, targetLine, sessionId)
355
+ if (existing !== undefined) return { ...existing, reused: true }
356
+ const id = 'claim_' + randomUUID().replaceAll('-', '')
357
+ this.#stmt(`INSERT INTO claim_requests(id, agent_id, line_id, session_id, state, requested_at)
358
+ VALUES(?, ?, ?, ?, 'pending', ?)`).run(id, agentId, targetLine, sessionId, now)
359
+ return { ...this.#stmt('SELECT * FROM claim_requests WHERE id = ?').get(id), reused: false }
360
+ }
361
+
362
+ /** Pending manager claims for one agent. */
363
+ pendingClaims(agentId, limit = 20) {
364
+ return this.#stmt(`SELECT * FROM claim_requests WHERE agent_id = ? AND state = 'pending'
365
+ ORDER BY requested_at ASC LIMIT ?`).all(agentId, limit)
366
+ }
367
+
368
+ /** Resolve a claim request. */
369
+ resolveClaim(id, state, error = null, now = Date.now()) {
370
+ this.#stmt('UPDATE claim_requests SET state = ?, resolved_at = ?, error = ? WHERE id = ?')
371
+ .run(state, now, error, id)
372
+ }
373
+
374
+ /** Number of registered lines for an agent (used by maxLines). */
375
+ countLines(agentId) {
376
+ const row = this.#stmt('SELECT COUNT(*) AS n FROM lines WHERE agent_id = ?').get(agentId)
377
+ return Number(row.n)
378
+ }
379
+
380
+ /** All lines with unread counts and live lease state. */
381
+ listLines(now = Date.now()) {
382
+ const rows = this.#stmt(`SELECT l.*, (SELECT COUNT(*) FROM messages m
383
+ WHERE m.to_agent = l.agent_id AND m.to_line = l.label AND m.state IN ('queued', 'claimed', 'delivered')) AS unread
384
+ FROM lines l ORDER BY l.agent_id, l.label`).all()
385
+ return rows.map(row => {
386
+ const lease = this.#stmt('SELECT * FROM leases WHERE line_id = ?').get(row.line_id)
387
+ return {
388
+ ...row,
389
+ unread: Number(row.unread),
390
+ lease: lease === undefined ? null : {
391
+ instanceId: lease.instance_id,
392
+ expiresAt: lease.expires_at,
393
+ live: lease.expires_at > now,
394
+ },
395
+ }
396
+ })
397
+ }
398
+
399
+ /** One line with unread count. */
400
+ getLine(agentId, line, now = Date.now()) {
401
+ const id = lineId(agentId, line)
402
+ const row = this.#stmt('SELECT * FROM lines WHERE line_id = ?').get(id)
403
+ if (row === undefined) return undefined
404
+ const lease = this.#stmt('SELECT * FROM leases WHERE line_id = ?').get(id)
405
+ const unread = this.#stmt(`SELECT COUNT(*) AS n FROM messages WHERE to_agent = ? AND to_line = ? AND state IN ('queued', 'claimed', 'delivered')`).get(agentId, line)
406
+ return {
407
+ ...row,
408
+ unread: Number(unread.n),
409
+ lease: lease === undefined ? null : { instanceId: lease.instance_id, expiresAt: lease.expires_at, live: lease.expires_at > now },
410
+ }
411
+ }
412
+
413
+ counts(now = Date.now()) {
414
+ const states = this.#stmt('SELECT state, COUNT(*) AS n FROM messages GROUP BY state ORDER BY state').all()
415
+ const leases = this.#stmt('SELECT COUNT(*) AS n FROM leases WHERE expires_at > ?').get(now)
416
+ const claims = this.#stmt("SELECT COUNT(*) AS n FROM claim_requests WHERE state = 'pending'").get()
417
+ return { states, liveLeases: Number(leases.n), pendingClaims: Number(claims.n) }
418
+ }
419
+ }
420
+
421
+ export { expandHome }