@lelouchhe/webagent 0.8.0 → 0.10.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/README.md +43 -15
- package/config.toml +7 -27
- package/dist/index.html +21 -5
- package/dist/js/app.INIQQEGD.js +5 -0
- package/dist/js/chunk.3CLGCUHW.js +1 -0
- package/dist/js/{chunk.CT5WBNGZ.js → chunk.7WADDFJZ.js} +50 -49
- package/dist/js/chunk.AOTG3PL7.js +20 -0
- package/dist/js/{login.2WA6DTGM.js → login.WMURU4NI.js} +1 -1
- package/dist/js/viewer.RHZMFYWJ.js +1 -0
- package/dist/login.html +2 -2
- package/dist/share-viewer.html +6 -6
- package/dist/{styles.00etlpgs.css → styles.01aj0l37.css} +186 -4
- package/dist/sw.js +6 -6
- package/lib/agent-key.js +6 -0
- package/lib/attachment-dispatch.js +60 -31
- package/lib/attachment-interceptor.js +7 -7
- package/lib/attachment-labels.js +1 -1
- package/lib/attachments.js +69 -7
- package/lib/auth-middleware.js +11 -4
- package/lib/auth.js +2 -2
- package/lib/bridge.js +209 -90
- package/lib/client-registry.js +12 -12
- package/lib/config.js +2 -31
- package/lib/event-handler.js +166 -85
- package/lib/files/limits.js +15 -0
- package/lib/files/paths.js +155 -0
- package/lib/files/routes.js +232 -0
- package/lib/home-path.js +35 -0
- package/lib/http-status.js +1 -0
- package/lib/mcp/capability.js +74 -0
- package/lib/mcp/server.js +148 -0
- package/lib/mcp/task-history.js +245 -0
- package/lib/mcp/task-host.js +253 -0
- package/lib/mcp/tools.js +168 -0
- package/lib/mode-bucket.js +1 -1
- package/lib/push-service.js +33 -35
- package/lib/routes.js +1022 -475
- package/lib/server.js +84 -34
- package/lib/share/routes.js +97 -85
- package/lib/shared/task-reference.js +20 -0
- package/lib/sse-manager.js +8 -8
- package/lib/store.js +992 -284
- package/lib/task-collaboration.js +15 -0
- package/lib/task-manager.js +1409 -0
- package/lib/task-path.js +131 -0
- package/lib/{session-state.js → task-state.js} +90 -38
- package/lib/task-tree-lock.js +74 -0
- package/lib/{sessions-anchor.js → tasks-anchor.js} +8 -7
- package/lib/tokens.js +1 -1
- package/lib/types.js +2 -2
- package/package.json +8 -1
- package/dist/js/app.XBFXH37R.js +0 -2
- package/dist/js/chunk.UMQMOGWO.js +0 -1
- package/dist/js/viewer.CVWXSKJM.js +0 -1
- package/lib/session-manager.js +0 -613
- package/lib/title-service.js +0 -95
package/lib/store.js
CHANGED
|
@@ -1,41 +1,231 @@
|
|
|
1
1
|
import Database from "better-sqlite3";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { mkdirSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
5
|
+
import { formatTaskPath, formatTaskReference, } from "./shared/task-reference.js";
|
|
6
|
+
export const ROOT_TASK_ID = "root";
|
|
4
7
|
export class MessageNotFoundError extends Error {
|
|
5
8
|
constructor(messageId) {
|
|
6
9
|
super(`Message not found: ${messageId}`);
|
|
7
10
|
this.name = "MessageNotFoundError";
|
|
8
11
|
}
|
|
9
12
|
}
|
|
13
|
+
function stringField(data, key) {
|
|
14
|
+
return typeof data[key] === "string" ? data[key] : undefined;
|
|
15
|
+
}
|
|
16
|
+
/** Normalize pre-title system events before any replay or egress path sees them. */
|
|
17
|
+
function migrateSystemMessageData(raw) {
|
|
18
|
+
const data = JSON.parse(raw);
|
|
19
|
+
const existingTitle = stringField(data, "title");
|
|
20
|
+
const body = stringField(data, "body");
|
|
21
|
+
// Older task-created rows used `title` for the child task title, while the
|
|
22
|
+
// new payload uses it for the visible system-message title. Their `body`
|
|
23
|
+
// already contains the old visible text, so preserve it as title-only.
|
|
24
|
+
if (data.kind === "task_created" &&
|
|
25
|
+
data.taskTitle === undefined &&
|
|
26
|
+
existingTitle !== undefined &&
|
|
27
|
+
body !== undefined) {
|
|
28
|
+
const migrated = { ...data, title: body };
|
|
29
|
+
delete migrated.body;
|
|
30
|
+
return JSON.stringify(migrated);
|
|
31
|
+
}
|
|
32
|
+
if (existingTitle?.trim())
|
|
33
|
+
return null;
|
|
34
|
+
const messageBody = stringField(data, "messageBody");
|
|
35
|
+
const migrated = { ...data };
|
|
36
|
+
if (data.kind === "collaboration" && messageBody !== undefined) {
|
|
37
|
+
const source = stringField(data, "sourceLabel") ??
|
|
38
|
+
stringField(data, "sourceTaskId") ??
|
|
39
|
+
"?";
|
|
40
|
+
const target = stringField(data, "targetLabel") ??
|
|
41
|
+
stringField(data, "targetTaskId") ??
|
|
42
|
+
"?";
|
|
43
|
+
migrated.title = `${formatTaskReference(source)} sent ${formatTaskReference(target)}`;
|
|
44
|
+
migrated.body = messageBody;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
migrated.title = body?.trim() ? body : "System message";
|
|
48
|
+
delete migrated.body;
|
|
49
|
+
}
|
|
50
|
+
delete migrated.messageBody;
|
|
51
|
+
return JSON.stringify(migrated);
|
|
52
|
+
}
|
|
10
53
|
export class Store {
|
|
11
54
|
db;
|
|
12
|
-
|
|
55
|
+
dataDir;
|
|
56
|
+
agentKey;
|
|
57
|
+
constructor(dataDir, agentKey) {
|
|
58
|
+
if (!agentKey)
|
|
59
|
+
throw new Error("agentKey is required");
|
|
60
|
+
this.agentKey = agentKey;
|
|
61
|
+
this.dataDir = dataDir;
|
|
13
62
|
mkdirSync(dataDir, { recursive: true });
|
|
14
63
|
this.db = new Database(join(dataDir, "webagent.db"));
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
64
|
+
try {
|
|
65
|
+
this.db.pragma("journal_mode = WAL");
|
|
66
|
+
this.db.pragma("foreign_keys = ON");
|
|
67
|
+
this.assertSupportedSchema();
|
|
68
|
+
this.initializeSchema();
|
|
69
|
+
this.migrateSystemMessagePayloads();
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
this.db.close();
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
assertSupportedSchema() {
|
|
77
|
+
const legacy = this.db
|
|
78
|
+
.prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'sessions'")
|
|
79
|
+
.get();
|
|
80
|
+
const requiredColumns = {
|
|
81
|
+
tasks: [
|
|
82
|
+
"id",
|
|
83
|
+
"cwd",
|
|
84
|
+
"title",
|
|
85
|
+
"brief",
|
|
86
|
+
"workflow_status",
|
|
87
|
+
"parent_id",
|
|
88
|
+
"pending_compact_summary",
|
|
89
|
+
"model",
|
|
90
|
+
"mode",
|
|
91
|
+
"reasoning_effort",
|
|
92
|
+
"source",
|
|
93
|
+
"deleted_at",
|
|
94
|
+
"created_at",
|
|
95
|
+
"last_active_at",
|
|
96
|
+
],
|
|
97
|
+
agent_sessions: [
|
|
98
|
+
"agent_key",
|
|
99
|
+
"agent_session_id",
|
|
100
|
+
"task_id",
|
|
101
|
+
"created_at",
|
|
102
|
+
],
|
|
103
|
+
events: [
|
|
104
|
+
"id",
|
|
105
|
+
"task_id",
|
|
106
|
+
"seq",
|
|
107
|
+
"type",
|
|
108
|
+
"data",
|
|
109
|
+
"created_at",
|
|
110
|
+
"from_ref",
|
|
111
|
+
],
|
|
112
|
+
shares: [
|
|
113
|
+
"token",
|
|
114
|
+
"task_id",
|
|
115
|
+
"shared_at",
|
|
116
|
+
"share_snapshot_seq",
|
|
117
|
+
"ttl_hours",
|
|
118
|
+
"display_name",
|
|
119
|
+
"owner_label",
|
|
120
|
+
"created_at",
|
|
121
|
+
"last_accessed_at",
|
|
122
|
+
],
|
|
123
|
+
attachments: [
|
|
124
|
+
"id",
|
|
125
|
+
"task_id",
|
|
126
|
+
"kind",
|
|
127
|
+
"name",
|
|
128
|
+
"mime",
|
|
129
|
+
"size",
|
|
130
|
+
"realpath",
|
|
131
|
+
"upload_seq",
|
|
132
|
+
"width",
|
|
133
|
+
"height",
|
|
134
|
+
"created_at",
|
|
135
|
+
],
|
|
136
|
+
inbox_messages: [
|
|
137
|
+
"id",
|
|
138
|
+
"from_ref",
|
|
139
|
+
"from_label",
|
|
140
|
+
"to_ref",
|
|
141
|
+
"deliver",
|
|
142
|
+
"dedup_key",
|
|
143
|
+
"title",
|
|
144
|
+
"body",
|
|
145
|
+
"cwd",
|
|
146
|
+
"created_at",
|
|
147
|
+
],
|
|
148
|
+
messages: [
|
|
149
|
+
"id",
|
|
150
|
+
"source_task_id",
|
|
151
|
+
"direct_target_task_id",
|
|
152
|
+
"source_actor",
|
|
153
|
+
"body",
|
|
154
|
+
"created_at",
|
|
155
|
+
],
|
|
156
|
+
message_projections: ["message_id", "task_id", "role", "created_at"],
|
|
157
|
+
deliveries: [
|
|
158
|
+
"id",
|
|
159
|
+
"message_id",
|
|
160
|
+
"recipient_task_id",
|
|
161
|
+
"idempotency_key",
|
|
162
|
+
"status",
|
|
163
|
+
"queued_at",
|
|
164
|
+
"claimed_at",
|
|
165
|
+
"delivered_at",
|
|
166
|
+
"failed_at",
|
|
167
|
+
"failure_reason",
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
const incompatible = legacy
|
|
171
|
+
? "legacy sessions table"
|
|
172
|
+
: Object.entries(requiredColumns).find(([table, columns]) => {
|
|
173
|
+
const exists = this.db
|
|
174
|
+
.prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
175
|
+
.get(table);
|
|
176
|
+
if (!exists)
|
|
177
|
+
return false;
|
|
178
|
+
const actual = new Set(this.db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
|
|
179
|
+
return (columns.some((column) => !actual.has(column)) ||
|
|
180
|
+
[...actual].some((column) => !columns.includes(column)) ||
|
|
181
|
+
(table === "events" &&
|
|
182
|
+
this.db
|
|
183
|
+
.prepare("SELECT 1 AS present FROM events WHERE from_ref IS NULL LIMIT 1")
|
|
184
|
+
.get() !== undefined));
|
|
185
|
+
})?.[0];
|
|
186
|
+
if (incompatible) {
|
|
187
|
+
throw new Error(`Pre-1.0 data directory detected (${incompatible}). Back up and delete the data directory before restarting: ${this.dataDir}`);
|
|
188
|
+
}
|
|
20
189
|
}
|
|
21
|
-
|
|
190
|
+
initializeSchema() {
|
|
22
191
|
this.db.exec(`
|
|
23
|
-
CREATE TABLE IF NOT EXISTS
|
|
192
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
24
193
|
id TEXT PRIMARY KEY,
|
|
25
194
|
cwd TEXT NOT NULL,
|
|
26
195
|
title TEXT,
|
|
196
|
+
brief TEXT NOT NULL DEFAULT '',
|
|
197
|
+
workflow_status TEXT NOT NULL DEFAULT 'idle'
|
|
198
|
+
CHECK (workflow_status IN ('running', 'idle', 'blocked', 'done')),
|
|
199
|
+
parent_id TEXT REFERENCES tasks(id),
|
|
200
|
+
pending_compact_summary TEXT,
|
|
201
|
+
model TEXT,
|
|
202
|
+
mode TEXT,
|
|
203
|
+
reasoning_effort TEXT,
|
|
204
|
+
source TEXT NOT NULL DEFAULT 'auto',
|
|
205
|
+
deleted_at INTEGER,
|
|
27
206
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
|
|
28
207
|
last_active_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
29
208
|
);
|
|
209
|
+
CREATE TABLE IF NOT EXISTS agent_sessions (
|
|
210
|
+
agent_key TEXT NOT NULL,
|
|
211
|
+
agent_session_id TEXT NOT NULL,
|
|
212
|
+
task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE,
|
|
213
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
|
|
214
|
+
PRIMARY KEY (agent_key, agent_session_id)
|
|
215
|
+
);
|
|
216
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_sessions_task
|
|
217
|
+
ON agent_sessions(task_id)
|
|
218
|
+
WHERE task_id IS NOT NULL;
|
|
30
219
|
CREATE TABLE IF NOT EXISTS events (
|
|
31
220
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
32
|
-
|
|
221
|
+
task_id TEXT NOT NULL REFERENCES tasks(id),
|
|
33
222
|
seq INTEGER NOT NULL,
|
|
34
223
|
type TEXT NOT NULL,
|
|
35
224
|
data TEXT NOT NULL DEFAULT '{}',
|
|
36
|
-
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
225
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
|
|
226
|
+
from_ref TEXT NOT NULL
|
|
37
227
|
);
|
|
38
|
-
CREATE INDEX IF NOT EXISTS
|
|
228
|
+
CREATE INDEX IF NOT EXISTS idx_events_task ON events(task_id, seq);
|
|
39
229
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|
40
230
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
41
231
|
endpoint TEXT NOT NULL UNIQUE,
|
|
@@ -44,39 +234,13 @@ export class Store {
|
|
|
44
234
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
45
235
|
);
|
|
46
236
|
`);
|
|
47
|
-
//
|
|
48
|
-
const cols = this.db.prepare("PRAGMA table_info(sessions)").all();
|
|
49
|
-
const colNames = new Set(cols.map((c) => c.name));
|
|
50
|
-
if (!colNames.has("title")) {
|
|
51
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN title TEXT");
|
|
52
|
-
}
|
|
53
|
-
if (!colNames.has("last_active_at")) {
|
|
54
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN last_active_at TEXT");
|
|
55
|
-
// Backfill from created_at
|
|
56
|
-
this.db.exec("UPDATE sessions SET last_active_at = created_at WHERE last_active_at IS NULL");
|
|
57
|
-
}
|
|
58
|
-
if (!colNames.has("model")) {
|
|
59
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN model TEXT");
|
|
60
|
-
}
|
|
61
|
-
if (!colNames.has("mode")) {
|
|
62
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN mode TEXT");
|
|
63
|
-
}
|
|
64
|
-
if (!colNames.has("reasoning_effort")) {
|
|
65
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN reasoning_effort TEXT");
|
|
66
|
-
}
|
|
67
|
-
if (!colNames.has("source")) {
|
|
68
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'auto'");
|
|
69
|
-
}
|
|
70
|
-
if (!colNames.has("deleted_at")) {
|
|
71
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN deleted_at INTEGER");
|
|
72
|
-
}
|
|
73
|
-
// messages — pending unbound notifications. POST /api/v1/messages with
|
|
237
|
+
// inbox_messages — pending unbound notifications. POST /api/v1/messages with
|
|
74
238
|
// `to = "user"` lands here; consumeMessageTx transactionally moves the
|
|
75
|
-
// content into an existing ACP-backed
|
|
76
|
-
// row. Bound messages (to =
|
|
239
|
+
// content into an existing ACP-backed task's events and deletes the
|
|
240
|
+
// row. Bound messages (to = task id) skip this table entirely and go
|
|
77
241
|
// straight to `events`.
|
|
78
242
|
this.db.exec(`
|
|
79
|
-
CREATE TABLE IF NOT EXISTS
|
|
243
|
+
CREATE TABLE IF NOT EXISTS inbox_messages (
|
|
80
244
|
id TEXT PRIMARY KEY,
|
|
81
245
|
from_ref TEXT NOT NULL,
|
|
82
246
|
from_label TEXT,
|
|
@@ -88,85 +252,85 @@ export class Store {
|
|
|
88
252
|
cwd TEXT,
|
|
89
253
|
created_at INTEGER NOT NULL
|
|
90
254
|
);
|
|
91
|
-
CREATE INDEX IF NOT EXISTS
|
|
92
|
-
CREATE INDEX IF NOT EXISTS
|
|
255
|
+
CREATE INDEX IF NOT EXISTS idx_inbox_messages_created ON inbox_messages (created_at);
|
|
256
|
+
CREATE INDEX IF NOT EXISTS idx_inbox_messages_dedup ON inbox_messages (to_ref, dedup_key);
|
|
257
|
+
|
|
258
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
259
|
+
id TEXT PRIMARY KEY,
|
|
260
|
+
source_task_id TEXT NOT NULL,
|
|
261
|
+
direct_target_task_id TEXT NOT NULL,
|
|
262
|
+
source_actor TEXT NOT NULL CHECK (source_actor IN ('user', 'agent', 'system')),
|
|
263
|
+
body TEXT NOT NULL,
|
|
264
|
+
created_at INTEGER NOT NULL
|
|
265
|
+
);
|
|
266
|
+
CREATE INDEX IF NOT EXISTS idx_messages_source_created
|
|
267
|
+
ON messages (source_task_id, created_at);
|
|
268
|
+
CREATE INDEX IF NOT EXISTS idx_messages_target_created
|
|
269
|
+
ON messages (direct_target_task_id, created_at);
|
|
270
|
+
|
|
271
|
+
CREATE TABLE IF NOT EXISTS message_projections (
|
|
272
|
+
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
273
|
+
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
274
|
+
role TEXT NOT NULL CHECK (role IN ('source', 'target', 'supervisor')),
|
|
275
|
+
created_at INTEGER NOT NULL,
|
|
276
|
+
PRIMARY KEY (message_id, task_id)
|
|
277
|
+
);
|
|
278
|
+
CREATE INDEX IF NOT EXISTS idx_message_projections_task_created
|
|
279
|
+
ON message_projections (task_id, created_at);
|
|
280
|
+
|
|
281
|
+
CREATE TABLE IF NOT EXISTS deliveries (
|
|
282
|
+
id TEXT PRIMARY KEY,
|
|
283
|
+
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
284
|
+
recipient_task_id TEXT NOT NULL,
|
|
285
|
+
idempotency_key TEXT NOT NULL UNIQUE,
|
|
286
|
+
status TEXT NOT NULL CHECK (status IN ('queued', 'draining', 'delivered', 'failed')),
|
|
287
|
+
queued_at INTEGER NOT NULL,
|
|
288
|
+
claimed_at INTEGER,
|
|
289
|
+
delivered_at INTEGER,
|
|
290
|
+
failed_at INTEGER,
|
|
291
|
+
failure_reason TEXT
|
|
292
|
+
);
|
|
293
|
+
CREATE INDEX IF NOT EXISTS idx_deliveries_recipient_status_queued
|
|
294
|
+
ON deliveries (recipient_task_id, status, queued_at);
|
|
93
295
|
`);
|
|
94
296
|
// client-server-split M2: idempotency for mutating REST calls. Stores
|
|
95
|
-
// the cached response per (
|
|
297
|
+
// the cached response per (task_id, client_op_id) so retries (after
|
|
96
298
|
// network/SSE reconnect) return the same result instead of re-executing
|
|
97
299
|
// side effects.
|
|
98
300
|
this.db.exec(`
|
|
99
301
|
CREATE TABLE IF NOT EXISTS client_ops (
|
|
100
|
-
|
|
302
|
+
task_id TEXT NOT NULL,
|
|
101
303
|
client_op_id TEXT NOT NULL,
|
|
102
304
|
result_json TEXT NOT NULL,
|
|
103
305
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
|
|
104
|
-
PRIMARY KEY (
|
|
306
|
+
PRIMARY KEY (task_id, client_op_id)
|
|
105
307
|
);
|
|
106
308
|
`);
|
|
107
309
|
// recent_paths: LRU path list for /new menu
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
FROM sessions GROUP BY cwd;
|
|
123
|
-
`);
|
|
124
|
-
}
|
|
125
|
-
// events.from_ref — origin marker for every event row.
|
|
126
|
-
// Values: 'user' | 'system' | 'agent' | 'msg:<id>'. The 'msg:<id>'
|
|
127
|
-
// form is reserved for events authored by consuming an inbox message
|
|
128
|
-
// (see C7+). Bucketed backfill runs once for legacy rows.
|
|
129
|
-
const eventCols = this.db
|
|
130
|
-
.prepare("PRAGMA table_info(events)")
|
|
131
|
-
.all();
|
|
132
|
-
const eventColNames = new Set(eventCols.map((c) => c.name));
|
|
133
|
-
if (!eventColNames.has("from_ref")) {
|
|
134
|
-
this.db.exec("ALTER TABLE events ADD COLUMN from_ref TEXT");
|
|
135
|
-
// Buckets:
|
|
136
|
-
// user — user-authored input
|
|
137
|
-
// system — client-originated side-channel actions + host responses
|
|
138
|
-
// (permission responses, local bash, system messages)
|
|
139
|
-
// agent — everything else (assistant_message, thinking, tool_call,
|
|
140
|
-
// tool_call_update, plan, prompt_done, permission_request,
|
|
141
|
-
// etc.)
|
|
142
|
-
this.db.exec(`
|
|
143
|
-
UPDATE events SET from_ref = CASE
|
|
144
|
-
WHEN type = 'user_message' THEN 'user'
|
|
145
|
-
WHEN type IN ('permission_response', 'bash_command', 'bash_result',
|
|
146
|
-
'system_message') THEN 'system'
|
|
147
|
-
ELSE 'agent'
|
|
148
|
-
END
|
|
149
|
-
WHERE from_ref IS NULL
|
|
150
|
-
`);
|
|
151
|
-
}
|
|
152
|
-
// One-time orphan cleanup: rows whose session_id no longer exists in
|
|
153
|
-
// `sessions`. Pre-FK writes could leave these behind (a session DELETE
|
|
154
|
-
// that didn't cascade because the FK pragma was off). Must run before
|
|
155
|
-
// enabling FK pragma.
|
|
156
|
-
this.db.exec("DELETE FROM events WHERE session_id NOT IN (SELECT id FROM sessions)");
|
|
157
|
-
// Secondary index for events queried by (session_id, type, created_at)
|
|
158
|
-
// -- used by upcoming inbox/message consume queries.
|
|
159
|
-
this.db.exec("CREATE INDEX IF NOT EXISTS idx_events_type ON events(session_id, type, created_at)");
|
|
310
|
+
this.db.exec(`
|
|
311
|
+
CREATE TABLE IF NOT EXISTS recent_paths (
|
|
312
|
+
cwd TEXT PRIMARY KEY,
|
|
313
|
+
last_used_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
314
|
+
);
|
|
315
|
+
`);
|
|
316
|
+
this.db.exec(`
|
|
317
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_parent_title_live
|
|
318
|
+
ON tasks (parent_id, title)
|
|
319
|
+
WHERE deleted_at IS NULL AND title IS NOT NULL;
|
|
320
|
+
`);
|
|
321
|
+
// Secondary index for events queried by (task_id, type, created_at)
|
|
322
|
+
// -- used by inbox/message consume queries.
|
|
323
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_events_type ON events(task_id, type, created_at)");
|
|
160
324
|
// shares — public read-only share links (share-plan §4.1).
|
|
161
325
|
// State machine: preview (shared_at NULL) → active (shared_at set).
|
|
162
326
|
// Revocation = hard-delete the row (no audit trail kept).
|
|
163
|
-
// Multiple active siblings per
|
|
327
|
+
// Multiple active siblings per task allowed (v4 multi-share).
|
|
164
328
|
// Partial unique index enforces at most one un-activated preview per
|
|
165
|
-
//
|
|
329
|
+
// task at any time.
|
|
166
330
|
this.db.exec(`
|
|
167
331
|
CREATE TABLE IF NOT EXISTS shares (
|
|
168
332
|
token TEXT PRIMARY KEY,
|
|
169
|
-
|
|
333
|
+
task_id TEXT NOT NULL REFERENCES tasks(id),
|
|
170
334
|
shared_at INTEGER,
|
|
171
335
|
share_snapshot_seq INTEGER NOT NULL,
|
|
172
336
|
ttl_hours INTEGER,
|
|
@@ -175,32 +339,17 @@ export class Store {
|
|
|
175
339
|
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER) * 1000),
|
|
176
340
|
last_accessed_at INTEGER
|
|
177
341
|
);
|
|
178
|
-
CREATE INDEX IF NOT EXISTS
|
|
342
|
+
CREATE INDEX IF NOT EXISTS idx_shares_task ON shares(task_id, created_at DESC);
|
|
179
343
|
`);
|
|
180
|
-
// Migrate: drop revoked_at column from existing tables (v0.5+).
|
|
181
|
-
// Revocation is now hard-delete; kept rows are always live.
|
|
182
|
-
const shareCols = this.db
|
|
183
|
-
.prepare("PRAGMA table_info(shares)")
|
|
184
|
-
.all();
|
|
185
|
-
const shareColNames = new Set(shareCols.map((c) => c.name));
|
|
186
|
-
if (shareColNames.has("revoked_at")) {
|
|
187
|
-
// Hard-delete any pre-existing revoked rows so the migration
|
|
188
|
-
// doesn't resurrect them as "live" shares after dropping the column.
|
|
189
|
-
this.db.exec("DELETE FROM shares WHERE revoked_at IS NOT NULL");
|
|
190
|
-
// Drop the partial unique index that references revoked_at, then
|
|
191
|
-
// the column, then recreate the index without the revoked_at clause.
|
|
192
|
-
this.db.exec("DROP INDEX IF EXISTS shares_one_active_preview");
|
|
193
|
-
this.db.exec("ALTER TABLE shares DROP COLUMN revoked_at");
|
|
194
|
-
}
|
|
195
344
|
this.db.exec(`
|
|
196
345
|
CREATE UNIQUE INDEX IF NOT EXISTS shares_one_active_preview
|
|
197
|
-
ON shares(
|
|
346
|
+
ON shares(task_id)
|
|
198
347
|
WHERE shared_at IS NULL;
|
|
199
348
|
`);
|
|
200
|
-
// attachments — server-managed file uploads bound to a
|
|
201
|
-
// Lifecycle =
|
|
202
|
-
//
|
|
203
|
-
//
|
|
349
|
+
// attachments — server-managed file uploads bound to a task.
|
|
350
|
+
// Lifecycle = task lifecycle: FK CASCADE removes the row when the
|
|
351
|
+
// task row is deleted (hard-delete path). Tombstoned (soft-deleted)
|
|
352
|
+
// tasks keep the row alive so the share viewer can still resolve
|
|
204
353
|
// file references for active shares.
|
|
205
354
|
//
|
|
206
355
|
// upload_seq = MAX(events.seq) at upload time. The share proxy uses
|
|
@@ -213,7 +362,7 @@ export class Store {
|
|
|
213
362
|
this.db.exec(`
|
|
214
363
|
CREATE TABLE IF NOT EXISTS attachments (
|
|
215
364
|
id TEXT PRIMARY KEY,
|
|
216
|
-
|
|
365
|
+
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
217
366
|
kind TEXT NOT NULL,
|
|
218
367
|
name TEXT NOT NULL,
|
|
219
368
|
mime TEXT NOT NULL,
|
|
@@ -224,18 +373,8 @@ export class Store {
|
|
|
224
373
|
height INTEGER,
|
|
225
374
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
226
375
|
);
|
|
227
|
-
CREATE INDEX IF NOT EXISTS
|
|
376
|
+
CREATE INDEX IF NOT EXISTS idx_attachments_task ON attachments(task_id);
|
|
228
377
|
`);
|
|
229
|
-
const attachmentCols = this.db
|
|
230
|
-
.prepare("PRAGMA table_info(attachments)")
|
|
231
|
-
.all();
|
|
232
|
-
const attachmentColNames = new Set(attachmentCols.map((c) => c.name));
|
|
233
|
-
if (!attachmentColNames.has("width")) {
|
|
234
|
-
this.db.exec("ALTER TABLE attachments ADD COLUMN width INTEGER");
|
|
235
|
-
}
|
|
236
|
-
if (!attachmentColNames.has("height")) {
|
|
237
|
-
this.db.exec("ALTER TABLE attachments ADD COLUMN height INTEGER");
|
|
238
|
-
}
|
|
239
378
|
// owner_prefs — key-value store for owner-scoped defaults (display_name,
|
|
240
379
|
// last /by selection, etc). Single-user model = single owner scope.
|
|
241
380
|
// Stored as plain key/value so we don't grow a new table per pref.
|
|
@@ -247,156 +386,519 @@ export class Store {
|
|
|
247
386
|
);
|
|
248
387
|
`);
|
|
249
388
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
.
|
|
256
|
-
|
|
389
|
+
/** Normalize the pre-title system_message payload in place. This is
|
|
390
|
+
* idempotent and runs before any event can be replayed or returned. */
|
|
391
|
+
migrateSystemMessagePayloads() {
|
|
392
|
+
const rows = this.db
|
|
393
|
+
.prepare("SELECT id, data FROM events WHERE type = 'system_message'")
|
|
394
|
+
.all();
|
|
395
|
+
const update = this.db.prepare("UPDATE events SET data = ? WHERE id = ?");
|
|
396
|
+
this.db.transaction(() => {
|
|
397
|
+
for (const row of rows) {
|
|
398
|
+
const migrated = migrateSystemMessageData(row.data);
|
|
399
|
+
if (migrated !== null)
|
|
400
|
+
update.run(migrated, row.id);
|
|
401
|
+
}
|
|
402
|
+
})();
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Ensure the reserved Root record exists and attach existing live tasks
|
|
406
|
+
* that do not have a parent. This is additive and keeps every old task,
|
|
407
|
+
* event, attachment, and share intact; the Root's ACP binding is created by
|
|
408
|
+
* SessionManager after the bridge is ready.
|
|
409
|
+
*
|
|
410
|
+
* The default title is the literal "root": it is only applied while the
|
|
411
|
+
* title is still NULL, so a user rename survives restarts. The non-null
|
|
412
|
+
* title also keeps title generation from ever overwriting Root.
|
|
413
|
+
*/
|
|
414
|
+
ensureRootTask(cwd) {
|
|
415
|
+
return this.db.transaction(() => {
|
|
416
|
+
this.db
|
|
417
|
+
.prepare("INSERT OR IGNORE INTO tasks (id, cwd, source, parent_id, title) VALUES (?, ?, 'root', NULL, 'root')")
|
|
418
|
+
.run("root", cwd);
|
|
419
|
+
this.db
|
|
420
|
+
.prepare("UPDATE tasks SET parent_id = NULL WHERE id = ?")
|
|
421
|
+
.run("root");
|
|
422
|
+
// Default title only while NULL so a user rename survives restarts.
|
|
423
|
+
this.db
|
|
424
|
+
.prepare("UPDATE tasks SET title = 'root' WHERE id = ? AND title IS NULL")
|
|
425
|
+
.run("root");
|
|
426
|
+
this.db
|
|
427
|
+
.prepare(`UPDATE tasks
|
|
428
|
+
SET parent_id = ?
|
|
429
|
+
WHERE id != ? AND parent_id IS NULL AND deleted_at IS NULL`)
|
|
430
|
+
.run("root", "root");
|
|
431
|
+
return this.db
|
|
432
|
+
.prepare("SELECT * FROM tasks WHERE id = ?")
|
|
433
|
+
.get("root");
|
|
434
|
+
})();
|
|
257
435
|
}
|
|
258
|
-
|
|
436
|
+
createTask(id, cwd, source = "auto", agentSessionId = id, parentId = null, opts = {}) {
|
|
437
|
+
return this.db.transaction(() => {
|
|
438
|
+
this.db
|
|
439
|
+
.prepare(`INSERT INTO tasks
|
|
440
|
+
(id, cwd, source, parent_id, title, brief, workflow_status)
|
|
441
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
442
|
+
.run(id, cwd, source, parentId, opts.title ?? id, opts.brief ?? "", opts.workflowStatus ?? "idle");
|
|
443
|
+
this.db
|
|
444
|
+
.prepare("INSERT INTO agent_sessions (agent_key, agent_session_id, task_id) VALUES (?, ?, ?)")
|
|
445
|
+
.run(this.agentKey, agentSessionId, id);
|
|
446
|
+
if (opts.initialMessage) {
|
|
447
|
+
this.createCollaborationMessage({
|
|
448
|
+
...opts.initialMessage,
|
|
449
|
+
directTargetTaskId: id,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
return this.db
|
|
453
|
+
.prepare("SELECT * FROM tasks WHERE id = ?")
|
|
454
|
+
.get(id);
|
|
455
|
+
})();
|
|
456
|
+
}
|
|
457
|
+
listTasks(opts) {
|
|
259
458
|
if (opts?.source) {
|
|
260
459
|
return this.db
|
|
261
|
-
.prepare(
|
|
262
|
-
|
|
460
|
+
.prepare(`SELECT s.*,
|
|
461
|
+
EXISTS (
|
|
462
|
+
SELECT 1 FROM events e
|
|
463
|
+
WHERE e.task_id = s.id AND e.type = 'user_message'
|
|
464
|
+
) OR EXISTS (
|
|
465
|
+
SELECT 1 FROM messages m
|
|
466
|
+
WHERE m.source_actor = 'user'
|
|
467
|
+
AND (m.source_task_id = s.id OR m.direct_target_task_id = s.id)
|
|
468
|
+
) AS has_user_input
|
|
469
|
+
FROM tasks s
|
|
470
|
+
JOIN agent_sessions a ON a.task_id = s.id
|
|
471
|
+
WHERE a.agent_key = ? AND s.source = ? AND s.deleted_at IS NULL
|
|
472
|
+
ORDER BY COALESCE(s.last_active_at, s.created_at) DESC`)
|
|
473
|
+
.all(this.agentKey, opts.source);
|
|
263
474
|
}
|
|
264
475
|
return this.db
|
|
265
|
-
.prepare(
|
|
266
|
-
|
|
476
|
+
.prepare(`SELECT s.*,
|
|
477
|
+
EXISTS (
|
|
478
|
+
SELECT 1 FROM events e
|
|
479
|
+
WHERE e.task_id = s.id AND e.type = 'user_message'
|
|
480
|
+
) OR EXISTS (
|
|
481
|
+
SELECT 1 FROM messages m
|
|
482
|
+
WHERE m.source_actor = 'user'
|
|
483
|
+
AND (m.source_task_id = s.id OR m.direct_target_task_id = s.id)
|
|
484
|
+
) AS has_user_input
|
|
485
|
+
FROM tasks s
|
|
486
|
+
JOIN agent_sessions a ON a.task_id = s.id
|
|
487
|
+
WHERE a.agent_key = ? AND s.deleted_at IS NULL
|
|
488
|
+
ORDER BY COALESCE(s.last_active_at, s.created_at) DESC`)
|
|
489
|
+
.all(this.agentKey);
|
|
267
490
|
}
|
|
268
|
-
/** Returns live
|
|
269
|
-
|
|
491
|
+
/** Returns live tasks only. Soft-deleted (tombstone) rows are hidden. */
|
|
492
|
+
getTask(id) {
|
|
270
493
|
return this.db
|
|
271
|
-
.prepare(
|
|
272
|
-
|
|
494
|
+
.prepare(`SELECT s.* FROM tasks s
|
|
495
|
+
JOIN agent_sessions a ON a.task_id = s.id
|
|
496
|
+
WHERE s.id = ? AND a.agent_key = ? AND s.deleted_at IS NULL`)
|
|
497
|
+
.get(id, this.agentKey);
|
|
498
|
+
}
|
|
499
|
+
registerInternalAgentSession(agentSessionId) {
|
|
500
|
+
this.db
|
|
501
|
+
.prepare("INSERT OR IGNORE INTO agent_sessions (agent_key, agent_session_id, task_id) VALUES (?, ?, NULL)")
|
|
502
|
+
.run(this.agentKey, agentSessionId);
|
|
503
|
+
const row = this.db
|
|
504
|
+
.prepare("SELECT * FROM agent_sessions WHERE agent_key = ? AND agent_session_id = ?")
|
|
505
|
+
.get(this.agentKey, agentSessionId);
|
|
506
|
+
if (row.task_id) {
|
|
507
|
+
throw new Error("Agent reused a user-visible task ID internally");
|
|
508
|
+
}
|
|
509
|
+
return row;
|
|
510
|
+
}
|
|
511
|
+
getAgentSessionId(taskId) {
|
|
512
|
+
return this.db
|
|
513
|
+
.prepare("SELECT agent_session_id FROM agent_sessions WHERE agent_key = ? AND task_id = ?")
|
|
514
|
+
.get(this.agentKey, taskId)?.agent_session_id;
|
|
515
|
+
}
|
|
516
|
+
getTaskId(agentSessionId) {
|
|
517
|
+
return this.db
|
|
518
|
+
.prepare("SELECT task_id FROM agent_sessions WHERE agent_key = ? AND agent_session_id = ? AND task_id IS NOT NULL")
|
|
519
|
+
.get(this.agentKey, agentSessionId)?.task_id;
|
|
520
|
+
}
|
|
521
|
+
getAgentSessionBinding(taskId) {
|
|
522
|
+
return this.db
|
|
523
|
+
.prepare("SELECT * FROM agent_sessions WHERE agent_key = ? AND task_id = ?")
|
|
524
|
+
.get(this.agentKey, taskId);
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Replace the current ACP execution for a stable WebAgent task.
|
|
528
|
+
* The previous binding row is removed: its execution is explicitly
|
|
529
|
+
* retired by the caller and must never accept WebAgent events again.
|
|
530
|
+
*/
|
|
531
|
+
rotateAgentSession(taskId, agentSessionId, cwd) {
|
|
532
|
+
return this.db.transaction(() => {
|
|
533
|
+
const current = this.getAgentSessionBinding(taskId);
|
|
534
|
+
if (!current)
|
|
535
|
+
throw new Error(`Task not found: ${taskId}`);
|
|
536
|
+
// Persist a requested cwd even when the agent returns the same
|
|
537
|
+
// execution id (no-op rotation); the caller decides whether to retire
|
|
538
|
+
// based on whether the binding actually moved.
|
|
539
|
+
if (cwd !== undefined) {
|
|
540
|
+
this.db
|
|
541
|
+
.prepare("UPDATE tasks SET cwd = ? WHERE id = ?")
|
|
542
|
+
.run(cwd, taskId);
|
|
543
|
+
}
|
|
544
|
+
if (current.agent_session_id === agentSessionId)
|
|
545
|
+
return current;
|
|
546
|
+
this.db
|
|
547
|
+
.prepare("DELETE FROM agent_sessions WHERE agent_key = ? AND task_id = ?")
|
|
548
|
+
.run(this.agentKey, taskId);
|
|
549
|
+
this.db
|
|
550
|
+
.prepare("INSERT INTO agent_sessions (agent_key, agent_session_id, task_id) VALUES (?, ?, ?)")
|
|
551
|
+
.run(this.agentKey, agentSessionId, taskId);
|
|
552
|
+
return this.getAgentSessionBinding(taskId);
|
|
553
|
+
})();
|
|
554
|
+
}
|
|
555
|
+
/** Bind an ACP execution to an existing WebAgent task without creating a row. */
|
|
556
|
+
bindAgentSession(taskId, agentSessionId) {
|
|
557
|
+
return this.db.transaction(() => {
|
|
558
|
+
if (!this.getTaskIncludingDeleted(taskId)) {
|
|
559
|
+
throw new Error(`Task not found: ${taskId}`);
|
|
560
|
+
}
|
|
561
|
+
const current = this.getAgentSessionBinding(taskId);
|
|
562
|
+
if (current) {
|
|
563
|
+
if (current.agent_session_id === agentSessionId)
|
|
564
|
+
return current;
|
|
565
|
+
throw new Error(`Task already has an ACP binding: ${taskId}`);
|
|
566
|
+
}
|
|
567
|
+
this.db
|
|
568
|
+
.prepare("INSERT INTO agent_sessions (agent_key, agent_session_id, task_id) VALUES (?, ?, ?)")
|
|
569
|
+
.run(this.agentKey, agentSessionId, taskId);
|
|
570
|
+
return this.getAgentSessionBinding(taskId);
|
|
571
|
+
})();
|
|
572
|
+
}
|
|
573
|
+
ownsTask(taskId) {
|
|
574
|
+
return this.getAgentSessionBinding(taskId) !== undefined;
|
|
273
575
|
}
|
|
274
576
|
/**
|
|
275
|
-
* Returns a
|
|
577
|
+
* Returns a task row even if soft-deleted. Used by the public share
|
|
276
578
|
* viewer, which must keep working after the owner deletes the source
|
|
277
|
-
*
|
|
579
|
+
* task (events stay alive as long as any active share references them).
|
|
580
|
+
*/
|
|
581
|
+
getTaskIncludingDeleted(id) {
|
|
582
|
+
return this.db.prepare("SELECT * FROM tasks WHERE id = ?").get(id);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Return the current lineage from Root (or the highest surviving ancestor)
|
|
586
|
+
* to a task. Includes soft-deleted ancestors so callers can detect a
|
|
587
|
+
* broken/hidden parent chain and revalidate it after acquiring a lock.
|
|
588
|
+
* Returns undefined for a missing row or a cycle.
|
|
589
|
+
*/
|
|
590
|
+
getTaskLineage(id) {
|
|
591
|
+
const reversed = [];
|
|
592
|
+
const seen = new Set();
|
|
593
|
+
let current = this.getTaskIncludingDeleted(id);
|
|
594
|
+
while (current) {
|
|
595
|
+
if (seen.has(current.id))
|
|
596
|
+
return undefined;
|
|
597
|
+
seen.add(current.id);
|
|
598
|
+
reversed.push(current.id);
|
|
599
|
+
if (current.parent_id === null)
|
|
600
|
+
return reversed.reverse();
|
|
601
|
+
current = this.getTaskIncludingDeleted(current.parent_id);
|
|
602
|
+
}
|
|
603
|
+
return undefined;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* The task's tree path (`@/parent/child`) for user-facing messages, or
|
|
607
|
+
* undefined when the row is missing or the lineage is broken. Users read this
|
|
608
|
+
* as the name of a task they can act on, unlike a raw id. Root is the path's
|
|
609
|
+
* origin and is never a segment, matching what the input grammar resolves.
|
|
278
610
|
*/
|
|
279
|
-
|
|
280
|
-
|
|
611
|
+
getTaskPath(id) {
|
|
612
|
+
const lineage = this.getTaskLineage(id);
|
|
613
|
+
if (!lineage)
|
|
614
|
+
return undefined;
|
|
615
|
+
const segments = lineage[0] === ROOT_TASK_ID ? lineage.slice(1) : lineage;
|
|
616
|
+
return formatTaskPath(segments.map((taskId) => this.getTaskIncludingDeleted(taskId)?.title ?? taskId.slice(0, 8)));
|
|
617
|
+
}
|
|
618
|
+
/** Re-parent surviving children of a hard-deleted task under Root so the
|
|
619
|
+
* FK on parent_id stays valid. Root is guaranteed to exist post-boot
|
|
620
|
+
* (ensureRootTask runs before listen). No-op when there are no children. */
|
|
621
|
+
reparentChildrenToRoot(parentId) {
|
|
622
|
+
this.db
|
|
623
|
+
.prepare("UPDATE tasks SET parent_id = ? WHERE parent_id = ? AND id != ? AND id != ?")
|
|
624
|
+
.run(ROOT_TASK_ID, parentId, parentId, ROOT_TASK_ID);
|
|
625
|
+
}
|
|
626
|
+
/** Direct child ids of a task (excluding itself and Root), in any order. */
|
|
627
|
+
listChildren(parentId) {
|
|
628
|
+
return this.db
|
|
629
|
+
.prepare("SELECT id FROM tasks WHERE parent_id = ? AND id != ? AND id != ?")
|
|
630
|
+
.all(parentId, parentId, ROOT_TASK_ID).map((row) => row.id);
|
|
281
631
|
}
|
|
282
632
|
/**
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
633
|
+
* Every descendant of a task, transitively (used to gate destructive
|
|
634
|
+
* operations such as the DELETE busy check against in-flight children).
|
|
635
|
+
*/
|
|
636
|
+
getDescendantTaskIds(rootId) {
|
|
637
|
+
const out = [];
|
|
638
|
+
const queue = [rootId];
|
|
639
|
+
while (queue.length > 0) {
|
|
640
|
+
for (const child of this.listChildren(queue.pop())) {
|
|
641
|
+
queue.push(child);
|
|
642
|
+
out.push(child);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return out;
|
|
646
|
+
}
|
|
647
|
+
hasActiveShare(taskId) {
|
|
648
|
+
const row = this.db
|
|
649
|
+
.prepare("SELECT 1 AS present FROM shares WHERE task_id = ? AND shared_at IS NOT NULL LIMIT 1")
|
|
650
|
+
.get(taskId);
|
|
651
|
+
return row !== undefined;
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Reset Root while preserving its stable anchor row. Every descendant is
|
|
655
|
+
* deleted using the ordinary task/share lifecycle, then Root's own event
|
|
656
|
+
* history and attachments are cleared. The caller rotates Root's ACP
|
|
657
|
+
* execution separately so this method remains a synchronous DB operation.
|
|
658
|
+
*/
|
|
659
|
+
resetRootTask() {
|
|
660
|
+
const root = this.getTaskIncludingDeleted(ROOT_TASK_ID);
|
|
661
|
+
if (!root)
|
|
662
|
+
throw new Error("Root task not found");
|
|
663
|
+
if (this.hasActiveShare(ROOT_TASK_ID)) {
|
|
664
|
+
throw new Error("Root task has an active share");
|
|
665
|
+
}
|
|
666
|
+
const reset = this.db.transaction(() => {
|
|
667
|
+
const affected = [];
|
|
668
|
+
for (const childId of this.listChildren(ROOT_TASK_ID)) {
|
|
669
|
+
affected.push(...this.deleteTask(childId).affected);
|
|
670
|
+
}
|
|
671
|
+
this.db
|
|
672
|
+
.prepare("DELETE FROM shares WHERE task_id = ? AND shared_at IS NULL")
|
|
673
|
+
.run(ROOT_TASK_ID);
|
|
674
|
+
this.db.prepare("DELETE FROM events WHERE task_id = ?").run(ROOT_TASK_ID);
|
|
675
|
+
this.db
|
|
676
|
+
.prepare("DELETE FROM client_ops WHERE task_id = ?")
|
|
677
|
+
.run(ROOT_TASK_ID);
|
|
678
|
+
this.db
|
|
679
|
+
.prepare("DELETE FROM attachments WHERE task_id = ?")
|
|
680
|
+
.run(ROOT_TASK_ID);
|
|
681
|
+
this.db
|
|
682
|
+
.prepare("UPDATE tasks SET pending_compact_summary = NULL WHERE id = ?")
|
|
683
|
+
.run(ROOT_TASK_ID);
|
|
684
|
+
return { affected };
|
|
685
|
+
})();
|
|
686
|
+
return reset;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Delete a task and every descendant task recursively. The
|
|
690
|
+
* parent/child hierarchy is a hard ownership link, so the deletion is
|
|
691
|
+
* immediate and needs no confirmation (a confirmation step is deferred
|
|
692
|
+
* until a tree UI exists). Each affected task follows its own share
|
|
693
|
+
* rules: a task with active shares is tombstoned (kept so share
|
|
694
|
+
* viewers still resolve) and its binding removed; a share-tombstoned
|
|
695
|
+
* descendant of a hard-deleted parent is re-parented under Root.
|
|
288
696
|
*
|
|
289
|
-
* Returns
|
|
290
|
-
*
|
|
291
|
-
*
|
|
697
|
+
* Returns every affected task with its deletion mode and the ACP
|
|
698
|
+
* execution bound at deletion time, so callers can retire executions,
|
|
699
|
+
* clean up in-memory state, and broadcast `task_deleted` per id.
|
|
292
700
|
*/
|
|
293
|
-
|
|
701
|
+
deleteTask(id) {
|
|
702
|
+
if (id === ROOT_TASK_ID) {
|
|
703
|
+
throw new Error("Root task cannot be deleted");
|
|
704
|
+
}
|
|
705
|
+
const affected = [];
|
|
706
|
+
// Delete descendants first (children before parents) so the FK on
|
|
707
|
+
// parent_id can never block the parent's own row removal.
|
|
708
|
+
const children = this.listChildren(id);
|
|
709
|
+
for (const childId of children) {
|
|
710
|
+
affected.push(...this.deleteTask(childId).affected);
|
|
711
|
+
}
|
|
712
|
+
const binding = this.getAgentSessionBinding(id);
|
|
294
713
|
// Drop preview shares regardless — they are owner-only drafts and
|
|
295
|
-
// share the
|
|
714
|
+
// share the task's lifecycle by design.
|
|
296
715
|
this.db
|
|
297
|
-
.prepare("DELETE FROM shares WHERE
|
|
716
|
+
.prepare("DELETE FROM shares WHERE task_id = ? AND shared_at IS NULL")
|
|
298
717
|
.run(id);
|
|
299
718
|
const activeShareCount = this.db
|
|
300
|
-
.prepare("SELECT COUNT(*) AS n FROM shares WHERE
|
|
719
|
+
.prepare("SELECT COUNT(*) AS n FROM shares WHERE task_id = ? AND shared_at IS NOT NULL")
|
|
301
720
|
.get(id).n;
|
|
302
|
-
this.db.prepare("DELETE FROM client_ops WHERE
|
|
721
|
+
this.db.prepare("DELETE FROM client_ops WHERE task_id = ?").run(id);
|
|
303
722
|
if (activeShareCount > 0) {
|
|
304
|
-
// Soft-delete: keep events +
|
|
305
|
-
// can still resolve.
|
|
306
|
-
//
|
|
723
|
+
// Soft-delete: keep events + tasks row so public share viewers
|
|
724
|
+
// can still resolve. The owner-side binding is retired like a hard
|
|
725
|
+
// delete; revokeShare() / reapTombstoneIfOrphaned() finishes the job
|
|
726
|
+
// once the last share is gone.
|
|
307
727
|
this.db
|
|
308
|
-
.prepare("UPDATE
|
|
728
|
+
.prepare("UPDATE tasks SET deleted_at = ? WHERE id = ?")
|
|
309
729
|
.run(Date.now(), id);
|
|
310
|
-
|
|
730
|
+
if (binding) {
|
|
731
|
+
this.db
|
|
732
|
+
.prepare("DELETE FROM agent_sessions WHERE agent_key = ? AND agent_session_id = ?")
|
|
733
|
+
.run(this.agentKey, binding.agent_session_id);
|
|
734
|
+
}
|
|
735
|
+
affected.unshift({
|
|
736
|
+
id,
|
|
737
|
+
mode: "soft",
|
|
738
|
+
agentSessionId: binding?.agent_session_id ?? null,
|
|
739
|
+
});
|
|
740
|
+
return { mode: "soft", affected };
|
|
311
741
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
742
|
+
// Hard delete: re-parent any survivor (share-tombstoned descendants)
|
|
743
|
+
// under Root, then drop events and the row (agent_sessions cascades).
|
|
744
|
+
this.reparentChildrenToRoot(id);
|
|
745
|
+
this.failOutstandingDeliveriesForDeletedTask(id);
|
|
746
|
+
this.db.prepare("DELETE FROM events WHERE task_id = ?").run(id);
|
|
747
|
+
this.db.prepare("DELETE FROM tasks WHERE id = ?").run(id);
|
|
748
|
+
affected.unshift({
|
|
749
|
+
id,
|
|
750
|
+
mode: "hard",
|
|
751
|
+
agentSessionId: binding?.agent_session_id ?? null,
|
|
752
|
+
});
|
|
753
|
+
return { mode: "hard", affected };
|
|
315
754
|
}
|
|
316
755
|
/**
|
|
317
|
-
* Hard-delete events +
|
|
318
|
-
* deleted and whose last share was just revoked. No-op if the
|
|
756
|
+
* Hard-delete events + tasks row for a task that has been soft-
|
|
757
|
+
* deleted and whose last share was just revoked. No-op if the task
|
|
319
758
|
* is still live (deleted_at IS NULL) or still has active shares.
|
|
320
759
|
* Returns true if a tombstone was reaped.
|
|
321
760
|
*/
|
|
322
|
-
reapTombstoneIfOrphaned(
|
|
323
|
-
const
|
|
324
|
-
.prepare("SELECT id FROM
|
|
325
|
-
.get(
|
|
326
|
-
if (!
|
|
761
|
+
reapTombstoneIfOrphaned(taskId) {
|
|
762
|
+
const row = this.db
|
|
763
|
+
.prepare("SELECT id FROM tasks WHERE id = ? AND deleted_at IS NOT NULL")
|
|
764
|
+
.get(taskId);
|
|
765
|
+
if (!row)
|
|
327
766
|
return false;
|
|
328
767
|
const remaining = this.db
|
|
329
|
-
.prepare("SELECT COUNT(*) AS n FROM shares WHERE
|
|
330
|
-
.get(
|
|
768
|
+
.prepare("SELECT COUNT(*) AS n FROM shares WHERE task_id = ?")
|
|
769
|
+
.get(taskId).n;
|
|
331
770
|
if (remaining > 0)
|
|
332
771
|
return false;
|
|
333
|
-
|
|
334
|
-
|
|
772
|
+
// Its live children were deleted when it was tombstoned; any survivor
|
|
773
|
+
// (a share-tombstoned descendant of this tombstone) still references it
|
|
774
|
+
// and must be re-parented under Root before the row goes away.
|
|
775
|
+
this.reparentChildrenToRoot(taskId);
|
|
776
|
+
this.failOutstandingDeliveriesForDeletedTask(taskId);
|
|
777
|
+
this.db.prepare("DELETE FROM events WHERE task_id = ?").run(taskId);
|
|
778
|
+
this.db.prepare("DELETE FROM tasks WHERE id = ?").run(taskId);
|
|
335
779
|
return true;
|
|
336
780
|
}
|
|
337
|
-
/** Delete
|
|
338
|
-
|
|
781
|
+
/** Delete tasks that have zero events and are older than minAgeS seconds. Returns IDs deleted. */
|
|
782
|
+
deleteEmptyTasks(minAgeS) {
|
|
339
783
|
const empties = this.db
|
|
340
784
|
.prepare(`
|
|
341
|
-
SELECT s.id FROM
|
|
342
|
-
|
|
785
|
+
SELECT s.id, a.agent_session_id FROM tasks s
|
|
786
|
+
JOIN agent_sessions a ON a.task_id = s.id
|
|
787
|
+
LEFT JOIN events e ON e.task_id = s.id
|
|
343
788
|
WHERE e.id IS NULL
|
|
789
|
+
AND s.id != ?
|
|
790
|
+
AND a.agent_key = ?
|
|
791
|
+
AND s.deleted_at IS NULL
|
|
344
792
|
AND strftime('%s', 'now') - strftime('%s', s.created_at) >= ?
|
|
345
793
|
`)
|
|
346
|
-
.all(minAgeS);
|
|
794
|
+
.all(ROOT_TASK_ID, this.agentKey, minAgeS);
|
|
347
795
|
if (empties.length === 0)
|
|
348
796
|
return [];
|
|
349
|
-
const del = this.db.prepare("DELETE FROM
|
|
350
|
-
|
|
797
|
+
const del = this.db.prepare("DELETE FROM tasks WHERE id = ?");
|
|
798
|
+
const removed = [];
|
|
799
|
+
for (const r of empties) {
|
|
800
|
+
// A junk task may still be someone's parent; keep the children by
|
|
801
|
+
// re-parenting them under Root instead of deleting them.
|
|
802
|
+
this.reparentChildrenToRoot(r.id);
|
|
803
|
+
this.failOutstandingDeliveriesForDeletedTask(r.id);
|
|
351
804
|
del.run(r.id);
|
|
352
|
-
|
|
805
|
+
removed.push({ id: r.id, agentSessionId: r.agent_session_id });
|
|
806
|
+
}
|
|
807
|
+
return removed;
|
|
353
808
|
}
|
|
354
|
-
|
|
809
|
+
updateTaskTitle(id, title) {
|
|
810
|
+
this.db.prepare("UPDATE tasks SET title = ? WHERE id = ?").run(title, id);
|
|
811
|
+
}
|
|
812
|
+
updateTaskWorkflowStatus(id, status) {
|
|
355
813
|
this.db
|
|
356
|
-
.prepare("UPDATE
|
|
357
|
-
.run(
|
|
814
|
+
.prepare("UPDATE tasks SET workflow_status = ? WHERE id = ?")
|
|
815
|
+
.run(status, id);
|
|
358
816
|
}
|
|
359
|
-
|
|
817
|
+
updateTaskLastActive(id) {
|
|
360
818
|
this.db
|
|
361
|
-
.prepare("UPDATE
|
|
819
|
+
.prepare("UPDATE tasks SET last_active_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = ?")
|
|
362
820
|
.run(id);
|
|
363
821
|
}
|
|
364
|
-
/**
|
|
365
|
-
|
|
822
|
+
/** Return the hidden summary waiting to be prepended to the next prompt. */
|
|
823
|
+
getPendingCompactSummary(id) {
|
|
824
|
+
const row = this.db
|
|
825
|
+
.prepare("SELECT pending_compact_summary FROM tasks WHERE id = ?")
|
|
826
|
+
.get(id);
|
|
827
|
+
return row?.pending_compact_summary ?? null;
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Persist the visible assistant summary and its hidden pending copy together.
|
|
831
|
+
* The latter is consumed only when the next real prompt is accepted.
|
|
832
|
+
*/
|
|
833
|
+
saveCompactSummary(taskId, summary) {
|
|
834
|
+
return this.db.transaction(() => {
|
|
835
|
+
const seq = this.db
|
|
836
|
+
.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM events WHERE task_id = ?")
|
|
837
|
+
.get(taskId).next;
|
|
838
|
+
this.db
|
|
839
|
+
.prepare("INSERT INTO events (task_id, seq, type, data, from_ref) VALUES (?, ?, ?, ?, ?)")
|
|
840
|
+
.run(taskId, seq, "assistant_message", JSON.stringify({ text: summary }), "agent");
|
|
841
|
+
this.db
|
|
842
|
+
.prepare("UPDATE tasks SET pending_compact_summary = ? WHERE id = ?")
|
|
843
|
+
.run(summary, taskId);
|
|
844
|
+
return this.db
|
|
845
|
+
.prepare("SELECT * FROM events WHERE task_id = ? AND seq = ?")
|
|
846
|
+
.get(taskId, seq);
|
|
847
|
+
})();
|
|
848
|
+
}
|
|
849
|
+
/** Clear a pending summary, optionally only when it is still the expected value. */
|
|
850
|
+
clearPendingCompactSummary(id, expected) {
|
|
851
|
+
const result = expected === undefined
|
|
852
|
+
? this.db
|
|
853
|
+
.prepare("UPDATE tasks SET pending_compact_summary = NULL WHERE id = ?")
|
|
854
|
+
.run(id)
|
|
855
|
+
: this.db
|
|
856
|
+
.prepare("UPDATE tasks SET pending_compact_summary = NULL WHERE id = ? AND pending_compact_summary = ?")
|
|
857
|
+
.run(id, expected);
|
|
858
|
+
return result.changes > 0;
|
|
859
|
+
}
|
|
860
|
+
/** Update a config option value (model, mode, reasoning_effort) for a task. */
|
|
861
|
+
updateTaskConfig(id, configId, value) {
|
|
366
862
|
const column = {
|
|
367
863
|
model: "model",
|
|
368
864
|
mode: "mode",
|
|
369
865
|
reasoning_effort: "reasoning_effort",
|
|
866
|
+
thought_level: "reasoning_effort",
|
|
370
867
|
}[configId];
|
|
371
868
|
if (!column)
|
|
372
869
|
return;
|
|
373
870
|
this.db
|
|
374
|
-
.prepare(`UPDATE
|
|
871
|
+
.prepare(`UPDATE tasks SET ${column} = ? WHERE id = ?`)
|
|
375
872
|
.run(value, id);
|
|
376
873
|
}
|
|
377
|
-
saveEvent(
|
|
874
|
+
saveEvent(taskId, type, data = {}, opts) {
|
|
378
875
|
const seq = this.db
|
|
379
|
-
.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM events WHERE
|
|
380
|
-
.get(
|
|
876
|
+
.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM events WHERE task_id = ?")
|
|
877
|
+
.get(taskId).next;
|
|
381
878
|
// Origin marker is required. Every writer must pass an explicit value;
|
|
382
879
|
// missing/empty fails loudly so a forgotten retrofit can't silently
|
|
383
880
|
// mis-bucket a row in production. Valid values:
|
|
384
881
|
// 'user' | 'system' | 'agent' | 'msg:<id>'.
|
|
385
882
|
const fromRef = opts?.from_ref;
|
|
386
883
|
if (!fromRef) {
|
|
387
|
-
throw new Error(`saveEvent: from_ref is required (type=${type}
|
|
884
|
+
throw new Error(`saveEvent: from_ref is required (type=${type} task=${taskId.slice(0, 8)}) — pass { from_ref: 'user' | 'system' | 'agent' | 'msg:<id>' }`);
|
|
388
885
|
}
|
|
389
886
|
this.db
|
|
390
|
-
.prepare("INSERT INTO events (
|
|
391
|
-
.run(
|
|
887
|
+
.prepare("INSERT INTO events (task_id, seq, type, data, from_ref) VALUES (?, ?, ?, ?, ?)")
|
|
888
|
+
.run(taskId, seq, type, JSON.stringify(data), fromRef);
|
|
392
889
|
return this.db
|
|
393
|
-
.prepare("SELECT * FROM events WHERE
|
|
394
|
-
.get(
|
|
890
|
+
.prepare("SELECT * FROM events WHERE task_id = ? AND seq = ?")
|
|
891
|
+
.get(taskId, seq);
|
|
395
892
|
}
|
|
396
|
-
|
|
893
|
+
getEvent(taskId, seq) {
|
|
894
|
+
return this.db
|
|
895
|
+
.prepare("SELECT * FROM events WHERE task_id = ? AND seq = ?")
|
|
896
|
+
.get(taskId, seq);
|
|
897
|
+
}
|
|
898
|
+
getEvents(taskId, opts) {
|
|
397
899
|
const hasLimit = opts?.limit != null && opts.limit > 0;
|
|
398
|
-
const conditions = ["
|
|
399
|
-
const params = [
|
|
900
|
+
const conditions = ["task_id = ?"];
|
|
901
|
+
const params = [taskId];
|
|
400
902
|
if (opts?.afterSeq != null) {
|
|
401
903
|
conditions.push("seq > ?");
|
|
402
904
|
params.push(opts.afterSeq);
|
|
@@ -408,6 +910,11 @@ export class Store {
|
|
|
408
910
|
if (opts?.excludeThinking) {
|
|
409
911
|
conditions.push("type != 'thinking'");
|
|
410
912
|
}
|
|
913
|
+
if (opts?.text !== undefined) {
|
|
914
|
+
conditions.push("data LIKE ? ESCAPE '\\'");
|
|
915
|
+
const escaped = opts.text.replace(/[\\%_]/g, (char) => `\\${char}`);
|
|
916
|
+
params.push(`%${escaped}%`);
|
|
917
|
+
}
|
|
411
918
|
const where = conditions.join(" AND ");
|
|
412
919
|
if (hasLimit) {
|
|
413
920
|
// Fetch the last N matching rows: subquery orders DESC with LIMIT,
|
|
@@ -420,34 +927,37 @@ export class Store {
|
|
|
420
927
|
.prepare(`SELECT * FROM events WHERE ${where} ORDER BY seq`)
|
|
421
928
|
.all(...params);
|
|
422
929
|
}
|
|
423
|
-
getEventCount(
|
|
424
|
-
let query = "SELECT COUNT(*) as count FROM events WHERE
|
|
425
|
-
const params = [
|
|
930
|
+
getEventCount(taskId, opts) {
|
|
931
|
+
let query = "SELECT COUNT(*) as count FROM events WHERE task_id = ?";
|
|
932
|
+
const params = [taskId];
|
|
426
933
|
if (opts?.excludeThinking) {
|
|
427
934
|
query += " AND type != 'thinking'";
|
|
428
935
|
}
|
|
429
936
|
return this.db.prepare(query).get(...params).count;
|
|
430
937
|
}
|
|
431
|
-
/** Highest seq of any stored event for this
|
|
432
|
-
getLastEventSeq(
|
|
938
|
+
/** Highest seq of any stored event for this task (0 when empty). */
|
|
939
|
+
getLastEventSeq(taskId) {
|
|
433
940
|
const row = this.db
|
|
434
|
-
.prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE
|
|
435
|
-
.get(
|
|
941
|
+
.prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE task_id = ?")
|
|
942
|
+
.get(taskId);
|
|
436
943
|
return row.seq;
|
|
437
944
|
}
|
|
438
|
-
/** Check if the most recent agent turn
|
|
439
|
-
hasInterruptedTurn(
|
|
945
|
+
/** Check if the most recent agent turn lacks a completion or error terminal event. */
|
|
946
|
+
hasInterruptedTurn(taskId) {
|
|
440
947
|
const row = this.db
|
|
441
948
|
.prepare(`
|
|
442
949
|
SELECT 1 FROM events
|
|
443
|
-
WHERE
|
|
950
|
+
WHERE task_id = ? AND type = 'user_message'
|
|
444
951
|
AND seq > COALESCE(
|
|
445
|
-
(
|
|
952
|
+
(
|
|
953
|
+
SELECT MAX(seq) FROM events
|
|
954
|
+
WHERE task_id = ? AND type IN ('prompt_done', 'error')
|
|
955
|
+
),
|
|
446
956
|
0
|
|
447
957
|
)
|
|
448
958
|
LIMIT 1
|
|
449
959
|
`)
|
|
450
|
-
.get(
|
|
960
|
+
.get(taskId, taskId);
|
|
451
961
|
return Boolean(row);
|
|
452
962
|
}
|
|
453
963
|
// --- Push subscriptions ---
|
|
@@ -496,30 +1006,34 @@ export class Store {
|
|
|
496
1006
|
deleteRecentPath(cwd) {
|
|
497
1007
|
this.db.prepare("DELETE FROM recent_paths WHERE cwd = ?").run(cwd);
|
|
498
1008
|
}
|
|
499
|
-
// ===== messages (pending unbound notifications) =====
|
|
1009
|
+
// ===== inbox messages (pending unbound notifications) =====
|
|
500
1010
|
createMessage(input) {
|
|
501
1011
|
this.db
|
|
502
|
-
.prepare(`INSERT INTO
|
|
1012
|
+
.prepare(`INSERT INTO inbox_messages
|
|
503
1013
|
(id, from_ref, from_label, to_ref, deliver, dedup_key, title, body, cwd, created_at)
|
|
504
1014
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
505
1015
|
.run(input.id, input.from_ref, input.from_label, input.to_ref, input.deliver, input.dedup_key, input.title, input.body, input.cwd, input.created_at);
|
|
506
1016
|
}
|
|
507
1017
|
getMessage(id) {
|
|
508
|
-
return this.db
|
|
1018
|
+
return this.db
|
|
1019
|
+
.prepare("SELECT * FROM inbox_messages WHERE id = ?")
|
|
1020
|
+
.get(id);
|
|
509
1021
|
}
|
|
510
1022
|
listUnprocessed() {
|
|
511
1023
|
return this.db
|
|
512
|
-
.prepare("SELECT * FROM
|
|
1024
|
+
.prepare("SELECT * FROM inbox_messages ORDER BY created_at DESC")
|
|
513
1025
|
.all();
|
|
514
1026
|
}
|
|
515
1027
|
countUnprocessed() {
|
|
516
1028
|
const row = this.db
|
|
517
|
-
.prepare("SELECT COUNT(*) AS count FROM
|
|
1029
|
+
.prepare("SELECT COUNT(*) AS count FROM inbox_messages")
|
|
518
1030
|
.get();
|
|
519
1031
|
return row.count;
|
|
520
1032
|
}
|
|
521
1033
|
deleteMessage(id) {
|
|
522
|
-
const info = this.db
|
|
1034
|
+
const info = this.db
|
|
1035
|
+
.prepare("DELETE FROM inbox_messages WHERE id = ?")
|
|
1036
|
+
.run(id);
|
|
523
1037
|
return info.changes;
|
|
524
1038
|
}
|
|
525
1039
|
/**
|
|
@@ -528,7 +1042,7 @@ export class Store {
|
|
|
528
1042
|
*/
|
|
529
1043
|
deleteOlderThan(thresholdMs) {
|
|
530
1044
|
const info = this.db
|
|
531
|
-
.prepare("DELETE FROM
|
|
1045
|
+
.prepare("DELETE FROM inbox_messages WHERE created_at < ?")
|
|
532
1046
|
.run(thresholdMs);
|
|
533
1047
|
return info.changes;
|
|
534
1048
|
}
|
|
@@ -537,25 +1051,25 @@ export class Store {
|
|
|
537
1051
|
if (!dedup_key)
|
|
538
1052
|
return undefined;
|
|
539
1053
|
return this.db
|
|
540
|
-
.prepare("SELECT * FROM
|
|
1054
|
+
.prepare("SELECT * FROM inbox_messages WHERE to_ref = ? AND dedup_key = ? LIMIT 1")
|
|
541
1055
|
.get(to_ref, dedup_key);
|
|
542
1056
|
}
|
|
543
1057
|
/**
|
|
544
|
-
* Atomically move a pending message into an existing
|
|
1058
|
+
* Atomically move a pending message into an existing task. Task
|
|
545
1059
|
* lifecycle belongs to SessionManager because ACP creation is asynchronous
|
|
546
1060
|
* and cannot participate in this SQLite transaction.
|
|
547
1061
|
*/
|
|
548
|
-
consumeMessageTx(messageId,
|
|
549
|
-
const existing = this.
|
|
1062
|
+
consumeMessageTx(messageId, taskId) {
|
|
1063
|
+
const existing = this.findConsumedMessageTask(messageId);
|
|
550
1064
|
if (existing) {
|
|
551
|
-
return {
|
|
1065
|
+
return { taskId: existing, alreadyConsumed: true };
|
|
552
1066
|
}
|
|
553
1067
|
const row = this.getMessage(messageId);
|
|
554
1068
|
if (!row) {
|
|
555
1069
|
throw new MessageNotFoundError(messageId);
|
|
556
1070
|
}
|
|
557
1071
|
const tx = this.db.transaction(() => {
|
|
558
|
-
this.saveEvent(
|
|
1072
|
+
this.saveEvent(taskId, "message", {
|
|
559
1073
|
message_id: row.id,
|
|
560
1074
|
from_ref: row.from_ref,
|
|
561
1075
|
from_label: row.from_label,
|
|
@@ -564,33 +1078,225 @@ export class Store {
|
|
|
564
1078
|
cwd: row.cwd,
|
|
565
1079
|
}, { from_ref: row.from_ref });
|
|
566
1080
|
const del = this.db
|
|
567
|
-
.prepare("DELETE FROM
|
|
1081
|
+
.prepare("DELETE FROM inbox_messages WHERE id = ?")
|
|
568
1082
|
.run(messageId);
|
|
569
1083
|
if (del.changes === 0) {
|
|
570
1084
|
throw new MessageNotFoundError(messageId);
|
|
571
1085
|
}
|
|
572
1086
|
});
|
|
573
1087
|
tx();
|
|
574
|
-
return {
|
|
1088
|
+
return { taskId, alreadyConsumed: false };
|
|
575
1089
|
}
|
|
576
|
-
|
|
1090
|
+
findConsumedMessageTask(messageId) {
|
|
577
1091
|
const row = this.db
|
|
578
|
-
.prepare(`SELECT
|
|
1092
|
+
.prepare(`SELECT task_id FROM events
|
|
579
1093
|
WHERE type = 'message'
|
|
580
1094
|
AND json_extract(data, '$.message_id') = ?
|
|
581
1095
|
LIMIT 1`)
|
|
582
1096
|
.get(messageId);
|
|
583
|
-
return row?.
|
|
1097
|
+
return row?.task_id;
|
|
1098
|
+
}
|
|
1099
|
+
// ===== collaboration messages =====
|
|
1100
|
+
requireLiveTask(taskId) {
|
|
1101
|
+
const task = this.getTaskIncludingDeleted(taskId);
|
|
1102
|
+
if (task?.deleted_at !== null) {
|
|
1103
|
+
throw new Error(`Live task not found: ${taskId}`);
|
|
1104
|
+
}
|
|
1105
|
+
return task;
|
|
1106
|
+
}
|
|
1107
|
+
lowestCommonAncestor(sourceTaskId, targetTaskId) {
|
|
1108
|
+
const sourceLineage = this.getTaskLineage(sourceTaskId);
|
|
1109
|
+
const targetLineage = this.getTaskLineage(targetTaskId);
|
|
1110
|
+
if (!sourceLineage || !targetLineage) {
|
|
1111
|
+
throw new Error("Cannot determine collaboration task lineage");
|
|
1112
|
+
}
|
|
1113
|
+
const sourceAncestors = new Set(sourceLineage);
|
|
1114
|
+
for (let index = targetLineage.length - 1; index >= 0; index--) {
|
|
1115
|
+
const taskId = targetLineage[index];
|
|
1116
|
+
if (sourceAncestors.has(taskId))
|
|
1117
|
+
return taskId;
|
|
1118
|
+
}
|
|
1119
|
+
throw new Error("Collaboration tasks have no common ancestor");
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Persist one cross-task fact, its task-timeline projections, and the sole
|
|
1123
|
+
* target Delivery in one SQLite transaction. Delivery mechanics live above
|
|
1124
|
+
* this Store API; this method never resolves paths or applies reachability
|
|
1125
|
+
* policy.
|
|
1126
|
+
*/
|
|
1127
|
+
createCollaborationMessage(input) {
|
|
1128
|
+
return this.db.transaction(() => this.createCollaborationMessageInTransaction(input))();
|
|
1129
|
+
}
|
|
1130
|
+
createCollaborationMessageInTransaction(input) {
|
|
1131
|
+
const createdAt = input.createdAt ?? Date.now();
|
|
1132
|
+
if (input.sourceTaskId === input.directTargetTaskId) {
|
|
1133
|
+
throw new Error("Collaboration source and target must differ");
|
|
1134
|
+
}
|
|
1135
|
+
this.requireLiveTask(input.sourceTaskId);
|
|
1136
|
+
this.requireLiveTask(input.directTargetTaskId);
|
|
1137
|
+
const lcaTaskId = this.lowestCommonAncestor(input.sourceTaskId, input.directTargetTaskId);
|
|
1138
|
+
this.db
|
|
1139
|
+
.prepare(`INSERT INTO messages
|
|
1140
|
+
(id, source_task_id, direct_target_task_id, source_actor, body, created_at)
|
|
1141
|
+
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
1142
|
+
.run(input.id, input.sourceTaskId, input.directTargetTaskId, input.sourceActor, input.body, createdAt);
|
|
1143
|
+
const insertProjection = this.db.prepare(`INSERT INTO message_projections (message_id, task_id, role, created_at)
|
|
1144
|
+
VALUES (?, ?, ?, ?)`);
|
|
1145
|
+
insertProjection.run(input.id, input.sourceTaskId, "source", createdAt);
|
|
1146
|
+
insertProjection.run(input.id, input.directTargetTaskId, "target", createdAt);
|
|
1147
|
+
if (lcaTaskId !== input.sourceTaskId &&
|
|
1148
|
+
lcaTaskId !== input.directTargetTaskId) {
|
|
1149
|
+
insertProjection.run(input.id, lcaTaskId, "supervisor", createdAt);
|
|
1150
|
+
}
|
|
1151
|
+
const sourceLabel = this.getTask(input.sourceTaskId)?.title ?? input.sourceTaskId.slice(0, 8);
|
|
1152
|
+
const targetLabel = this.getTask(input.directTargetTaskId)?.title ??
|
|
1153
|
+
input.directTargetTaskId.slice(0, 8);
|
|
1154
|
+
const collaborationTitle = `${formatTaskReference(sourceLabel)} sent ${formatTaskReference(targetLabel)}`;
|
|
1155
|
+
for (const projection of this.listCollaborationProjections(input.id)) {
|
|
1156
|
+
this.saveEvent(projection.task_id, "system_message", {
|
|
1157
|
+
kind: "collaboration",
|
|
1158
|
+
messageId: input.id,
|
|
1159
|
+
sourceTaskId: input.sourceTaskId,
|
|
1160
|
+
sourceLabel,
|
|
1161
|
+
targetTaskId: input.directTargetTaskId,
|
|
1162
|
+
targetLabel,
|
|
1163
|
+
role: projection.role,
|
|
1164
|
+
title: collaborationTitle,
|
|
1165
|
+
body: input.body,
|
|
1166
|
+
}, { from_ref: `msg:${input.id}` });
|
|
1167
|
+
}
|
|
1168
|
+
this.db
|
|
1169
|
+
.prepare(`INSERT INTO deliveries
|
|
1170
|
+
(id, message_id, recipient_task_id, idempotency_key, status, queued_at)
|
|
1171
|
+
VALUES (?, ?, ?, ?, 'queued', ?)`)
|
|
1172
|
+
.run(input.deliveryId, input.id, input.directTargetTaskId, `${input.id}:${input.directTargetTaskId}`, createdAt);
|
|
1173
|
+
return {
|
|
1174
|
+
message: this.getCollaborationMessage(input.id),
|
|
1175
|
+
delivery: this.getCollaborationDelivery(input.deliveryId),
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
/** Atomically record an Agent workflow update and its parent handoff. */
|
|
1179
|
+
recordAgentWorkflowUpdate(taskId, status, body) {
|
|
1180
|
+
return this.db.transaction(() => {
|
|
1181
|
+
const task = this.requireLiveTask(taskId);
|
|
1182
|
+
this.db
|
|
1183
|
+
.prepare("UPDATE tasks SET workflow_status = ? WHERE id = ?")
|
|
1184
|
+
.run(status, taskId);
|
|
1185
|
+
this.saveEvent(taskId, "task_update", { status, body }, { from_ref: "agent" });
|
|
1186
|
+
if (!task.parent_id) {
|
|
1187
|
+
return { parentTaskId: null, collaborationMessageId: null };
|
|
1188
|
+
}
|
|
1189
|
+
const collaborationMessageId = randomUUID();
|
|
1190
|
+
this.createCollaborationMessageInTransaction({
|
|
1191
|
+
id: collaborationMessageId,
|
|
1192
|
+
deliveryId: randomUUID(),
|
|
1193
|
+
sourceTaskId: taskId,
|
|
1194
|
+
directTargetTaskId: task.parent_id,
|
|
1195
|
+
sourceActor: "agent",
|
|
1196
|
+
body: `Task status: ${status}\n${body}`,
|
|
1197
|
+
});
|
|
1198
|
+
return {
|
|
1199
|
+
parentTaskId: task.parent_id,
|
|
1200
|
+
collaborationMessageId,
|
|
1201
|
+
};
|
|
1202
|
+
})();
|
|
1203
|
+
}
|
|
1204
|
+
getCollaborationMessage(id) {
|
|
1205
|
+
return this.db.prepare("SELECT * FROM messages WHERE id = ?").get(id);
|
|
1206
|
+
}
|
|
1207
|
+
listCollaborationProjections(messageId) {
|
|
1208
|
+
return this.db
|
|
1209
|
+
.prepare(`SELECT task_id, role FROM message_projections
|
|
1210
|
+
WHERE message_id = ?
|
|
1211
|
+
ORDER BY CASE role
|
|
1212
|
+
WHEN 'source' THEN 0
|
|
1213
|
+
WHEN 'target' THEN 1
|
|
1214
|
+
ELSE 2
|
|
1215
|
+
END, task_id`)
|
|
1216
|
+
.all(messageId);
|
|
1217
|
+
}
|
|
1218
|
+
getCollaborationDelivery(id) {
|
|
1219
|
+
return this.db.prepare("SELECT * FROM deliveries WHERE id = ?").get(id);
|
|
1220
|
+
}
|
|
1221
|
+
/** Read-only queued count; lets a caller skip busy-turn churn entirely. */
|
|
1222
|
+
countQueuedDeliveries(recipientTaskId) {
|
|
1223
|
+
const row = this.db
|
|
1224
|
+
.prepare("SELECT COUNT(*) AS count FROM deliveries WHERE recipient_task_id = ? AND status = 'queued'")
|
|
1225
|
+
.get(recipientTaskId);
|
|
1226
|
+
return row.count;
|
|
1227
|
+
}
|
|
1228
|
+
/** Atomically claim every currently queued Delivery for one target task. */
|
|
1229
|
+
claimQueuedDeliveries(recipientTaskId) {
|
|
1230
|
+
const claimedAt = Date.now();
|
|
1231
|
+
return this.db.transaction(() => {
|
|
1232
|
+
const ids = this.db
|
|
1233
|
+
.prepare(`SELECT id FROM deliveries
|
|
1234
|
+
WHERE recipient_task_id = ? AND status = 'queued'
|
|
1235
|
+
ORDER BY queued_at, id`)
|
|
1236
|
+
.all(recipientTaskId);
|
|
1237
|
+
const claim = this.db.prepare(`UPDATE deliveries
|
|
1238
|
+
SET status = 'draining', claimed_at = ?
|
|
1239
|
+
WHERE id = ? AND status = 'queued'`);
|
|
1240
|
+
const claimed = [];
|
|
1241
|
+
for (const { id } of ids) {
|
|
1242
|
+
if (claim.run(claimedAt, id).changes !== 1)
|
|
1243
|
+
continue;
|
|
1244
|
+
const delivery = this.getCollaborationDelivery(id);
|
|
1245
|
+
if (delivery)
|
|
1246
|
+
claimed.push(delivery);
|
|
1247
|
+
}
|
|
1248
|
+
return claimed;
|
|
1249
|
+
})();
|
|
1250
|
+
}
|
|
1251
|
+
markCollaborationDeliveriesDelivered(ids, deliveredAt = Date.now()) {
|
|
1252
|
+
const mark = this.db.prepare(`UPDATE deliveries
|
|
1253
|
+
SET status = 'delivered', delivered_at = ?, failure_reason = NULL, failed_at = NULL
|
|
1254
|
+
WHERE id = ? AND status = 'draining'`);
|
|
1255
|
+
const tx = this.db.transaction(() => {
|
|
1256
|
+
for (const id of ids)
|
|
1257
|
+
mark.run(deliveredAt, id);
|
|
1258
|
+
});
|
|
1259
|
+
tx();
|
|
1260
|
+
}
|
|
1261
|
+
failCollaborationDeliveries(ids, failureReason, failedAt = Date.now()) {
|
|
1262
|
+
const fail = this.db.prepare(`UPDATE deliveries
|
|
1263
|
+
SET status = 'failed', failed_at = ?, failure_reason = ?
|
|
1264
|
+
WHERE id = ? AND status = 'draining'`);
|
|
1265
|
+
const tx = this.db.transaction(() => {
|
|
1266
|
+
for (const id of ids)
|
|
1267
|
+
fail.run(failedAt, failureReason, id);
|
|
1268
|
+
});
|
|
1269
|
+
tx();
|
|
1270
|
+
}
|
|
1271
|
+
failOutstandingDeliveriesForTaskClear(taskId) {
|
|
1272
|
+
const failedAt = Date.now();
|
|
1273
|
+
this.db
|
|
1274
|
+
.prepare(`UPDATE deliveries
|
|
1275
|
+
SET status = 'failed', failed_at = ?, failure_reason =
|
|
1276
|
+
CASE status
|
|
1277
|
+
WHEN 'queued' THEN 'cleared_before_delivery'
|
|
1278
|
+
ELSE 'cleared_during_delivery'
|
|
1279
|
+
END
|
|
1280
|
+
WHERE recipient_task_id = ? AND status IN ('queued', 'draining')`)
|
|
1281
|
+
.run(failedAt, taskId);
|
|
1282
|
+
}
|
|
1283
|
+
/** Preserve the fact while recording that a deleted target cannot receive it. */
|
|
1284
|
+
failOutstandingDeliveriesForDeletedTask(taskId) {
|
|
1285
|
+
this.db
|
|
1286
|
+
.prepare(`UPDATE deliveries
|
|
1287
|
+
SET status = 'failed', failed_at = ?, failure_reason = 'target_deleted'
|
|
1288
|
+
WHERE recipient_task_id = ? AND status IN ('queued', 'draining')`)
|
|
1289
|
+
.run(Date.now(), taskId);
|
|
584
1290
|
}
|
|
585
1291
|
// --- client-server-split M2: client_ops idempotency ---
|
|
586
1292
|
/**
|
|
587
|
-
* Look up a previously-cached response for (
|
|
1293
|
+
* Look up a previously-cached response for (taskId, clientOpId).
|
|
588
1294
|
* Returns the parsed result or null if no cached entry exists.
|
|
589
1295
|
*/
|
|
590
|
-
getClientOp(
|
|
1296
|
+
getClientOp(taskId, clientOpId) {
|
|
591
1297
|
const row = this.db
|
|
592
|
-
.prepare("SELECT result_json FROM client_ops WHERE
|
|
593
|
-
.get(
|
|
1298
|
+
.prepare("SELECT result_json FROM client_ops WHERE task_id = ? AND client_op_id = ?")
|
|
1299
|
+
.get(taskId, clientOpId);
|
|
594
1300
|
if (!row)
|
|
595
1301
|
return null;
|
|
596
1302
|
try {
|
|
@@ -601,13 +1307,13 @@ export class Store {
|
|
|
601
1307
|
}
|
|
602
1308
|
}
|
|
603
1309
|
/**
|
|
604
|
-
* Cache a successful response for (
|
|
1310
|
+
* Cache a successful response for (taskId, clientOpId). Uses
|
|
605
1311
|
* INSERT OR IGNORE so a concurrent winner is preserved.
|
|
606
1312
|
*/
|
|
607
|
-
saveClientOp(
|
|
1313
|
+
saveClientOp(taskId, clientOpId, result) {
|
|
608
1314
|
this.db
|
|
609
|
-
.prepare("INSERT OR IGNORE INTO client_ops (
|
|
610
|
-
.run(
|
|
1315
|
+
.prepare("INSERT OR IGNORE INTO client_ops (task_id, client_op_id, result_json) VALUES (?, ?, ?)")
|
|
1316
|
+
.run(taskId, clientOpId, JSON.stringify(result));
|
|
611
1317
|
}
|
|
612
1318
|
/** Prune client_ops rows older than `maxAgeMs` (milliseconds). Returns rows deleted. */
|
|
613
1319
|
pruneClientOps(maxAgeMs) {
|
|
@@ -620,55 +1326,55 @@ export class Store {
|
|
|
620
1326
|
// ===== attachments (uploads-plan v2.6 §1.2) =====
|
|
621
1327
|
/**
|
|
622
1328
|
* Insert a new attachment row. upload_seq is computed as
|
|
623
|
-
* `COALESCE(MAX(events.seq), 0)` for the
|
|
1329
|
+
* `COALESCE(MAX(events.seq), 0)` for the task at insert time. Callers
|
|
624
1330
|
* must have already written the file under
|
|
625
|
-
* <data_dir>/
|
|
626
|
-
* realpath. The row is bound by FK CASCADE to its
|
|
1331
|
+
* <data_dir>/tasks/<sid>/attachments/<id>.<ext> and resolved its
|
|
1332
|
+
* realpath. The row is bound by FK CASCADE to its task.
|
|
627
1333
|
*/
|
|
628
1334
|
insertAttachment(input) {
|
|
629
1335
|
const seqRow = this.db
|
|
630
|
-
.prepare("SELECT COALESCE(MAX(seq), 0) AS s FROM events WHERE
|
|
631
|
-
.get(input.
|
|
1336
|
+
.prepare("SELECT COALESCE(MAX(seq), 0) AS s FROM events WHERE task_id = ?")
|
|
1337
|
+
.get(input.taskId);
|
|
632
1338
|
const uploadSeq = seqRow.s;
|
|
633
1339
|
this.db
|
|
634
1340
|
.prepare(`INSERT INTO attachments
|
|
635
|
-
(id,
|
|
1341
|
+
(id, task_id, kind, name, mime, size, realpath, upload_seq, width, height)
|
|
636
1342
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
637
|
-
.run(input.id, input.
|
|
1343
|
+
.run(input.id, input.taskId, input.kind, input.name, input.mime, input.size, input.realpath, uploadSeq, input.width ?? null, input.height ?? null);
|
|
638
1344
|
return this.db
|
|
639
1345
|
.prepare("SELECT * FROM attachments WHERE id = ?")
|
|
640
1346
|
.get(input.id);
|
|
641
1347
|
}
|
|
642
|
-
/** Look up an attachment row by (
|
|
643
|
-
getAttachment(
|
|
1348
|
+
/** Look up an attachment row by (task_id, id). */
|
|
1349
|
+
getAttachment(taskId, id) {
|
|
644
1350
|
return this.db
|
|
645
|
-
.prepare("SELECT * FROM attachments WHERE
|
|
646
|
-
.get(
|
|
1351
|
+
.prepare("SELECT * FROM attachments WHERE task_id = ? AND id = ?")
|
|
1352
|
+
.get(taskId, id);
|
|
647
1353
|
}
|
|
648
1354
|
/**
|
|
649
1355
|
* For the permission interceptor: list all attachment realpaths for a
|
|
650
|
-
*
|
|
1356
|
+
* task so we can compare against `toolCall.locations[].path` after
|
|
651
1357
|
* realpath-ing each side. The set is small (≤ a few hundred per
|
|
652
|
-
*
|
|
1358
|
+
* task) so we hand back an in-memory array.
|
|
653
1359
|
*/
|
|
654
|
-
listAttachmentRealpaths(
|
|
1360
|
+
listAttachmentRealpaths(taskId) {
|
|
655
1361
|
const rows = this.db
|
|
656
|
-
.prepare("SELECT realpath FROM attachments WHERE
|
|
657
|
-
.all(
|
|
1362
|
+
.prepare("SELECT realpath FROM attachments WHERE task_id = ?")
|
|
1363
|
+
.all(taskId);
|
|
658
1364
|
return rows.map((r) => r.realpath);
|
|
659
1365
|
}
|
|
660
1366
|
/**
|
|
661
1367
|
* For the egress label-rewrite (CLAUDE.md "Attachment label egress
|
|
662
1368
|
* rewrite"): list each attachment's id, user-supplied name, and
|
|
663
|
-
* realpath for a
|
|
1369
|
+
* realpath for a task. Caller (task-manager label cache)
|
|
664
1370
|
* derives the label string `<name> [#<id4>]`. Pure DB read; no
|
|
665
1371
|
* realpath syscalls (the stored realpath is already resolved at
|
|
666
1372
|
* upload time).
|
|
667
1373
|
*/
|
|
668
|
-
listAttachmentLabels(
|
|
1374
|
+
listAttachmentLabels(taskId) {
|
|
669
1375
|
const rows = this.db
|
|
670
|
-
.prepare("SELECT id, name, realpath FROM attachments WHERE
|
|
671
|
-
.all(
|
|
1376
|
+
.prepare("SELECT id, name, realpath FROM attachments WHERE task_id = ?")
|
|
1377
|
+
.all(taskId);
|
|
672
1378
|
return rows;
|
|
673
1379
|
}
|
|
674
1380
|
/**
|
|
@@ -676,10 +1382,10 @@ export class Store {
|
|
|
676
1382
|
* filename portion of its URL (`<id>.<ext>`). The id is the uuid prefix
|
|
677
1383
|
* of the file segment.
|
|
678
1384
|
*/
|
|
679
|
-
getAttachmentByFile(
|
|
1385
|
+
getAttachmentByFile(taskId, file) {
|
|
680
1386
|
const dot = file.indexOf(".");
|
|
681
1387
|
const id = dot === -1 ? file : file.slice(0, dot);
|
|
682
|
-
return this.getAttachment(
|
|
1388
|
+
return this.getAttachment(taskId, id);
|
|
683
1389
|
}
|
|
684
1390
|
close() {
|
|
685
1391
|
this.db.close();
|
|
@@ -691,22 +1397,22 @@ export class Store {
|
|
|
691
1397
|
* §4.3 R1-c2). Returns the inserted row.
|
|
692
1398
|
*
|
|
693
1399
|
* May throw SQLITE_CONSTRAINT_UNIQUE on shares_one_active_preview;
|
|
694
|
-
* callers handle via
|
|
1400
|
+
* callers handle via findActivePreviewByTask fallback (§4.3 R2-c2).
|
|
695
1401
|
*/
|
|
696
1402
|
insertSharePreview(input) {
|
|
697
1403
|
this.db
|
|
698
|
-
.prepare(`INSERT INTO shares (token,
|
|
1404
|
+
.prepare(`INSERT INTO shares (token, task_id, share_snapshot_seq, ttl_hours, display_name, owner_label)
|
|
699
1405
|
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
700
|
-
.run(input.token, input.
|
|
1406
|
+
.run(input.token, input.taskId, input.snapshotSeq, input.ttlHours ?? null, input.displayName ?? null, input.ownerLabel ?? null);
|
|
701
1407
|
return this.getShareByToken(input.token);
|
|
702
1408
|
}
|
|
703
|
-
/** SELECT the single un-activated preview for this
|
|
704
|
-
|
|
1409
|
+
/** SELECT the single un-activated preview for this task (partial unique). */
|
|
1410
|
+
findActivePreviewByTask(taskId) {
|
|
705
1411
|
return this.db
|
|
706
1412
|
.prepare(`SELECT * FROM shares
|
|
707
|
-
WHERE
|
|
1413
|
+
WHERE task_id = ? AND shared_at IS NULL
|
|
708
1414
|
ORDER BY created_at DESC LIMIT 1`)
|
|
709
|
-
.get(
|
|
1415
|
+
.get(taskId);
|
|
710
1416
|
}
|
|
711
1417
|
getShareByToken(token) {
|
|
712
1418
|
return this.db
|
|
@@ -761,8 +1467,8 @@ export class Store {
|
|
|
761
1467
|
return this.db
|
|
762
1468
|
.prepare(`SELECT
|
|
763
1469
|
s.token AS token,
|
|
764
|
-
s.
|
|
765
|
-
|
|
1470
|
+
s.task_id AS task_id,
|
|
1471
|
+
t.title AS task_title,
|
|
766
1472
|
s.shared_at AS shared_at,
|
|
767
1473
|
s.created_at AS created_at,
|
|
768
1474
|
s.display_name AS display_name,
|
|
@@ -771,9 +1477,11 @@ export class Store {
|
|
|
771
1477
|
s.ttl_hours AS ttl_hours,
|
|
772
1478
|
s.last_accessed_at AS last_accessed_at
|
|
773
1479
|
FROM shares s
|
|
774
|
-
|
|
1480
|
+
JOIN agent_sessions a ON a.task_id = s.task_id
|
|
1481
|
+
LEFT JOIN tasks t ON t.id = s.task_id
|
|
1482
|
+
WHERE a.agent_key = ?
|
|
775
1483
|
ORDER BY s.created_at DESC`)
|
|
776
|
-
.all();
|
|
1484
|
+
.all(this.agentKey);
|
|
777
1485
|
}
|
|
778
1486
|
/**
|
|
779
1487
|
* One-time write of last_accessed_at (share-plan §4.1 R2 ENG-6a +
|