letagents 0.12.22 → 0.12.24

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,62 @@
1
+ export interface ConversationPerson {
2
+ id: string;
3
+ login: string;
4
+ display_name: string | null;
5
+ avatar_url: string | null;
6
+ }
7
+ export interface ConversationMember extends ConversationPerson {
8
+ accepted: boolean;
9
+ blocked: boolean;
10
+ }
11
+ export interface ConversationMessage {
12
+ conversation_id: string;
13
+ number: number;
14
+ sender_account_id: string;
15
+ client_message_id: string;
16
+ text: string;
17
+ created_at: string;
18
+ }
19
+ export interface Conversation {
20
+ id: string;
21
+ created_by: string;
22
+ members: ConversationMember[];
23
+ accepted: boolean;
24
+ muted: boolean;
25
+ archived: boolean;
26
+ can_send: boolean;
27
+ unread_count: number;
28
+ last_message: ConversationMessage | null;
29
+ updated_at: string;
30
+ }
31
+ export interface ConversationList {
32
+ conversations: Conversation[];
33
+ version: string;
34
+ }
35
+ export interface ConversationApi {
36
+ list(): Promise<ConversationList>;
37
+ people(query: string): Promise<{ people: ConversationPerson[] }>;
38
+ create(
39
+ accountIds: string[],
40
+ fromConversationId?: string,
41
+ ): Promise<{ conversation_id: string }>;
42
+ messages(
43
+ id: string,
44
+ cursor?: { before?: number; after?: number },
45
+ ): Promise<{ messages: ConversationMessage[]; has_more: boolean }>;
46
+ send(
47
+ id: string,
48
+ text: string,
49
+ clientMessageId: string,
50
+ ): Promise<ConversationMessage>;
51
+ update(
52
+ id: string,
53
+ changes: {
54
+ accept?: boolean;
55
+ last_read_number?: number;
56
+ muted?: boolean;
57
+ archived?: boolean;
58
+ },
59
+ ): Promise<void>;
60
+ block(accountId: string, blocked: boolean): Promise<void>;
61
+ changes(after: string): Promise<{ version: string }>;
62
+ }
@@ -0,0 +1,9 @@
1
+ export const LOCAL_BOARD_MAX_FRAME_BYTES: number;
2
+ export function runWithLocalBoardOwner<T>(assertCurrent: () => void, callback: () => T): T;
3
+ export function registerLocalBoardOwner(assertCurrent: () => void): void;
4
+ export function isLocalBoardOwner(): boolean;
5
+ export function notifyLocalBoardChanged(roomId: string): void;
6
+ export function onLocalBoardChanged(listener: (roomId: string) => void): () => void;
7
+ export function requestLocalBoard<T = unknown>(method: "mutate" | "watch", params: unknown, options?: {
8
+ signal?: AbortSignal; socketPath?: string; timeoutMs?: number;
9
+ }): Promise<T>;
@@ -0,0 +1,80 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { randomUUID } from "node:crypto";
3
+ import { createConnection } from "node:net";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ export const LOCAL_BOARD_MAX_FRAME_BYTES = 16 * 1024 * 1024;
8
+
9
+ const ownership = new AsyncLocalStorage();
10
+ const listeners = new Set();
11
+ let processOwner = null;
12
+
13
+ /** The daemon registers once after acquiring its singleton; clients never do. */
14
+ export function registerLocalBoardOwner(assertCurrent) {
15
+ processOwner = assertCurrent;
16
+ }
17
+
18
+ /** Only the background service enters this context, after its generation fence. */
19
+ export function runWithLocalBoardOwner(assertCurrent, callback) {
20
+ assertCurrent();
21
+ return ownership.run(assertCurrent, callback);
22
+ }
23
+
24
+ export function isLocalBoardOwner() {
25
+ const assertCurrent = ownership.getStore() ?? processOwner;
26
+ if (!assertCurrent) return false;
27
+ assertCurrent();
28
+ return true;
29
+ }
30
+
31
+ export function notifyLocalBoardChanged(roomId) {
32
+ // A renderer disconnect must never turn a committed write into a failure.
33
+ for (const listener of listeners) { try { listener(roomId); } catch { /* reconnect catches up */ } }
34
+ }
35
+
36
+ export function onLocalBoardChanged(listener) {
37
+ listeners.add(listener);
38
+ return () => listeners.delete(listener);
39
+ }
40
+
41
+ /** One request, no mutation retry. A disconnected reply is an unknown outcome. */
42
+ export function requestLocalBoard(method, params, { signal, socketPath, timeoutMs = 30_000 } = {}) {
43
+ return new Promise((resolve, reject) => {
44
+ const id = randomUUID();
45
+ const socket = createConnection(socketPath ?? process.env.LETAGENTS_BOARD_SOCKET_PATH ?? join(homedir(), ".letagents", "daemon.sock"));
46
+ let settled = false;
47
+ let buffer = "";
48
+ const finish = (error, value) => {
49
+ if (settled) return;
50
+ settled = true;
51
+ signal?.removeEventListener("abort", abort);
52
+ socket.destroy();
53
+ error ? reject(error) : resolve(value);
54
+ };
55
+ const abort = () => finish(new Error("Board subscription closed."));
56
+ if (signal?.aborted) { abort(); return; }
57
+ signal?.addEventListener("abort", abort, { once: true });
58
+ socket.setEncoding("utf8");
59
+ socket.setTimeout(timeoutMs, () => finish(new Error("The board service did not respond.")));
60
+ socket.once("error", () => finish(new Error("Open LetAgents to connect the board service.")));
61
+ socket.once("close", () => finish(new Error("The board service disconnected.")));
62
+ socket.once("connect", () => {
63
+ const frame = JSON.stringify({ version: 3, id, method: `local_board.${method}`, params });
64
+ if (Buffer.byteLength(frame) > LOCAL_BOARD_MAX_FRAME_BYTES - 1) { finish(new Error("This board change is too large.")); return; }
65
+ socket.write(`${frame}\n`);
66
+ });
67
+ socket.on("data", chunk => {
68
+ buffer += chunk;
69
+ if (Buffer.byteLength(buffer) > LOCAL_BOARD_MAX_FRAME_BYTES) { finish(new Error("The board response is too large.")); return; }
70
+ const newline = buffer.indexOf("\n");
71
+ if (newline < 0) return;
72
+ try {
73
+ const response = JSON.parse(buffer.slice(0, newline));
74
+ if (response.id !== id || response.version !== 3) throw new Error("The board service response did not match this request.");
75
+ if (!response.ok) throw new Error(response.error || "The board change failed.");
76
+ finish(null, response.result);
77
+ } catch (error) { finish(error); }
78
+ });
79
+ });
80
+ }
@@ -0,0 +1,4 @@
1
+ import type { SqliteRoutingDatabase } from './sqlite-thread-routing.mjs';
2
+ export function ensureLocalTaskRevisionSchema(db: SqliteRoutingDatabase): void;
3
+ export function readLocalTaskRevision(db: SqliteRoutingDatabase, roomId: string): number;
4
+ export function observeLocalTaskCommits<T>(db: T): T;
@@ -0,0 +1,97 @@
1
+ import { isLocalBoardOwner, notifyLocalBoardChanged } from "./local-board-owner.mjs";
2
+
3
+ /** Commit-coupled board revisions. The daemon is the only board writer. */
4
+ export function ensureLocalTaskRevisionSchema(db) {
5
+ // A newly installed standalone client must not migrate the board underneath
6
+ // an older running daemon. The upgraded writer installs this schema itself.
7
+ if (!isLocalBoardOwner()) return;
8
+ db.exec(`CREATE TABLE IF NOT EXISTS local_task_revisions (
9
+ room_id TEXT PRIMARY KEY, revision INTEGER NOT NULL
10
+ )`);
11
+ // Storage sync and worker heartbeat timestamps do not change the board.
12
+ const sources = [
13
+ ["local_tasks", ["title", "description", "status", "assignee", "assignee_agent_key",
14
+ "assignee_agent_instance_id", "assignee_agent_session_id", "created_by", "pr_url",
15
+ "workflow_artifacts_json", "workflow_refs_json", "review_lease_id", "review_holder_label",
16
+ "review_agent_key", "review_agent_session_id", "review_updated_at"]],
17
+ ["local_work_leases", ["status", "agent_key", "agent_session_id", "agent_instance_id",
18
+ "actor_label", "epoch", "expires_at"]],
19
+ ];
20
+ for (const [table, columns] of sources) {
21
+ for (const operation of ["INSERT", "UPDATE", "DELETE"]) {
22
+ const row = operation === "DELETE" ? "OLD" : "NEW";
23
+ const changed = operation === "UPDATE"
24
+ ? `WHEN ${columns.map(column => `NEW.${column} IS NOT OLD.${column}`).join(" OR ")}`
25
+ : "";
26
+ db.exec(`CREATE TRIGGER IF NOT EXISTS ${table}_owner_${operation.toLowerCase()}
27
+ BEFORE ${operation} ON ${table} ${changed}
28
+ BEGIN
29
+ SELECT CASE WHEN local_board_writer() != 1
30
+ THEN RAISE(ABORT, 'Local board changes require the LetAgents background service.') END;
31
+ END`);
32
+ db.exec(`CREATE TRIGGER IF NOT EXISTS ${table}_board_${operation.toLowerCase()}
33
+ AFTER ${operation} ON ${table} ${changed}
34
+ BEGIN
35
+ INSERT INTO local_task_revisions(room_id, revision) VALUES (${row}.room_id, 1)
36
+ ON CONFLICT(room_id) DO UPDATE SET revision = revision + 1;
37
+ SELECT local_board_changed(${row}.room_id,
38
+ (SELECT revision - 1 FROM local_task_revisions WHERE room_id = ${row}.room_id));
39
+ END`);
40
+ }
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Triggers collect dirty rooms, but only a completed commit can notify readers.
46
+ * In particular, a rollback and a failed statement produce no change event.
47
+ * All owner writes use this connection; socket loss causes reconnect catch-up.
48
+ */
49
+ export function observeLocalTaskCommits(database) {
50
+ const assertTransactionSupport = () => {
51
+ if (typeof database.isTransaction !== "boolean") throw new Error("Local boards require SQLite transaction-state support.");
52
+ };
53
+ if (isLocalBoardOwner()) assertTransactionSupport();
54
+ const dirty = new Map();
55
+ const rawPrepare = database.prepare.bind(database);
56
+ database.function("local_board_writer", () => {
57
+ if (!isLocalBoardOwner()) return 0;
58
+ assertTransactionSupport();
59
+ return 1;
60
+ });
61
+ database.function("local_board_changed", (roomId, before) => {
62
+ if (!dirty.has(roomId)) dirty.set(roomId, Number(before));
63
+ return 0;
64
+ });
65
+ const flush = () => {
66
+ if (database.isTransaction !== false || !dirty.size) return;
67
+ const committed = [...dirty];
68
+ dirty.clear();
69
+ for (const [roomId, before] of committed) {
70
+ const after = Number(rawPrepare("SELECT revision FROM local_task_revisions WHERE room_id=?").get(roomId)?.revision ?? 0);
71
+ if (after !== before) notifyLocalBoardChanged(roomId);
72
+ }
73
+ };
74
+ return new Proxy(database, {
75
+ get(target, key) {
76
+ if (key === "exec") return (...args) => { try { return target.exec(...args); } finally { flush(); } };
77
+ if (key === "prepare") return (...args) => {
78
+ const statement = rawPrepare(...args);
79
+ return new Proxy(statement, {
80
+ get(stmt, member) {
81
+ const value = Reflect.get(stmt, member, stmt);
82
+ if (typeof value !== "function") return value;
83
+ if (!["run", "get", "all"].includes(member)) return value.bind(stmt);
84
+ return (...values) => { try { return value.apply(stmt, values); } finally { flush(); } };
85
+ },
86
+ });
87
+ };
88
+ const value = Reflect.get(target, key, target);
89
+ return typeof value === "function" ? value.bind(target) : value;
90
+ },
91
+ });
92
+ }
93
+
94
+ export function readLocalTaskRevision(db, roomId) {
95
+ return Number(db.prepare("SELECT revision FROM local_task_revisions WHERE room_id=?")
96
+ .get(roomId)?.revision ?? 0);
97
+ }
@@ -0,0 +1,43 @@
1
+ import type { SqliteRoutingDatabase } from "./sqlite-thread-routing.mjs";
2
+ import type { LocalWorkLeaseAction } from "./local-work-leases.mjs";
3
+ export type LocalTask = {
4
+ id: string;
5
+ title: string;
6
+ description: string | null;
7
+ status: string;
8
+ assignee: string | null;
9
+ assignee_agent_key: string | null;
10
+ assignee_agent_instance_id: string | null;
11
+ assignee_agent_session_id: string | null;
12
+ created_by: string | null;
13
+ pr_url: string | null;
14
+ workflow_artifacts: Array<Record<string, unknown>>;
15
+ workflow_refs: Array<Record<string, unknown>>;
16
+ active_leases?: Array<{
17
+ id: string;
18
+ kind: "review" | string;
19
+ holder_label: string | null;
20
+ agent_key: string | null;
21
+ agent_session_id: string | null;
22
+ status: string;
23
+ updated_at: string | null;
24
+ }>;
25
+ created_at: string;
26
+ updated_at: string;
27
+ };
28
+
29
+ export type LocalTaskStore = {
30
+ addLocalTask(roomId: string, input: { title: string; description?: string | null; created_by?: string | null }): Promise<LocalTask>;
31
+ updateLocalTask(roomId: string, taskId: string, patch: Record<string, unknown>): Promise<LocalTask>;
32
+ getLocalTask(roomId: string, taskId: string): Promise<LocalTask | null>;
33
+ listLocalTasks(roomId: string, options?: { status?: string | null; openOnly?: boolean }): Promise<{ tasks: LocalTask[]; has_more: boolean }>;
34
+ listLocalActiveTaskOwnerLeases(roomId: string): Promise<Array<{ kind: "work"; status: "active"; actor_label: string; agent_key: string; agent_instance_id: string | null; agent_session_id: string | null }>>;
35
+ changeLocalTaskWorkLease(roomId: string, taskId: string, input: LocalWorkLeaseAction): Promise<{ action: string; task: LocalTask; released_lease: Record<string, unknown>; new_lease: Record<string, unknown> | null }>;
36
+ claimLocalTaskReviewLease(roomId: string, taskId: string, input: { holder_label?: string | null; agent_key?: string | null; agent_session_id?: string | null }): Promise<{ task: LocalTask; lease: NonNullable<LocalTask["active_leases"]>[number] }>;
37
+ releaseLocalTaskReviewLease(roomId: string, taskId: string, input?: { lease_id?: string | null }): Promise<{ task: LocalTask; released_lease: NonNullable<LocalTask["active_leases"]>[number] | null }>;
38
+ };
39
+ export function createLocalTaskStore(dependencies: {
40
+ getDb(): Promise<SqliteRoutingDatabase>;
41
+ withWorkerStateFence<T>(callback: () => T): T;
42
+ currentWorkerCall(): { agent_key?: string | null; session_id: string; actor_label?: string | null; agent_instance_id?: string | null } | null | undefined;
43
+ }): LocalTaskStore;
@@ -0,0 +1,374 @@
1
+ import { readLocalWorkLeases, assertLocalTaskLeaseMutation, assertLocalWorkLeaseWorker, claimLocalWorkLease, changeLocalWorkLease } from './local-work-leases.mjs';
2
+ import { runLocalSqliteWriteTransactionAsync } from './sqlite-thread-routing.mjs';
3
+ /** Existing standalone MCP task semantics, executed by the desktop board owner. */
4
+ export function createLocalTaskStore({ getDb, withWorkerStateFence, currentWorkerCall }) {
5
+ const validLocalTaskTransitions = {
6
+ proposed: ["accepted", "cancelled"],
7
+ accepted: ["assigned", "cancelled"],
8
+ assigned: ["in_progress", "in_review", "cancelled"],
9
+ in_progress: ["blocked", "in_review", "done", "cancelled"],
10
+ blocked: ["in_progress", "in_review", "cancelled"],
11
+ in_review: ["merged", "in_progress", "blocked", "done", "cancelled"],
12
+ merged: ["done", "accepted"],
13
+ done: ["accepted"],
14
+ cancelled: ["accepted"],
15
+ };
16
+ function resolveLocalTaskStatus(fromStatus, toStatus) {
17
+ if (typeof toStatus !== "string" || !toStatus.trim())
18
+ return fromStatus;
19
+ const nextStatus = toStatus.trim();
20
+ if (!validLocalTaskTransitions[fromStatus]?.includes(nextStatus)) {
21
+ throw new Error(`Invalid transition: ${fromStatus} -> ${nextStatus}. ` +
22
+ `Allowed: ${validLocalTaskTransitions[fromStatus]?.join(", ") || "none"}`);
23
+ }
24
+ return nextStatus;
25
+ }
26
+ function allocateLocalTaskId(database, roomId) {
27
+ database
28
+ .prepare(`
29
+ INSERT INTO local_task_room_sequences (room_id, next_number)
30
+ SELECT ?, COALESCE(MAX(CAST(SUBSTR(task_id, 6) AS INTEGER)), 0) + 1
31
+ FROM local_tasks
32
+ WHERE room_id = ? AND task_id GLOB 'task_[0-9]*'
33
+ ON CONFLICT(room_id) DO NOTHING
34
+ `)
35
+ .run(roomId, roomId);
36
+ const row = database
37
+ .prepare("SELECT next_number FROM local_task_room_sequences WHERE room_id = ?")
38
+ .get(roomId);
39
+ const number = Number(row?.next_number || 0);
40
+ if (!Number.isInteger(number) || number <= 0) {
41
+ throw new Error("Local task sequence could not be allocated.");
42
+ }
43
+ database
44
+ .prepare("UPDATE local_task_room_sequences SET next_number = next_number + 1 WHERE room_id = ?")
45
+ .run(roomId);
46
+ return `task_${number}`;
47
+ }
48
+ function mapTaskRow(row, database) {
49
+ const reviewLeaseId = typeof row.review_lease_id === "string" && row.review_lease_id.trim()
50
+ ? row.review_lease_id
51
+ : null;
52
+ return {
53
+ id: String(row.task_id || ""),
54
+ title: String(row.title || ""),
55
+ description: typeof row.description === "string" ? row.description : null,
56
+ status: String(row.status || "proposed"),
57
+ assignee: typeof row.assignee === "string" ? row.assignee : null,
58
+ assignee_agent_key: typeof row.assignee_agent_key === "string" ? row.assignee_agent_key : null,
59
+ assignee_agent_instance_id: typeof row.assignee_agent_instance_id === "string" ? row.assignee_agent_instance_id : null,
60
+ assignee_agent_session_id: typeof row.assignee_agent_session_id === "string" ? row.assignee_agent_session_id : null,
61
+ created_by: typeof row.created_by === "string" ? row.created_by : null,
62
+ pr_url: typeof row.pr_url === "string" ? row.pr_url : null,
63
+ workflow_artifacts: parseJsonArray(row.workflow_artifacts_json, []),
64
+ workflow_refs: parseJsonArray(row.workflow_refs_json, []),
65
+ active_leases: [...readLocalWorkLeases(database, String(row.room_id), String(row.task_id))
66
+ .map(lease => ({ ...lease, holder_label: lease.actor_label })), ...(reviewLeaseId
67
+ ? [
68
+ {
69
+ id: reviewLeaseId,
70
+ kind: "review",
71
+ holder_label: typeof row.review_holder_label === "string"
72
+ ? row.review_holder_label
73
+ : null,
74
+ agent_key: typeof row.review_agent_key === "string"
75
+ ? row.review_agent_key
76
+ : null,
77
+ agent_session_id: typeof row.review_agent_session_id === "string"
78
+ ? row.review_agent_session_id
79
+ : null,
80
+ status: "active",
81
+ updated_at: typeof row.review_updated_at === "string"
82
+ ? row.review_updated_at
83
+ : null,
84
+ },
85
+ ]
86
+ : [])],
87
+ created_at: String(row.created_at || ""),
88
+ updated_at: String(row.updated_at || ""),
89
+ };
90
+ }
91
+ function parseJsonArray(value, fallback) {
92
+ if (typeof value !== "string" || !value.trim())
93
+ return fallback;
94
+ try {
95
+ const parsed = JSON.parse(value);
96
+ return Array.isArray(parsed) ? parsed : fallback;
97
+ }
98
+ catch {
99
+ return fallback;
100
+ }
101
+ }
102
+ async function addLocalTask(roomId, input) {
103
+ const trimmedRoomId = roomId.trim();
104
+ const title = input.title.trim();
105
+ if (!trimmedRoomId)
106
+ throw new Error("No room is available for this request.");
107
+ if (!title)
108
+ throw new Error("Task title is required.");
109
+ const database = await getDb();
110
+ const now = new Date().toISOString();
111
+ let taskId = "";
112
+ await runLocalSqliteWriteTransactionAsync(database, () => withWorkerStateFence(() => {
113
+ taskId = allocateLocalTaskId(database, trimmedRoomId);
114
+ database
115
+ .prepare(`
116
+ INSERT INTO local_tasks (
117
+ room_id, task_id, title, description, status, assignee, assignee_agent_key,
118
+ assignee_agent_instance_id, assignee_agent_session_id,
119
+ created_by, pr_url, workflow_artifacts_json, workflow_refs_json,
120
+ synced_cloud_id, sync_key, sync_started_at, sync_dirty, created_at, updated_at
121
+ )
122
+ VALUES (?, ?, ?, ?, 'proposed', NULL, NULL, NULL, NULL, ?, NULL, NULL, NULL, NULL, ?, NULL, 1, ?, ?)
123
+ `)
124
+ .run(trimmedRoomId, taskId, title, input.description?.trim() || null, input.created_by || "agent", `local-task:${trimmedRoomId}:${taskId}`, now, now);
125
+ }));
126
+ const task = await getLocalTask(trimmedRoomId, taskId);
127
+ if (!task)
128
+ throw new Error("Local task could not be created.");
129
+ return task;
130
+ }
131
+ async function listLocalTasks(roomId, options = {}) {
132
+ const clauses = ["room_id = ?"];
133
+ const params = [roomId];
134
+ if (options.status) {
135
+ clauses.push("status = ?");
136
+ params.push(options.status);
137
+ }
138
+ if (options.openOnly !== false) {
139
+ clauses.push("status NOT IN ('done', 'cancelled')");
140
+ }
141
+ const database = await getDb();
142
+ const tasks = database
143
+ .prepare(`
144
+ SELECT *
145
+ FROM local_tasks
146
+ WHERE ${clauses.join(" AND ")}
147
+ ORDER BY created_at ASC
148
+ `)
149
+ .all(...params)
150
+ .map(row => mapTaskRow(row, database));
151
+ return { tasks, has_more: false };
152
+ }
153
+ async function listLocalActiveTaskOwnerLeases(roomId) {
154
+ const database = await getDb();
155
+ const rows = database
156
+ .prepare(`
157
+ SELECT
158
+ MIN(COALESCE(NULLIF(TRIM(assignee), ''), assignee_agent_key)) AS actor_label,
159
+ assignee_agent_key,
160
+ assignee_agent_instance_id,
161
+ assignee_agent_session_id
162
+ FROM local_tasks
163
+ WHERE room_id = ?
164
+ AND status IN ('assigned', 'in_progress', 'blocked', 'in_review')
165
+ AND assignee_agent_key IS NOT NULL
166
+ AND TRIM(assignee_agent_key) <> ''
167
+ GROUP BY CASE
168
+ WHEN NULLIF(TRIM(assignee_agent_session_id), '') IS NOT NULL
169
+ THEN 'session:' || TRIM(assignee_agent_session_id)
170
+ WHEN NULLIF(TRIM(assignee_agent_instance_id), '') IS NOT NULL
171
+ THEN 'instance:' || TRIM(assignee_agent_key) || ':' || TRIM(assignee_agent_instance_id)
172
+ ELSE 'agent:' || TRIM(assignee_agent_key)
173
+ END
174
+ ORDER BY MIN(created_at) ASC
175
+ LIMIT 2
176
+ `)
177
+ .all(roomId);
178
+ return rows.map((row) => ({
179
+ kind: "work",
180
+ status: "active",
181
+ actor_label: String(row.actor_label || row.assignee_agent_key || ""),
182
+ agent_key: String(row.assignee_agent_key || ""),
183
+ agent_instance_id: typeof row.assignee_agent_instance_id === "string"
184
+ ? row.assignee_agent_instance_id
185
+ : null,
186
+ agent_session_id: typeof row.assignee_agent_session_id === "string"
187
+ ? row.assignee_agent_session_id
188
+ : null,
189
+ }));
190
+ }
191
+ async function getLocalTask(roomId, taskId) {
192
+ const database = await getDb();
193
+ const row = database
194
+ .prepare("SELECT * FROM local_tasks WHERE room_id = ? AND task_id = ?")
195
+ .get(roomId, taskId);
196
+ return row ? mapTaskRow(row, database) : null;
197
+ }
198
+ async function updateLocalTask(roomId, taskId, patch) {
199
+ const database = await getDb();
200
+ let observed = readLocalWorkLeases(database, roomId, taskId)[0] ?? null;
201
+ return runLocalSqliteWriteTransactionAsync(database, () => withWorkerStateFence(() => {
202
+ let row = database.prepare("SELECT * FROM local_tasks WHERE room_id=? AND task_id=?").get(roomId, taskId);
203
+ if (!row)
204
+ throw new Error("Task not found.");
205
+ if (patch.expected_no_work_lease === true && readLocalWorkLeases(database, roomId, taskId).length) {
206
+ throw new Error("The task lease changed. Refresh the task before trying again.");
207
+ }
208
+ const caller = currentWorkerCall();
209
+ const worker = caller?.agent_key ? {
210
+ agent_key: caller.agent_key, session_id: caller.session_id, actor_label: caller.actor_label || caller.agent_key,
211
+ agent_instance_id: caller.agent_instance_id,
212
+ } : null;
213
+ const supervised = worker ? assertLocalWorkLeaseWorker(database, roomId, worker) : false;
214
+ if (supervised && worker && patch.status === "assigned") {
215
+ if ((patch.assignee_agent_key != null && patch.assignee_agent_key !== worker.agent_key)
216
+ || (patch.assignee != null && patch.assignee !== worker.actor_label)) {
217
+ throw new Error("Use handoff_task_lease to assign work to another worker.");
218
+ }
219
+ observed = claimLocalWorkLease(database, roomId, taskId, worker);
220
+ row = database.prepare("SELECT * FROM local_tasks WHERE room_id=? AND task_id=?").get(roomId, taskId);
221
+ }
222
+ if (worker) {
223
+ assertLocalTaskLeaseMutation(database, row, worker, observed);
224
+ if ((supervised || observed) && ((patch.assignee !== undefined && patch.assignee !== row.assignee)
225
+ || (patch.assignee_agent_key !== undefined && patch.assignee_agent_key !== row.assignee_agent_key)
226
+ || (patch.assignee_agent_session_id !== undefined && patch.assignee_agent_session_id !== row.assignee_agent_session_id)
227
+ || (patch.assignee_agent_key !== undefined && patch.agent_session_id !== undefined && patch.agent_session_id !== row.assignee_agent_session_id)
228
+ || (patch.assignee_agent_instance_id !== undefined && patch.assignee_agent_instance_id !== row.assignee_agent_instance_id)
229
+ || (patch.assignee_agent_key !== undefined && patch.actor_instance_id !== undefined && patch.actor_instance_id !== row.assignee_agent_instance_id))) {
230
+ throw new Error("Use claim_task or handoff_task_lease to change task ownership.");
231
+ }
232
+ }
233
+ else if (observed || readLocalWorkLeases(database, roomId, taskId).length) {
234
+ throw new Error("A registered owning worker is required to update this leased task.");
235
+ }
236
+ const current = mapTaskRow(row, database);
237
+ const nextStatus = supervised && patch.status === "assigned" ? current.status
238
+ : patch.skip_transition_validation === true
239
+ ? typeof patch.status === "string" && patch.status.trim()
240
+ ? patch.status.trim()
241
+ : current.status
242
+ : resolveLocalTaskStatus(current.status, patch.status);
243
+ const assigneeAgentKey = patch.assignee_agent_key === undefined
244
+ ? current.assignee_agent_key
245
+ : typeof patch.assignee_agent_key === "string"
246
+ ? patch.assignee_agent_key
247
+ : null;
248
+ const assigneeAgentInstanceId = patch.assignee_agent_key === undefined || (worker && patch.assignee_agent_key === current.assignee_agent_key)
249
+ ? current.assignee_agent_instance_id
250
+ : assigneeAgentKey && typeof patch.assignee_agent_instance_id === "string"
251
+ ? patch.assignee_agent_instance_id
252
+ : assigneeAgentKey && typeof patch.actor_instance_id === "string"
253
+ ? patch.actor_instance_id
254
+ : null;
255
+ const assigneeAgentSessionId = patch.assignee_agent_key === undefined || (worker && patch.assignee_agent_key === current.assignee_agent_key)
256
+ ? current.assignee_agent_session_id
257
+ : assigneeAgentKey && typeof patch.assignee_agent_session_id === "string"
258
+ ? patch.assignee_agent_session_id
259
+ : assigneeAgentKey && typeof patch.agent_session_id === "string"
260
+ ? patch.agent_session_id
261
+ : null;
262
+ const workflowArtifacts = patch.workflow_artifacts === undefined
263
+ ? JSON.stringify(current.workflow_artifacts)
264
+ : JSON.stringify(Array.isArray(patch.workflow_artifacts) ? patch.workflow_artifacts : []);
265
+ const now = new Date().toISOString();
266
+ database
267
+ .prepare(`
268
+ UPDATE local_tasks
269
+ SET status = ?,
270
+ assignee = ?,
271
+ assignee_agent_key = ?,
272
+ assignee_agent_instance_id = ?,
273
+ assignee_agent_session_id = ?,
274
+ pr_url = ?,
275
+ workflow_artifacts_json = ?,
276
+ sync_dirty = 1,
277
+ updated_at = ?
278
+ WHERE room_id = ? AND task_id = ?
279
+ `)
280
+ .run(nextStatus, patch.assignee === undefined ? current.assignee : patch.assignee || null, assigneeAgentKey, assigneeAgentInstanceId, assigneeAgentSessionId, patch.pr_url === undefined ? current.pr_url : patch.pr_url || null, workflowArtifacts, now, roomId, taskId);
281
+ return mapTaskRow(database.prepare("SELECT * FROM local_tasks WHERE room_id=? AND task_id=?").get(roomId, taskId), database);
282
+ }));
283
+ }
284
+ async function changeLocalTaskWorkLease(roomId, taskId, input) {
285
+ const database = await getDb();
286
+ const observed = readLocalWorkLeases(database, roomId, taskId)[0];
287
+ if (!observed)
288
+ throw new Error("This task has no active work lease.");
289
+ return runLocalSqliteWriteTransactionAsync(database, () => withWorkerStateFence(() => {
290
+ const worker = currentWorkerCall();
291
+ if (!worker?.agent_key)
292
+ throw new Error("A registered owning worker is required to change this work lease.");
293
+ const result = changeLocalWorkLease(database, roomId, taskId, {
294
+ ...input, lease_id: input.lease_id ?? observed.id, epoch: input.epoch ?? observed.epoch,
295
+ }, {
296
+ agent_key: worker.agent_key, session_id: worker.session_id, actor_label: worker.actor_label || worker.agent_key,
297
+ });
298
+ const task = mapTaskRow(database.prepare("SELECT * FROM local_tasks WHERE room_id=? AND task_id=?").get(roomId, taskId), database);
299
+ return { action: input.action, task, ...result };
300
+ }));
301
+ }
302
+ async function claimLocalTaskReviewLease(roomId, taskId, input) {
303
+ const current = await getLocalTask(roomId, taskId);
304
+ if (!current)
305
+ throw new Error("Task not found.");
306
+ const actorKey = input.agent_key?.trim() || null;
307
+ if (actorKey &&
308
+ current.assignee_agent_key &&
309
+ current.assignee_agent_key === actorKey) {
310
+ throw new Error("A worker holding the task cannot also claim review authority.");
311
+ }
312
+ const currentReviewLease = current.active_leases?.find((lease) => lease.kind === "review");
313
+ if (currentReviewLease?.agent_key &&
314
+ actorKey &&
315
+ currentReviewLease.agent_key !== actorKey) {
316
+ throw new Error("Review authority is already held by another local reviewer.");
317
+ }
318
+ const database = await getDb();
319
+ const now = new Date().toISOString();
320
+ const leaseId = currentReviewLease?.id || `local_review_${Date.now()}_${Math.random().toString(36).slice(2)}`;
321
+ await runLocalSqliteWriteTransactionAsync(database, () => withWorkerStateFence(() => database
322
+ .prepare(`
323
+ UPDATE local_tasks
324
+ SET review_lease_id = ?,
325
+ review_holder_label = ?,
326
+ review_agent_key = ?,
327
+ review_agent_session_id = ?,
328
+ review_updated_at = ?,
329
+ updated_at = ?
330
+ WHERE room_id = ? AND task_id = ?
331
+ `)
332
+ .run(leaseId, input.holder_label?.trim() || actorKey || "Local reviewer", actorKey, input.agent_session_id?.trim() || null, now, now, roomId, taskId)));
333
+ const task = await getLocalTask(roomId, taskId);
334
+ const lease = task?.active_leases?.find((entry) => entry.id === leaseId);
335
+ if (!task || !lease)
336
+ throw new Error("Review authority could not be claimed.");
337
+ return { task, lease };
338
+ }
339
+ async function releaseLocalTaskReviewLease(roomId, taskId, input = {}) {
340
+ const current = await getLocalTask(roomId, taskId);
341
+ if (!current)
342
+ throw new Error("Task not found.");
343
+ const currentReviewLease = current.active_leases?.find((lease) => lease.kind === "review") || null;
344
+ if (input.lease_id &&
345
+ currentReviewLease &&
346
+ input.lease_id !== currentReviewLease.id) {
347
+ throw new Error("Review lease id did not match the active local review authority.");
348
+ }
349
+ const database = await getDb();
350
+ const now = new Date().toISOString();
351
+ await runLocalSqliteWriteTransactionAsync(database, () => withWorkerStateFence(() => database
352
+ .prepare(`
353
+ UPDATE local_tasks
354
+ SET review_lease_id = NULL,
355
+ review_holder_label = NULL,
356
+ review_agent_key = NULL,
357
+ review_agent_session_id = NULL,
358
+ review_updated_at = NULL,
359
+ updated_at = ?
360
+ WHERE room_id = ? AND task_id = ?
361
+ `)
362
+ .run(now, roomId, taskId)));
363
+ const task = await getLocalTask(roomId, taskId);
364
+ if (!task)
365
+ throw new Error("Task not found.");
366
+ return {
367
+ task,
368
+ released_lease: currentReviewLease
369
+ ? { ...currentReviewLease, status: "released" }
370
+ : null,
371
+ };
372
+ }
373
+ return { addLocalTask, listLocalTasks, listLocalActiveTaskOwnerLeases, getLocalTask, updateLocalTask, changeLocalTaskWorkLease, claimLocalTaskReviewLease, releaseLocalTaskReviewLease };
374
+ }