@kontextmind/kxm 0.7.71 → 0.7.73

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.
@@ -1,6 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
  import type { DatabaseSync } from "./sqlite.ts";
3
- import type { AgentIdentity, MessageRecord } from "./protocol.ts";
3
+ import type { AgentIdentity, LeaseRecord, MessageRecord, RuntimePresenceRecord, StoredSyncEvent } from "./protocol.ts";
4
4
  import type { ContextItem } from "./context.ts";
5
5
  import type { WorkflowJournalEntry, WorkflowRun } from "./workflow.ts";
6
6
  import {
@@ -9,7 +9,7 @@ import {
9
9
  type DatabaseSchemaSpec,
10
10
  } from "./database.ts";
11
11
 
12
- export const HUB_STORE_SCHEMA_VERSION = 3;
12
+ export const HUB_STORE_SCHEMA_VERSION = 5;
13
13
 
14
14
  export const HUB_STORE_TABLES: Readonly<Record<string, readonly string[]>> = Object.freeze({
15
15
  agents: Object.freeze(["id", "record"]),
@@ -19,9 +19,14 @@ export const HUB_STORE_TABLES: Readonly<Record<string, readonly string[]>> = Obj
19
19
  workflow_runs: Object.freeze(["id", "definition_id", "delivery_id", "record"]),
20
20
  workflow_journal: Object.freeze(["id", "run_id", "category", "area", "record"]),
21
21
  context_items: Object.freeze(["id", "project", "kind", "record"]),
22
+ leases: Object.freeze(["resource", "holder_agent_id", "fencing_token", "expires_at", "record"]),
23
+ sync_events: Object.freeze([
24
+ "project_id", "run_id", "sequence", "hub_project", "home_runtime_id", "event_type", "content_hash", "received_at", "record",
25
+ ]),
26
+ runtime_presence: Object.freeze(["runtime_id", "hub_project", "host", "heartbeat", "record"]),
22
27
  });
23
28
 
24
- export const HUB_STORE_SCHEMA_V3 = `
29
+ export const HUB_STORE_SCHEMA_V5 = `
25
30
  CREATE TABLE IF NOT EXISTS agents (
26
31
  id TEXT PRIMARY KEY,
27
32
  record TEXT NOT NULL
@@ -70,10 +75,38 @@ export const HUB_STORE_SCHEMA_V3 = `
70
75
  record TEXT NOT NULL
71
76
  ) STRICT;
72
77
  CREATE INDEX IF NOT EXISTS context_items_project ON context_items(project);
78
+ CREATE TABLE IF NOT EXISTS leases (
79
+ resource TEXT PRIMARY KEY,
80
+ holder_agent_id TEXT NOT NULL,
81
+ fencing_token INTEGER NOT NULL,
82
+ expires_at TEXT NOT NULL,
83
+ record TEXT NOT NULL
84
+ ) STRICT;
85
+ CREATE TABLE IF NOT EXISTS sync_events (
86
+ project_id TEXT NOT NULL,
87
+ run_id TEXT NOT NULL,
88
+ sequence INTEGER NOT NULL,
89
+ hub_project TEXT NOT NULL,
90
+ home_runtime_id TEXT NOT NULL,
91
+ event_type TEXT NOT NULL,
92
+ content_hash TEXT NOT NULL,
93
+ received_at TEXT NOT NULL,
94
+ record TEXT NOT NULL,
95
+ PRIMARY KEY (project_id, run_id, sequence)
96
+ ) STRICT;
97
+ CREATE INDEX IF NOT EXISTS sync_events_hub_project ON sync_events(hub_project, run_id, sequence);
98
+ CREATE TABLE IF NOT EXISTS runtime_presence (
99
+ runtime_id TEXT NOT NULL,
100
+ hub_project TEXT NOT NULL,
101
+ host TEXT,
102
+ heartbeat TEXT NOT NULL,
103
+ record TEXT NOT NULL,
104
+ PRIMARY KEY (hub_project, runtime_id)
105
+ ) STRICT;
73
106
  `;
74
107
 
75
108
  export const HUB_STORE_SCHEMA_SPEC: DatabaseSchemaSpec = Object.freeze({
76
- schema: HUB_STORE_SCHEMA_V3,
109
+ schema: HUB_STORE_SCHEMA_V5,
77
110
  version: HUB_STORE_SCHEMA_VERSION,
78
111
  tables: HUB_STORE_TABLES,
79
112
  });
@@ -85,6 +118,77 @@ export interface StoredAgent extends AgentIdentity {
85
118
  key: string;
86
119
  }
87
120
 
121
+ /** Why a lease call did not get the resource. `held` is live contention,
122
+ * `superseded` is a token the hub has already moved past, `expired` is a lease
123
+ * whose hub-clocked deadline has passed, and `missing` is no lease at all. */
124
+ export type LeaseRefusal = "held" | "superseded" | "expired" | "missing";
125
+
126
+ export type LeaseOutcome =
127
+ | { ok: true; lease: LeaseRecord; renewed: boolean }
128
+ | { ok: false; reason: LeaseRefusal; lease?: LeaseRecord };
129
+
130
+ export interface LeaseAcquireInput {
131
+ resource: string;
132
+ project: string;
133
+ name: string;
134
+ holderAgentId: string;
135
+ holderAgentName: string;
136
+ ttlMs: number;
137
+ nowMs: number;
138
+ }
139
+
140
+ /** How one pushed sync event landed. `conflict` is a security alert: the
141
+ * same `{projectId, runId, sequence}` already holds different bytes, or the
142
+ * run already belongs to another project or home Runtime. */
143
+ export type SyncIngestOutcome =
144
+ | { outcome: "accepted" | "duplicate" }
145
+ | { outcome: "conflict"; reason: "sequence_reused" | "project_mismatch" | "home_runtime_mismatch"; existingHash?: string };
146
+
147
+ function syncKey(projectId: string, runId: string, sequence: number): string {
148
+ return `${projectId}\u0000${runId}\u0000${sequence}`;
149
+ }
150
+
151
+ function parseLeaseRecord(record: string): LeaseRecord | undefined {
152
+ try {
153
+ return JSON.parse(record) as LeaseRecord;
154
+ } catch {
155
+ return undefined;
156
+ }
157
+ }
158
+
159
+ /** A deadline the hub cannot read is an expired deadline. The alternative — a
160
+ * lease nobody can ever take over — is the worse failure. */
161
+ function leaseHasLapsed(lease: LeaseRecord, nowMs: number): boolean {
162
+ const expiresAtMs = Date.parse(lease.expiresAt);
163
+ return !Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs;
164
+ }
165
+
166
+ type SyncEventSqlRow = {
167
+ project_id: string;
168
+ run_id: string;
169
+ sequence: number;
170
+ hub_project: string;
171
+ home_runtime_id: string;
172
+ event_type: string;
173
+ content_hash: string;
174
+ received_at: string;
175
+ record: string;
176
+ };
177
+
178
+ function syncEventFromRow(row: SyncEventSqlRow): StoredSyncEvent {
179
+ return {
180
+ projectId: row.project_id,
181
+ runId: row.run_id,
182
+ sequence: row.sequence,
183
+ hubProject: row.hub_project,
184
+ homeRuntimeId: row.home_runtime_id,
185
+ eventType: row.event_type,
186
+ contentHash: row.content_hash,
187
+ receivedAt: row.received_at,
188
+ bytes: row.record,
189
+ };
190
+ }
191
+
88
192
  class MessageMap extends Map<string, MessageRecord> {
89
193
  private readonly store: MeshStore;
90
194
 
@@ -124,6 +228,10 @@ export class MeshStore {
124
228
  readonly workflowRuns = new Map<string, WorkflowRun>();
125
229
  readonly journal = new Map<string, WorkflowJournalEntry>();
126
230
  readonly contextItems = new Map<string, ContextItem>();
231
+ readonly leases = new Map<string, LeaseRecord>();
232
+ /** Memory-only stores keep sync state here; a database store reads SQLite. */
233
+ private readonly syncEvents = new Map<string, StoredSyncEvent>();
234
+ private readonly runtimePresence = new Map<string, RuntimePresenceRecord>();
127
235
  readonly path?: string;
128
236
  private readonly database?: DatabaseSync;
129
237
  private readonly agentSequences = new Map<string, number>();
@@ -331,6 +439,278 @@ export class MeshStore {
331
439
  return result;
332
440
  }
333
441
 
442
+ /** Read the lease over one already project-scoped resource. */
443
+ getLease(resource: string): LeaseRecord | undefined {
444
+ if (!this.database) return this.leases.get(resource);
445
+ const row = this.database.prepare("SELECT record FROM leases WHERE resource = ?").get(resource) as { record: string } | undefined;
446
+ if (!row) {
447
+ this.leases.delete(resource);
448
+ return undefined;
449
+ }
450
+ const lease = parseLeaseRecord(row.record);
451
+ if (lease) this.leases.set(resource, lease);
452
+ return lease;
453
+ }
454
+
455
+ /**
456
+ * Take or extend the lease over `resource`, deciding the whole outcome inside
457
+ * one transaction so two writers cannot both read "free" and both insert.
458
+ *
459
+ * The token is the fence: a first acquisition starts at 1, the holder's own
460
+ * re-acquisition keeps its token, and only a takeover of an expired lease
461
+ * increments it. That is what lets a holder that wakes up after its deadline
462
+ * be refused at commit rather than silently writing behind the new holder.
463
+ */
464
+ acquireLease(input: LeaseAcquireInput): LeaseOutcome {
465
+ return this.inLeaseTransaction(() => {
466
+ const current = this.readLeaseForUpdate(input.resource);
467
+ const expired = current !== undefined && leaseHasLapsed(current, input.nowMs);
468
+ if (current && !expired && current.holderAgentId !== input.holderAgentId) {
469
+ return { ok: false, reason: "held", lease: current };
470
+ }
471
+ const fencingToken = current === undefined
472
+ ? 1
473
+ : expired
474
+ ? current.fencingToken + 1
475
+ : current.fencingToken;
476
+ const renewed = current !== undefined && !expired;
477
+ const lease: LeaseRecord = {
478
+ resource: input.resource,
479
+ project: input.project,
480
+ name: input.name,
481
+ holderAgentId: input.holderAgentId,
482
+ holderAgentName: input.holderAgentName,
483
+ fencingToken,
484
+ acquiredAt: renewed && current ? current.acquiredAt : new Date(input.nowMs).toISOString(),
485
+ expiresAt: new Date(input.nowMs + input.ttlMs).toISOString(),
486
+ };
487
+ this.writeLease(lease);
488
+ return { ok: true, lease, renewed };
489
+ });
490
+ }
491
+
492
+ /** Extend a lease the caller still holds under the token it was given. The
493
+ * token never changes on renewal — a renewal that would need a new token is
494
+ * a takeover, and takeovers go through `acquireLease`. */
495
+ renewLease(input: {
496
+ resource: string;
497
+ holderAgentId: string;
498
+ fencingToken: number;
499
+ ttlMs: number;
500
+ nowMs: number;
501
+ }): LeaseOutcome {
502
+ return this.inLeaseTransaction(() => {
503
+ const current = this.readLeaseForUpdate(input.resource);
504
+ if (!current) return { ok: false, reason: "missing" };
505
+ if (current.holderAgentId !== input.holderAgentId || current.fencingToken !== input.fencingToken) {
506
+ return { ok: false, reason: "superseded", lease: current };
507
+ }
508
+ if (leaseHasLapsed(current, input.nowMs)) {
509
+ // The deadline passed, so the resource is takeable by anyone. Resurrecting it
510
+ // under the old token would let a second holder appear behind the first.
511
+ return { ok: false, reason: "expired", lease: current };
512
+ }
513
+ const lease: LeaseRecord = { ...current, expiresAt: new Date(input.nowMs + input.ttlMs).toISOString() };
514
+ this.writeLease(lease);
515
+ return { ok: true, lease, renewed: true };
516
+ });
517
+ }
518
+
519
+ /** Drop a lease the caller holds. A clean release ends the fence: there is no
520
+ * stale writer left to keep a token for, so the next acquisition starts over. */
521
+ releaseLease(input: { resource: string; holderAgentId: string; fencingToken: number }): LeaseOutcome {
522
+ return this.inLeaseTransaction(() => {
523
+ const current = this.readLeaseForUpdate(input.resource);
524
+ if (!current) return { ok: false, reason: "missing" };
525
+ if (current.holderAgentId !== input.holderAgentId || current.fencingToken !== input.fencingToken) {
526
+ return { ok: false, reason: "superseded", lease: current };
527
+ }
528
+ this.deleteLease(input.resource);
529
+ return { ok: true, lease: current, renewed: false };
530
+ });
531
+ }
532
+
533
+ /** Every lease of one project, newest deadline last. Reader surface only. */
534
+ listLeases(project: string): LeaseRecord[] {
535
+ if (this.database) {
536
+ const rows = this.database.prepare("SELECT record FROM leases").all() as Array<{ record: string }>;
537
+ this.leases.clear();
538
+ for (const row of rows) {
539
+ const lease = parseLeaseRecord(row.record);
540
+ if (lease) this.leases.set(lease.resource, lease);
541
+ }
542
+ }
543
+ return [...this.leases.values()]
544
+ .filter((lease) => lease.project === project)
545
+ .sort((left, right) => left.resource.localeCompare(right.resource));
546
+ }
547
+
548
+ private inLeaseTransaction(work: () => LeaseOutcome): LeaseOutcome {
549
+ if (!this.database) return work();
550
+ return withDatabaseTransaction(this.database, work);
551
+ }
552
+
553
+ private readLeaseForUpdate(resource: string): LeaseRecord | undefined {
554
+ if (!this.database) return this.leases.get(resource);
555
+ const row = this.database.prepare("SELECT record FROM leases WHERE resource = ?").get(resource) as { record: string } | undefined;
556
+ return row ? parseLeaseRecord(row.record) : undefined;
557
+ }
558
+
559
+ private writeLease(lease: LeaseRecord): void {
560
+ this.leases.set(lease.resource, lease);
561
+ this.database?.prepare(`
562
+ INSERT INTO leases (resource, holder_agent_id, fencing_token, expires_at, record) VALUES (?, ?, ?, ?, ?)
563
+ ON CONFLICT(resource) DO UPDATE SET
564
+ holder_agent_id = excluded.holder_agent_id,
565
+ fencing_token = excluded.fencing_token,
566
+ expires_at = excluded.expires_at,
567
+ record = excluded.record
568
+ `).run(lease.resource, lease.holderAgentId, lease.fencingToken, lease.expiresAt, JSON.stringify(lease));
569
+ }
570
+
571
+ private deleteLease(resource: string): void {
572
+ this.leases.delete(resource);
573
+ this.database?.prepare("DELETE FROM leases WHERE resource = ?").run(resource);
574
+ }
575
+
576
+ // ----- Runtime → hub sync (P5) -----
577
+
578
+ /**
579
+ * Accept one sync event exactly once by `{projectId, runId, sequence}`,
580
+ * deciding inside one transaction. Identical bytes are an idempotent
581
+ * duplicate; different bytes under a used sequence are refused, and so is a
582
+ * run that already belongs to another hub project or home Runtime.
583
+ */
584
+ ingestSyncEvent(event: StoredSyncEvent): SyncIngestOutcome {
585
+ const decide = (): SyncIngestOutcome => {
586
+ const existing = this.readSyncEvent(event.projectId, event.runId, event.sequence);
587
+ if (existing) {
588
+ if (existing.contentHash === event.contentHash && existing.hubProject === event.hubProject) return { outcome: "duplicate" };
589
+ return { outcome: "conflict", reason: "sequence_reused", existingHash: existing.contentHash };
590
+ }
591
+ const owner = this.syncProjectOwner(event.projectId);
592
+ if (owner !== undefined && owner !== event.hubProject) return { outcome: "conflict", reason: "project_mismatch" };
593
+ const home = this.syncRunHome(event.projectId, event.runId);
594
+ if (home !== undefined && home !== event.homeRuntimeId) return { outcome: "conflict", reason: "home_runtime_mismatch" };
595
+ this.writeSyncEvent(event);
596
+ return { outcome: "accepted" };
597
+ };
598
+ return this.database ? withDatabaseTransaction(this.database, decide) : decide();
599
+ }
600
+
601
+ /** Every sync event one hub project holds, by run then sequence. */
602
+ listSyncEvents(hubProject: string): StoredSyncEvent[] {
603
+ if (!this.database) {
604
+ return [...this.syncEvents.values()]
605
+ .filter((event) => event.hubProject === hubProject)
606
+ .sort((left, right) => left.runId.localeCompare(right.runId) || left.sequence - right.sequence);
607
+ }
608
+ const rows = this.database.prepare(`
609
+ SELECT project_id, run_id, sequence, hub_project, home_runtime_id, event_type, content_hash, received_at, record
610
+ FROM sync_events WHERE hub_project = ? ORDER BY run_id ASC, sequence ASC
611
+ `).all(hubProject) as SyncEventSqlRow[];
612
+ return rows.map(syncEventFromRow);
613
+ }
614
+
615
+ /** Highest sequence of one run with no gap below it; 0 when sequence 1 is missing. */
616
+ syncCursor(projectId: string, runId: string): number {
617
+ const sequences = this.database
618
+ ? (this.database.prepare("SELECT sequence FROM sync_events WHERE project_id = ? AND run_id = ? ORDER BY sequence ASC")
619
+ .all(projectId, runId) as Array<{ sequence: number }>).map((row) => row.sequence)
620
+ : [...this.syncEvents.values()]
621
+ .filter((event) => event.projectId === projectId && event.runId === runId)
622
+ .map((event) => event.sequence)
623
+ .sort((left, right) => left - right);
624
+ let cursor = 0;
625
+ for (const sequence of sequences) {
626
+ if (sequence !== cursor + 1) break;
627
+ cursor = sequence;
628
+ }
629
+ return cursor;
630
+ }
631
+
632
+ saveRuntimePresence(record: RuntimePresenceRecord): void {
633
+ this.runtimePresence.set(`${record.project}\u0000${record.runtimeId}`, record);
634
+ this.database?.prepare(`
635
+ INSERT INTO runtime_presence (runtime_id, hub_project, host, heartbeat, record) VALUES (?, ?, ?, ?, ?)
636
+ ON CONFLICT(hub_project, runtime_id) DO UPDATE SET
637
+ host = excluded.host,
638
+ heartbeat = excluded.heartbeat,
639
+ record = excluded.record
640
+ `).run(record.runtimeId, record.project, record.host ?? null, record.heartbeatAt, JSON.stringify(record));
641
+ }
642
+
643
+ getRuntimePresence(project: string, runtimeId: string): RuntimePresenceRecord | undefined {
644
+ if (!this.database) return this.runtimePresence.get(`${project}\u0000${runtimeId}`);
645
+ const row = this.database.prepare("SELECT record FROM runtime_presence WHERE hub_project = ? AND runtime_id = ?")
646
+ .get(project, runtimeId) as { record: string } | undefined;
647
+ return row ? JSON.parse(row.record) as RuntimePresenceRecord : undefined;
648
+ }
649
+
650
+ listRuntimePresence(project: string): RuntimePresenceRecord[] {
651
+ if (!this.database) {
652
+ return [...this.runtimePresence.values()]
653
+ .filter((record) => record.project === project)
654
+ .sort((left, right) => left.runtimeId.localeCompare(right.runtimeId));
655
+ }
656
+ const rows = this.database.prepare("SELECT record FROM runtime_presence WHERE hub_project = ? ORDER BY runtime_id ASC")
657
+ .all(project) as Array<{ record: string }>;
658
+ return rows.map((row) => JSON.parse(row.record) as RuntimePresenceRecord);
659
+ }
660
+
661
+ private readSyncEvent(projectId: string, runId: string, sequence: number): StoredSyncEvent | undefined {
662
+ if (!this.database) return this.syncEvents.get(syncKey(projectId, runId, sequence));
663
+ const row = this.database.prepare(`
664
+ SELECT project_id, run_id, sequence, hub_project, home_runtime_id, event_type, content_hash, received_at, record
665
+ FROM sync_events WHERE project_id = ? AND run_id = ? AND sequence = ?
666
+ `).get(projectId, runId, sequence) as SyncEventSqlRow | undefined;
667
+ return row ? syncEventFromRow(row) : undefined;
668
+ }
669
+
670
+ private syncProjectOwner(projectId: string): string | undefined {
671
+ if (!this.database) {
672
+ for (const event of this.syncEvents.values()) if (event.projectId === projectId) return event.hubProject;
673
+ return undefined;
674
+ }
675
+ const row = this.database.prepare("SELECT hub_project FROM sync_events WHERE project_id = ? LIMIT 1").get(projectId) as
676
+ | { hub_project: string }
677
+ | undefined;
678
+ return row?.hub_project;
679
+ }
680
+
681
+ private syncRunHome(projectId: string, runId: string): string | undefined {
682
+ if (!this.database) {
683
+ for (const event of this.syncEvents.values()) {
684
+ if (event.projectId === projectId && event.runId === runId) return event.homeRuntimeId;
685
+ }
686
+ return undefined;
687
+ }
688
+ const row = this.database.prepare("SELECT home_runtime_id FROM sync_events WHERE project_id = ? AND run_id = ? LIMIT 1")
689
+ .get(projectId, runId) as { home_runtime_id: string } | undefined;
690
+ return row?.home_runtime_id;
691
+ }
692
+
693
+ private writeSyncEvent(event: StoredSyncEvent): void {
694
+ if (!this.database) {
695
+ this.syncEvents.set(syncKey(event.projectId, event.runId, event.sequence), event);
696
+ return;
697
+ }
698
+ this.database.prepare(`
699
+ INSERT INTO sync_events (project_id, run_id, sequence, hub_project, home_runtime_id, event_type, content_hash, received_at, record)
700
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
701
+ `).run(
702
+ event.projectId,
703
+ event.runId,
704
+ event.sequence,
705
+ event.hubProject,
706
+ event.homeRuntimeId,
707
+ event.eventType,
708
+ event.contentHash,
709
+ event.receivedAt,
710
+ event.bytes,
711
+ );
712
+ }
713
+
334
714
  deleteWorkflowRun(runId: string): void {
335
715
  this.workflowRuns.delete(runId);
336
716
  this.database?.prepare("DELETE FROM workflow_runs WHERE id = ?").run(runId);
@@ -355,6 +735,7 @@ export class MeshStore {
355
735
  purgedRuns: string[];
356
736
  purgedJournal: string[];
357
737
  purgedContextItems: string[];
738
+ purgedLeases: LeaseRecord[];
358
739
  } {
359
740
  const messageCutoffMs = nowMs - messageRetentionMs;
360
741
  const messageCutoffIso = new Date(messageCutoffMs).toISOString();
@@ -363,6 +744,7 @@ export class MeshStore {
363
744
  const purgedRuns: string[] = [];
364
745
  const purgedJournal: string[] = [];
365
746
  const purgedContextItems: string[] = [];
747
+ const purgedLeases: LeaseRecord[] = [];
366
748
 
367
749
  // 1. Messages
368
750
  if (!this.database) {
@@ -441,7 +823,31 @@ export class MeshStore {
441
823
  purgedContextItems.push(id);
442
824
  }
443
825
 
444
- return { purgedMessages, purgedRuns, purgedJournal, purgedContextItems };
826
+ // 5. Leases. An expired lease row is kept on purpose — its token is what a
827
+ // takeover increments, so reaping it the moment it lapses would let the
828
+ // fence restart at 1 while a stale holder was still alive. Only rows whose
829
+ // deadline is older than the retention window, far beyond any live holder's
830
+ // TTL, are dropped.
831
+ const leaseCutoffMs = nowMs - runRetentionMs;
832
+ for (const lease of this.listAllLeases()) {
833
+ if (leaseHasLapsed(lease, leaseCutoffMs)) {
834
+ this.deleteLease(lease.resource);
835
+ purgedLeases.push(lease);
836
+ }
837
+ }
838
+
839
+ return { purgedMessages, purgedRuns, purgedJournal, purgedContextItems, purgedLeases };
840
+ }
841
+
842
+ private listAllLeases(): LeaseRecord[] {
843
+ if (!this.database) return [...this.leases.values()];
844
+ const rows = this.database.prepare("SELECT record FROM leases").all() as Array<{ record: string }>;
845
+ const result: LeaseRecord[] = [];
846
+ for (const row of rows) {
847
+ const lease = parseLeaseRecord(row.record);
848
+ if (lease) result.push(lease);
849
+ }
850
+ return result;
445
851
  }
446
852
 
447
853
  saveWorkflowRun(run: WorkflowRun): void {
@@ -528,6 +934,7 @@ export class MeshStore {
528
934
  const workflowRows = this.database.prepare("SELECT record FROM workflow_runs").all() as Array<{ record: string }>;
529
935
  const journalRows = this.database.prepare("SELECT record FROM workflow_journal").all() as Array<{ record: string }>;
530
936
  const contextRows = this.database.prepare("SELECT record FROM context_items").all() as Array<{ record: string }>;
937
+ const leaseRows = this.database.prepare("SELECT record FROM leases").all() as Array<{ record: string }>;
531
938
  for (const row of agentRows) {
532
939
  const agent = JSON.parse(row.record) as StoredAgent;
533
940
  this.agents.set(agent.id, agent);
@@ -544,5 +951,9 @@ export class MeshStore {
544
951
  const item = JSON.parse(row.record) as ContextItem;
545
952
  this.contextItems.set(item.id, item);
546
953
  }
954
+ for (const row of leaseRows) {
955
+ const lease = parseLeaseRecord(row.record);
956
+ if (lease) this.leases.set(lease.resource, lease);
957
+ }
547
958
  }
548
959
  }