@kontextmind/kxm 0.7.70 → 0.7.72

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 { AgentRecord, MessageRecord } from "./protocol.ts";
3
+ import type { AgentIdentity, LeaseRecord, MessageRecord } 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 = 4;
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,10 @@ 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"]),
22
23
  });
23
24
 
24
- export const HUB_STORE_SCHEMA_V3 = `
25
+ export const HUB_STORE_SCHEMA_V4 = `
25
26
  CREATE TABLE IF NOT EXISTS agents (
26
27
  id TEXT PRIMARY KEY,
27
28
  record TEXT NOT NULL
@@ -70,18 +71,62 @@ export const HUB_STORE_SCHEMA_V3 = `
70
71
  record TEXT NOT NULL
71
72
  ) STRICT;
72
73
  CREATE INDEX IF NOT EXISTS context_items_project ON context_items(project);
74
+ CREATE TABLE IF NOT EXISTS leases (
75
+ resource TEXT PRIMARY KEY,
76
+ holder_agent_id TEXT NOT NULL,
77
+ fencing_token INTEGER NOT NULL,
78
+ expires_at TEXT NOT NULL,
79
+ record TEXT NOT NULL
80
+ ) STRICT;
73
81
  `;
74
82
 
75
83
  export const HUB_STORE_SCHEMA_SPEC: DatabaseSchemaSpec = Object.freeze({
76
- schema: HUB_STORE_SCHEMA_V3,
84
+ schema: HUB_STORE_SCHEMA_V4,
77
85
  version: HUB_STORE_SCHEMA_VERSION,
78
86
  tables: HUB_STORE_TABLES,
79
87
  });
80
88
 
81
- export interface StoredAgent extends AgentRecord {
89
+ /** The stored shape stays the durable identity plus the agent key: presence
90
+ * and the lease are derived on read, so the agents record JSON grows only by
91
+ * the additive `host` label. */
92
+ export interface StoredAgent extends AgentIdentity {
82
93
  key: string;
83
94
  }
84
95
 
96
+ /** Why a lease call did not get the resource. `held` is live contention,
97
+ * `superseded` is a token the hub has already moved past, `expired` is a lease
98
+ * whose hub-clocked deadline has passed, and `missing` is no lease at all. */
99
+ export type LeaseRefusal = "held" | "superseded" | "expired" | "missing";
100
+
101
+ export type LeaseOutcome =
102
+ | { ok: true; lease: LeaseRecord; renewed: boolean }
103
+ | { ok: false; reason: LeaseRefusal; lease?: LeaseRecord };
104
+
105
+ export interface LeaseAcquireInput {
106
+ resource: string;
107
+ project: string;
108
+ name: string;
109
+ holderAgentId: string;
110
+ holderAgentName: string;
111
+ ttlMs: number;
112
+ nowMs: number;
113
+ }
114
+
115
+ function parseLeaseRecord(record: string): LeaseRecord | undefined {
116
+ try {
117
+ return JSON.parse(record) as LeaseRecord;
118
+ } catch {
119
+ return undefined;
120
+ }
121
+ }
122
+
123
+ /** A deadline the hub cannot read is an expired deadline. The alternative — a
124
+ * lease nobody can ever take over — is the worse failure. */
125
+ function leaseHasLapsed(lease: LeaseRecord, nowMs: number): boolean {
126
+ const expiresAtMs = Date.parse(lease.expiresAt);
127
+ return !Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs;
128
+ }
129
+
85
130
  class MessageMap extends Map<string, MessageRecord> {
86
131
  private readonly store: MeshStore;
87
132
 
@@ -121,6 +166,7 @@ export class MeshStore {
121
166
  readonly workflowRuns = new Map<string, WorkflowRun>();
122
167
  readonly journal = new Map<string, WorkflowJournalEntry>();
123
168
  readonly contextItems = new Map<string, ContextItem>();
169
+ readonly leases = new Map<string, LeaseRecord>();
124
170
  readonly path?: string;
125
171
  private readonly database?: DatabaseSync;
126
172
  private readonly agentSequences = new Map<string, number>();
@@ -328,6 +374,140 @@ export class MeshStore {
328
374
  return result;
329
375
  }
330
376
 
377
+ /** Read the lease over one already project-scoped resource. */
378
+ getLease(resource: string): LeaseRecord | undefined {
379
+ if (!this.database) return this.leases.get(resource);
380
+ const row = this.database.prepare("SELECT record FROM leases WHERE resource = ?").get(resource) as { record: string } | undefined;
381
+ if (!row) {
382
+ this.leases.delete(resource);
383
+ return undefined;
384
+ }
385
+ const lease = parseLeaseRecord(row.record);
386
+ if (lease) this.leases.set(resource, lease);
387
+ return lease;
388
+ }
389
+
390
+ /**
391
+ * Take or extend the lease over `resource`, deciding the whole outcome inside
392
+ * one transaction so two writers cannot both read "free" and both insert.
393
+ *
394
+ * The token is the fence: a first acquisition starts at 1, the holder's own
395
+ * re-acquisition keeps its token, and only a takeover of an expired lease
396
+ * increments it. That is what lets a holder that wakes up after its deadline
397
+ * be refused at commit rather than silently writing behind the new holder.
398
+ */
399
+ acquireLease(input: LeaseAcquireInput): LeaseOutcome {
400
+ return this.inLeaseTransaction(() => {
401
+ const current = this.readLeaseForUpdate(input.resource);
402
+ const expired = current !== undefined && leaseHasLapsed(current, input.nowMs);
403
+ if (current && !expired && current.holderAgentId !== input.holderAgentId) {
404
+ return { ok: false, reason: "held", lease: current };
405
+ }
406
+ const fencingToken = current === undefined
407
+ ? 1
408
+ : expired
409
+ ? current.fencingToken + 1
410
+ : current.fencingToken;
411
+ const renewed = current !== undefined && !expired;
412
+ const lease: LeaseRecord = {
413
+ resource: input.resource,
414
+ project: input.project,
415
+ name: input.name,
416
+ holderAgentId: input.holderAgentId,
417
+ holderAgentName: input.holderAgentName,
418
+ fencingToken,
419
+ acquiredAt: renewed && current ? current.acquiredAt : new Date(input.nowMs).toISOString(),
420
+ expiresAt: new Date(input.nowMs + input.ttlMs).toISOString(),
421
+ };
422
+ this.writeLease(lease);
423
+ return { ok: true, lease, renewed };
424
+ });
425
+ }
426
+
427
+ /** Extend a lease the caller still holds under the token it was given. The
428
+ * token never changes on renewal — a renewal that would need a new token is
429
+ * a takeover, and takeovers go through `acquireLease`. */
430
+ renewLease(input: {
431
+ resource: string;
432
+ holderAgentId: string;
433
+ fencingToken: number;
434
+ ttlMs: number;
435
+ nowMs: number;
436
+ }): LeaseOutcome {
437
+ return this.inLeaseTransaction(() => {
438
+ const current = this.readLeaseForUpdate(input.resource);
439
+ if (!current) return { ok: false, reason: "missing" };
440
+ if (current.holderAgentId !== input.holderAgentId || current.fencingToken !== input.fencingToken) {
441
+ return { ok: false, reason: "superseded", lease: current };
442
+ }
443
+ if (leaseHasLapsed(current, input.nowMs)) {
444
+ // The deadline passed, so the resource is takeable by anyone. Resurrecting it
445
+ // under the old token would let a second holder appear behind the first.
446
+ return { ok: false, reason: "expired", lease: current };
447
+ }
448
+ const lease: LeaseRecord = { ...current, expiresAt: new Date(input.nowMs + input.ttlMs).toISOString() };
449
+ this.writeLease(lease);
450
+ return { ok: true, lease, renewed: true };
451
+ });
452
+ }
453
+
454
+ /** Drop a lease the caller holds. A clean release ends the fence: there is no
455
+ * stale writer left to keep a token for, so the next acquisition starts over. */
456
+ releaseLease(input: { resource: string; holderAgentId: string; fencingToken: number }): LeaseOutcome {
457
+ return this.inLeaseTransaction(() => {
458
+ const current = this.readLeaseForUpdate(input.resource);
459
+ if (!current) return { ok: false, reason: "missing" };
460
+ if (current.holderAgentId !== input.holderAgentId || current.fencingToken !== input.fencingToken) {
461
+ return { ok: false, reason: "superseded", lease: current };
462
+ }
463
+ this.deleteLease(input.resource);
464
+ return { ok: true, lease: current, renewed: false };
465
+ });
466
+ }
467
+
468
+ /** Every lease of one project, newest deadline last. Reader surface only. */
469
+ listLeases(project: string): LeaseRecord[] {
470
+ if (this.database) {
471
+ const rows = this.database.prepare("SELECT record FROM leases").all() as Array<{ record: string }>;
472
+ this.leases.clear();
473
+ for (const row of rows) {
474
+ const lease = parseLeaseRecord(row.record);
475
+ if (lease) this.leases.set(lease.resource, lease);
476
+ }
477
+ }
478
+ return [...this.leases.values()]
479
+ .filter((lease) => lease.project === project)
480
+ .sort((left, right) => left.resource.localeCompare(right.resource));
481
+ }
482
+
483
+ private inLeaseTransaction(work: () => LeaseOutcome): LeaseOutcome {
484
+ if (!this.database) return work();
485
+ return withDatabaseTransaction(this.database, work);
486
+ }
487
+
488
+ private readLeaseForUpdate(resource: string): LeaseRecord | undefined {
489
+ if (!this.database) return this.leases.get(resource);
490
+ const row = this.database.prepare("SELECT record FROM leases WHERE resource = ?").get(resource) as { record: string } | undefined;
491
+ return row ? parseLeaseRecord(row.record) : undefined;
492
+ }
493
+
494
+ private writeLease(lease: LeaseRecord): void {
495
+ this.leases.set(lease.resource, lease);
496
+ this.database?.prepare(`
497
+ INSERT INTO leases (resource, holder_agent_id, fencing_token, expires_at, record) VALUES (?, ?, ?, ?, ?)
498
+ ON CONFLICT(resource) DO UPDATE SET
499
+ holder_agent_id = excluded.holder_agent_id,
500
+ fencing_token = excluded.fencing_token,
501
+ expires_at = excluded.expires_at,
502
+ record = excluded.record
503
+ `).run(lease.resource, lease.holderAgentId, lease.fencingToken, lease.expiresAt, JSON.stringify(lease));
504
+ }
505
+
506
+ private deleteLease(resource: string): void {
507
+ this.leases.delete(resource);
508
+ this.database?.prepare("DELETE FROM leases WHERE resource = ?").run(resource);
509
+ }
510
+
331
511
  deleteWorkflowRun(runId: string): void {
332
512
  this.workflowRuns.delete(runId);
333
513
  this.database?.prepare("DELETE FROM workflow_runs WHERE id = ?").run(runId);
@@ -352,6 +532,7 @@ export class MeshStore {
352
532
  purgedRuns: string[];
353
533
  purgedJournal: string[];
354
534
  purgedContextItems: string[];
535
+ purgedLeases: LeaseRecord[];
355
536
  } {
356
537
  const messageCutoffMs = nowMs - messageRetentionMs;
357
538
  const messageCutoffIso = new Date(messageCutoffMs).toISOString();
@@ -360,6 +541,7 @@ export class MeshStore {
360
541
  const purgedRuns: string[] = [];
361
542
  const purgedJournal: string[] = [];
362
543
  const purgedContextItems: string[] = [];
544
+ const purgedLeases: LeaseRecord[] = [];
363
545
 
364
546
  // 1. Messages
365
547
  if (!this.database) {
@@ -438,7 +620,31 @@ export class MeshStore {
438
620
  purgedContextItems.push(id);
439
621
  }
440
622
 
441
- return { purgedMessages, purgedRuns, purgedJournal, purgedContextItems };
623
+ // 5. Leases. An expired lease row is kept on purpose — its token is what a
624
+ // takeover increments, so reaping it the moment it lapses would let the
625
+ // fence restart at 1 while a stale holder was still alive. Only rows whose
626
+ // deadline is older than the retention window, far beyond any live holder's
627
+ // TTL, are dropped.
628
+ const leaseCutoffMs = nowMs - runRetentionMs;
629
+ for (const lease of this.listAllLeases()) {
630
+ if (leaseHasLapsed(lease, leaseCutoffMs)) {
631
+ this.deleteLease(lease.resource);
632
+ purgedLeases.push(lease);
633
+ }
634
+ }
635
+
636
+ return { purgedMessages, purgedRuns, purgedJournal, purgedContextItems, purgedLeases };
637
+ }
638
+
639
+ private listAllLeases(): LeaseRecord[] {
640
+ if (!this.database) return [...this.leases.values()];
641
+ const rows = this.database.prepare("SELECT record FROM leases").all() as Array<{ record: string }>;
642
+ const result: LeaseRecord[] = [];
643
+ for (const row of rows) {
644
+ const lease = parseLeaseRecord(row.record);
645
+ if (lease) result.push(lease);
646
+ }
647
+ return result;
442
648
  }
443
649
 
444
650
  saveWorkflowRun(run: WorkflowRun): void {
@@ -525,6 +731,7 @@ export class MeshStore {
525
731
  const workflowRows = this.database.prepare("SELECT record FROM workflow_runs").all() as Array<{ record: string }>;
526
732
  const journalRows = this.database.prepare("SELECT record FROM workflow_journal").all() as Array<{ record: string }>;
527
733
  const contextRows = this.database.prepare("SELECT record FROM context_items").all() as Array<{ record: string }>;
734
+ const leaseRows = this.database.prepare("SELECT record FROM leases").all() as Array<{ record: string }>;
528
735
  for (const row of agentRows) {
529
736
  const agent = JSON.parse(row.record) as StoredAgent;
530
737
  this.agents.set(agent.id, agent);
@@ -541,5 +748,9 @@ export class MeshStore {
541
748
  const item = JSON.parse(row.record) as ContextItem;
542
749
  this.contextItems.set(item.id, item);
543
750
  }
751
+ for (const row of leaseRows) {
752
+ const lease = parseLeaseRecord(row.record);
753
+ if (lease) this.leases.set(lease.resource, lease);
754
+ }
544
755
  }
545
756
  }
@@ -310,6 +310,19 @@ function visibleAgents(snapshot: MeshTuiSnapshot): AgentRecord[] {
310
310
  return snapshot.agents.filter((agent) => agent.model !== "tui");
311
311
  }
312
312
 
313
+ /** Presence comes from the hub clock, so the dash colours it rather than
314
+ * recomputing it: green holds a lease, amber has lost it but has not been
315
+ * swept, dim is a registered peer that is gone. */
316
+ function presenceCell(agent: AgentRecord, theme: MeshTuiTheme, width = 7): string {
317
+ // Local-store projections carry no hub-clocked presence; the dash shows "n/a"
318
+ // rather than inventing a lease from the reader's clock.
319
+ const presence = agent.presence ?? "n/a";
320
+ const cell = pad(presence, width);
321
+ if (presence === "online") return theme.success(cell);
322
+ if (presence === "stale") return theme.warning(cell);
323
+ return theme.dim(cell);
324
+ }
325
+
313
326
  function panelMetric(snapshot: MeshTuiSnapshot, panel: MeshTuiPanel): string {
314
327
  if (panel === "agents") {
315
328
  const agents = visibleAgents(snapshot);
@@ -441,7 +454,7 @@ function listLines(snapshot: MeshTuiSnapshot, view: MeshTuiView, theme: MeshTuiT
441
454
  const mark = (index: number) => cursor(theme, index === view.selected, "list", view.pane);
442
455
  if (view.tab === "agents") {
443
456
  return visibleAgents(snapshot).map((agent, index) => (
444
- `${mark(index)}${pad(agent.name, 14)} ${agent.online ? theme.success(pad("yes", 3)) : theme.dim(pad("no", 3))} ${pad(agent.model ?? "-", 18)} ${pad(age(agent.lastSeenAt, now), 4)}`
457
+ `${mark(index)}${pad(agent.name, 14)} ${presenceCell(agent, theme)} ${pad(agent.host ?? "-", 12)} ${pad(agent.model ?? "-", 14)} ${pad(age(agent.lastSeenAt, now), 4)}`
445
458
  ));
446
459
  }
447
460
  if (view.tab === "tasks" || view.tab === "workflows") {
@@ -489,9 +502,10 @@ function detailLines(snapshot: MeshTuiSnapshot, view: MeshTuiView, theme: MeshTu
489
502
  const related = snapshot.openMessages.filter((message) => message.fromName === agent.name || message.toName === agent.name);
490
503
  return [
491
504
  theme.accent(agent.name),
492
- `${agent.online ? theme.success("online") : theme.dim("offline")} ${agent.model ?? "-"}`,
505
+ `${presenceCell(agent, theme, (agent.presence ?? "n/a").length)} ${agent.model ?? "-"}`,
506
+ `host ${agent.host ?? "-"}`,
493
507
  agent.purpose,
494
- `seen ${age(agent.lastSeenAt, now)} ago`,
508
+ `seen ${age(agent.lastSeenAt, now)} ago · lease ${agent.leaseExpiresAt?.slice(11, 19) ?? "n/a"} UTC`,
495
509
  "",
496
510
  theme.dim("Open work"),
497
511
  ...(related.length === 0 ? [theme.dim("none")] : related.map((message) => `${message.status} ${message.fromName} → ${message.toName} ${age(message.createdAt, now)}`)),
@@ -875,7 +889,9 @@ export async function runMeshTui(input: {
875
889
  }
876
890
  if (!useOpsStream && identity) {
877
891
  try {
878
- const listed = await input.fetchImpl(`${base}/v1/agents`, { headers: headers(identity) });
892
+ // Offline members too: the ops snapshot lists the whole project, and
893
+ // the legacy path is a fallback for the same screen, not a narrower one.
894
+ const listed = await input.fetchImpl(`${base}/v1/agents?includeOffline=true`, { headers: headers(identity) });
879
895
  if (listed.ok) {
880
896
  const body = await readJson<{ agents: AgentRecord[] }>(listed);
881
897
  const byId = new Map(local.agents.map((agent) => [agent.id, agent]));