letagents 0.12.20 → 0.12.22

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.
@@ -0,0 +1,160 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ /** Shared by desktop and MCP writers. All mutations run inside their write transaction. */
4
+ export function ensureLocalWorkLeaseSchema(db) {
5
+ db.exec(`
6
+ CREATE TABLE IF NOT EXISTS local_work_leases (
7
+ id TEXT PRIMARY KEY, room_id TEXT NOT NULL, task_id TEXT NOT NULL,
8
+ kind TEXT NOT NULL DEFAULT 'work' CHECK(kind='work'),
9
+ status TEXT NOT NULL CHECK(status IN ('active','released','revoked','expired')),
10
+ agent_key TEXT NOT NULL, agent_session_id TEXT NOT NULL, agent_instance_id TEXT,
11
+ actor_label TEXT NOT NULL, epoch INTEGER NOT NULL CHECK(epoch>=0),
12
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_heartbeat_at TEXT NOT NULL,
13
+ expires_at TEXT, revoked_reason TEXT,
14
+ FOREIGN KEY(room_id,task_id) REFERENCES local_tasks(room_id,task_id) ON DELETE CASCADE
15
+ ) STRICT;
16
+ CREATE UNIQUE INDEX IF NOT EXISTS local_work_lease_owner
17
+ ON local_work_leases(room_id,task_id) WHERE status='active';
18
+ CREATE INDEX IF NOT EXISTS local_work_lease_session ON local_work_leases(agent_session_id,status);
19
+ CREATE TRIGGER IF NOT EXISTS local_work_lease_task_changed
20
+ AFTER UPDATE OF status,assignee,assignee_agent_key,assignee_agent_session_id ON local_tasks
21
+ BEGIN
22
+ UPDATE local_work_leases SET status='revoked',revoked_reason='Task completed or ownership changed',updated_at=NEW.updated_at
23
+ WHERE room_id=NEW.room_id AND task_id=NEW.task_id AND status='active'
24
+ AND (NEW.status NOT IN ('assigned','in_progress','blocked','in_review')
25
+ OR NEW.assignee_agent_key IS NOT agent_key
26
+ OR NEW.assignee_agent_session_id IS NOT agent_session_id
27
+ OR NEW.assignee IS NOT actor_label);
28
+ END;
29
+ CREATE TRIGGER IF NOT EXISTS local_work_lease_task_deleted AFTER DELETE ON local_tasks
30
+ BEGIN DELETE FROM local_work_leases WHERE room_id=OLD.room_id AND task_id=OLD.task_id; END;
31
+ `);
32
+ }
33
+
34
+ export function readLocalWorkLeases(db, roomId, taskId) {
35
+ return db.prepare(`SELECT * FROM local_work_leases WHERE room_id=? AND task_id=? AND status='active'
36
+ AND (expires_at IS NULL OR expires_at>?)`).all(roomId, taskId, new Date().toISOString());
37
+ }
38
+
39
+ export function assertLocalWorkLeaseWorker(db, roomId, worker) {
40
+ if (!worker?.agent_key || !worker.session_id) throw new Error("A registered worker is required for task ownership.");
41
+ const hasSessions = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='local_supervisor_sessions'").get();
42
+ const session = hasSessions && db.prepare(`SELECT s.ended_at,g.revoked_at,g.room_id,g.agent_key FROM local_supervisor_sessions s
43
+ JOIN local_supervisor_grants g USING(grant_id) WHERE s.session_id=?`).get(worker.session_id);
44
+ if ((worker.supervised && !session) || (session && (session.ended_at || session.revoked_at
45
+ || session.room_id !== roomId || session.agent_key !== worker.agent_key))) {
46
+ throw new Error("Local worker authority ended before the task mutation.");
47
+ }
48
+ return Boolean(session);
49
+ }
50
+
51
+ function taskRow(db, roomId, taskId) {
52
+ const task = db.prepare("SELECT * FROM local_tasks WHERE room_id=? AND task_id=?").get(roomId, taskId);
53
+ if (!task) throw new Error("Task not found.");
54
+ return task;
55
+ }
56
+
57
+ function expireLeases(db, roomId, taskId, now) {
58
+ db.prepare(`UPDATE local_work_leases SET status='expired',updated_at=?
59
+ WHERE room_id=? AND task_id=? AND status='active' AND expires_at IS NOT NULL AND expires_at<=?`)
60
+ .run(now, roomId, taskId, now);
61
+ }
62
+
63
+ function mintLease(db, roomId, taskId, worker, now) {
64
+ const epoch = Number(db.prepare("SELECT COALESCE(MAX(epoch),-1)+1 AS epoch FROM local_work_leases WHERE room_id=? AND task_id=?")
65
+ .get(roomId, taskId).epoch);
66
+ const id = `local_work_${randomUUID()}`;
67
+ db.prepare(`INSERT INTO local_work_leases(id,room_id,task_id,status,agent_key,agent_session_id,agent_instance_id,
68
+ actor_label,epoch,created_at,updated_at,last_heartbeat_at) VALUES(?,?,?,'active',?,?,?,?,?,?,?,?)`)
69
+ .run(id, roomId, taskId, worker.agent_key, worker.session_id, worker.agent_instance_id ?? null,
70
+ worker.actor_label, epoch, now, now, now);
71
+ return db.prepare("SELECT * FROM local_work_leases WHERE id=?").get(id);
72
+ }
73
+
74
+ function assignTask(db, roomId, taskId, worker, status, now) {
75
+ db.prepare(`UPDATE local_tasks SET status=?,assignee=?,assignee_agent_key=?,assignee_agent_instance_id=?,
76
+ assignee_agent_session_id=?,sync_dirty=1,updated_at=? WHERE room_id=? AND task_id=?`)
77
+ .run(status, worker?.actor_label ?? null, worker?.agent_key ?? null, worker?.agent_instance_id ?? null,
78
+ worker?.session_id ?? null, now, roomId, taskId);
79
+ }
80
+
81
+ export function claimLocalWorkLease(db, roomId, taskId, worker) {
82
+ if (!assertLocalWorkLeaseWorker(db, roomId, worker)) throw new Error("A supervised worker is required for a local work lease.");
83
+ const task = taskRow(db, roomId, taskId);
84
+ const now = new Date().toISOString();
85
+ expireLeases(db, roomId, taskId, now);
86
+ const active = readLocalWorkLeases(db, roomId, taskId)[0];
87
+ if (active) {
88
+ if (active.agent_session_id !== worker.session_id || active.agent_key !== worker.agent_key) {
89
+ throw new Error("This task is already owned by another worker. Use a lease handoff.");
90
+ }
91
+ return active; // Retry never changes the failure-time cutoff or ownership fence.
92
+ }
93
+ const reclaim = ['assigned', 'in_progress', 'blocked', 'in_review'].includes(task.status)
94
+ && task.assignee_agent_key === worker.agent_key;
95
+ if (task.status !== 'accepted' && !reclaim) throw new Error("Claim an accepted task, or reclaim your own assigned task.");
96
+ if (task.assignee_agent_key && task.assignee_agent_key !== worker.agent_key) throw new Error("This task is assigned to another worker.");
97
+ assignTask(db, roomId, taskId, worker, reclaim ? task.status : 'assigned', now);
98
+ return mintLease(db, roomId, taskId, worker, now);
99
+ }
100
+
101
+ /** Worker APIs must call this after taking the write lock, before changing any task fields. */
102
+ export function assertLocalTaskLeaseMutation(db, task, worker, expected) {
103
+ const supervised = assertLocalWorkLeaseWorker(db, task.room_id, worker);
104
+ const active = readLocalWorkLeases(db, task.room_id, task.task_id)[0];
105
+ if (expected !== undefined && ((active?.id ?? null) !== (expected?.id ?? null)
106
+ || (active?.epoch ?? null) !== (expected?.epoch ?? null))) throw new Error("The task lease changed before this update.");
107
+ const reviewOwner = task.review_lease_id && task.review_agent_key === worker.agent_key
108
+ && task.review_agent_session_id === worker.session_id && task.status === 'in_review';
109
+ if (active && (active.agent_session_id !== worker.session_id || active.agent_key !== worker.agent_key) && !reviewOwner) {
110
+ throw new Error("This task's work lease belongs to another worker.");
111
+ }
112
+ if (!active && supervised && ['assigned','in_progress','blocked','in_review'].includes(task.status) && !reviewOwner) {
113
+ throw new Error("Claim this task to establish a current work lease before updating it.");
114
+ }
115
+ }
116
+
117
+ /** A null actor is reserved for the desktop's explicit human lease controls. */
118
+ export function changeLocalWorkLease(db, roomId, taskId, input, worker) {
119
+ if (worker) assertLocalWorkLeaseWorker(db, roomId, worker);
120
+ const task = taskRow(db, roomId, taskId);
121
+ const now = new Date().toISOString();
122
+ expireLeases(db, roomId, taskId, now);
123
+ const active = readLocalWorkLeases(db, roomId, taskId)[0];
124
+ if (!active) throw new Error("This task has no active work lease.");
125
+ if ((input.lease_id && active.id !== input.lease_id)
126
+ || (input.epoch !== undefined && active.epoch !== input.epoch)) throw new Error("The task lease changed. Refresh the task before trying again.");
127
+ if (worker && (active.agent_session_id !== worker.session_id || active.agent_key !== worker.agent_key)) {
128
+ throw new Error("Only the current worker can release or hand off this work lease.");
129
+ }
130
+ let target = null;
131
+ if (input.action === 'handoff') {
132
+ const targets = db.prepare(`SELECT s.session_id,s.instance_id,g.agent_key,s.public_json FROM local_supervisor_sessions s
133
+ JOIN local_supervisor_grants g USING(grant_id) WHERE g.room_id=? AND g.agent_key=?
134
+ AND g.revoked_at IS NULL AND s.ended_at IS NULL`)
135
+ .all(roomId, input.target_actor_key ?? '').filter(row =>
136
+ (!input.target_agent_session_id || row.session_id === input.target_agent_session_id)
137
+ && (!input.target_actor_instance_id || row.instance_id === input.target_actor_instance_id));
138
+ if (targets.length !== 1) throw new Error("Choose one active local worker session for this handoff.");
139
+ const session = JSON.parse(targets[0].public_json);
140
+ target = { ...session, supervised: true };
141
+ assertLocalWorkLeaseWorker(db, roomId, target);
142
+ } else if (input.action !== 'release') throw new Error("Unknown work lease action.");
143
+ db.prepare("UPDATE local_work_leases SET status='released',updated_at=? WHERE id=?").run(now, active.id);
144
+ assignTask(db, roomId, taskId, target, target ? (task.status === 'blocked' ? 'assigned' : task.status) : 'accepted', now);
145
+ return { released_lease: { ...active, status: 'released', updated_at: now },
146
+ new_lease: target ? mintLease(db, roomId, taskId, target, now) : null };
147
+ }
148
+
149
+ export function endLocalWorkerLeases(db, sessionId, now) {
150
+ db.prepare(`UPDATE local_work_leases SET status='revoked',revoked_reason='Worker session ended',updated_at=?
151
+ WHERE agent_session_id=? AND status='active'`).run(now, sessionId);
152
+ }
153
+
154
+ export function heartbeatLocalWorkLeases(db, sessionId, now) {
155
+ db.prepare(`UPDATE local_work_leases SET last_heartbeat_at=?,updated_at=?
156
+ WHERE agent_session_id=? AND status='active' AND (expires_at IS NULL OR expires_at>?)`)
157
+ .run(now, now, sessionId, now);
158
+ return db.prepare(`SELECT id,epoch FROM local_work_leases WHERE agent_session_id=? AND status='active'
159
+ AND (expires_at IS NULL OR expires_at>?)`).all(sessionId, now);
160
+ }
@@ -30,3 +30,5 @@ export type ParsedAccountAgentRouting =
30
30
  export function parseAccountAgentRoutingEnvelope(
31
31
  routing: unknown,
32
32
  ): ParsedAccountAgentRouting | undefined;
33
+
34
+ export function parseSupervisedReplySourceNumber(clientMessageId: string | null): number | null;
@@ -107,3 +107,14 @@ export function parseAccountAgentRoutingEnvelope(routing) {
107
107
  controlAuthorized: routing.control_authorized === true,
108
108
  };
109
109
  }
110
+
111
+ /** Only daemon publication identities can link an answer to its source receipt. */
112
+ export function parseSupervisedReplySourceNumber(clientMessageId) {
113
+ if (!clientMessageId) return null;
114
+ const parts = clientMessageId.split(":");
115
+ if (parts[0] !== "supervised-room" || parts.at(-2) !== "reply" || parts.at(-1) !== "v1") return null;
116
+ const body = parts.slice(1, -2);
117
+ if (body.length !== 2 && body.length !== 3) return null;
118
+ const source = body.at(-1);
119
+ return source ? parsePositivePgIntegerScopedId(source, "msg") : null;
120
+ }
@@ -0,0 +1,3 @@
1
+ export const LOCAL_ROOM_API_ORIGIN: "letagents-local://rooms";
2
+ export function isLocalRoomApi(value: unknown): boolean;
3
+ export function roomApiOrigin(value: string): string;
@@ -0,0 +1,12 @@
1
+ /** An in-process storage adapter, never an HTTP endpoint or cloud credential audience. */
2
+ export const LOCAL_ROOM_API_ORIGIN = "letagents-local://rooms";
3
+
4
+ export function isLocalRoomApi(value) {
5
+ return value === LOCAL_ROOM_API_ORIGIN;
6
+ }
7
+
8
+ /** Preserve the existing HTTP normalization while retaining the explicit local authority. */
9
+ export function roomApiOrigin(value) {
10
+ if (isLocalRoomApi(value)) return LOCAL_ROOM_API_ORIGIN;
11
+ return new URL(value).origin;
12
+ }
@@ -70,3 +70,7 @@ export function getLocalThreadRoutingAgentKeysForRoots(
70
70
  signal?: AbortSignal;
71
71
  },
72
72
  ): Promise<Map<number, Set<string>>>;
73
+
74
+ export function ensureRequestedRootsProjected(database: SqliteRoutingDatabase, roomId: string, rootNumbers: readonly number[], options?: { foregroundTimeBudgetMs?: number; scheduleOnTimeout?: boolean; signal?: AbortSignal }): Promise<void>;
75
+ export function readProjectedLocalThreadRoutingAgentKeys(database: SqliteRoutingDatabase, roomId: string, rootNumbers: readonly number[], identities: readonly RoutingIdentityLike[]): Map<number, Set<string>>;
76
+ export class LocalThreadRoutingProjectionChangedError extends LocalThreadRoutingProjectionUnavailableError {}
@@ -788,7 +788,7 @@ export function invalidateLocalThreadRoutingRoots(database, roomId, rootNumbersI
788
788
  }
789
789
  }
790
790
 
791
- async function ensureRequestedRootsProjected(database, roomId, rootNumbers, options = {}) {
791
+ export async function ensureRequestedRootsProjected(database, roomId, rootNumbers, options = {}) {
792
792
  if (rootNumbers.length > LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS) {
793
793
  throw new LocalThreadRoutingProjectionUnavailableError();
794
794
  }
@@ -869,6 +869,33 @@ export async function getLocalThreadRoutingAgentKeysForRoots(
869
869
  if (rootNumbers.length === 0 || identities.length === 0) return new Map();
870
870
  await ensureRequestedRootsProjected(database, roomId, rootNumbers, options);
871
871
 
872
+ const batches = readProjectedThreadRoutingBatches(database, roomId, rootNumbers, identities);
873
+ for (;;) {
874
+ const step = batches.next();
875
+ if (step.done) return step.value;
876
+ await yieldToEventLoop();
877
+ }
878
+ }
879
+
880
+ /** Read prepared projections synchronously inside an existing message transaction. */
881
+ export function readProjectedLocalThreadRoutingAgentKeys(database, roomId, rootNumbers, identities) {
882
+ const statements = requestedRootProjectionStatements(database);
883
+ const rootsJson = JSON.stringify(rootNumbers);
884
+ if (pendingRequestedRoots(statements, roomId, rootsJson).length
885
+ || statements.invalidated.all(roomId, rootsJson).length) {
886
+ throw new LocalThreadRoutingProjectionChangedError();
887
+ }
888
+ const batches = readProjectedThreadRoutingBatches(database, roomId, rootNumbers, identities);
889
+ for (;;) {
890
+ const step = batches.next();
891
+ if (step.done) return step.value;
892
+ }
893
+ }
894
+
895
+ export class LocalThreadRoutingProjectionChangedError extends LocalThreadRoutingProjectionUnavailableError {}
896
+
897
+ function* readProjectedThreadRoutingBatches(database, roomId, rootNumbers, identities) {
898
+ if (!rootNumbers.length || !identities.length) return new Map();
872
899
  const keysByHash = new Map();
873
900
  const durableKeysByHash = new Map();
874
901
  for (const identity of identities) {
@@ -938,7 +965,7 @@ export async function getLocalThreadRoutingAgentKeysForRoots(
938
965
  keys.add(agentKey);
939
966
  result.set(root, keys);
940
967
  }
941
- await yieldToEventLoop();
968
+ yield;
942
969
  }
943
970
 
944
971
  const aliasInputs = [];
@@ -1032,7 +1059,7 @@ export async function getLocalThreadRoutingAgentKeysForRoots(
1032
1059
  keys.add(agentKey);
1033
1060
  result.set(root, keys);
1034
1061
  }
1035
- await yieldToEventLoop();
1062
+ yield;
1036
1063
  }
1037
1064
  return result;
1038
1065
  }