@lelouchhe/webagent 0.9.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 +39 -14
- package/config.toml +7 -27
- package/dist/index.html +4 -4
- package/dist/js/app.INIQQEGD.js +5 -0
- package/dist/js/{chunk.S5LRNRJI.js → chunk.7WADDFJZ.js} +27 -27
- package/dist/js/viewer.RHZMFYWJ.js +1 -0
- package/dist/login.html +1 -1
- package/dist/share-viewer.html +5 -5
- package/dist/{styles.00nlhhf3.css → styles.01aj0l37.css} +19 -2
- package/dist/sw.js +6 -6
- 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 +25 -0
- package/lib/auth-middleware.js +2 -2
- package/lib/auth.js +2 -2
- package/lib/bridge.js +109 -83
- package/lib/client-registry.js +12 -12
- package/lib/config.js +2 -31
- package/lib/event-handler.js +143 -90
- package/lib/files/routes.js +1 -1
- 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 +947 -489
- package/lib/server.js +64 -16
- package/lib/share/routes.js +88 -88
- package/lib/shared/task-reference.js +20 -0
- package/lib/sse-manager.js +8 -8
- package/lib/store.js +941 -314
- 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} +64 -41
- 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 +7 -1
- package/dist/js/app.QC7IRDTP.js +0 -5
- package/dist/js/viewer.GP5VXAUY.js +0 -1
- package/lib/session-manager.js +0 -638
- package/lib/title-service.js +0 -95
package/lib/store.js
CHANGED
|
@@ -1,55 +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;
|
|
55
|
+
dataDir;
|
|
12
56
|
agentKey;
|
|
13
57
|
constructor(dataDir, agentKey) {
|
|
14
58
|
if (!agentKey)
|
|
15
59
|
throw new Error("agentKey is required");
|
|
16
60
|
this.agentKey = agentKey;
|
|
61
|
+
this.dataDir = dataDir;
|
|
17
62
|
mkdirSync(dataDir, { recursive: true });
|
|
18
63
|
this.db = new Database(join(dataDir, "webagent.db"));
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
+
}
|
|
24
189
|
}
|
|
25
|
-
|
|
190
|
+
initializeSchema() {
|
|
26
191
|
this.db.exec(`
|
|
27
|
-
CREATE TABLE IF NOT EXISTS
|
|
192
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
28
193
|
id TEXT PRIMARY KEY,
|
|
29
194
|
cwd TEXT NOT NULL,
|
|
30
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,
|
|
31
206
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
|
|
32
207
|
last_active_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
33
208
|
);
|
|
34
209
|
CREATE TABLE IF NOT EXISTS agent_sessions (
|
|
35
210
|
agent_key TEXT NOT NULL,
|
|
36
211
|
agent_session_id TEXT NOT NULL,
|
|
37
|
-
|
|
212
|
+
task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE,
|
|
38
213
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
|
|
39
214
|
PRIMARY KEY (agent_key, agent_session_id)
|
|
40
215
|
);
|
|
41
|
-
CREATE UNIQUE INDEX IF NOT EXISTS
|
|
42
|
-
ON agent_sessions(
|
|
43
|
-
WHERE
|
|
216
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_sessions_task
|
|
217
|
+
ON agent_sessions(task_id)
|
|
218
|
+
WHERE task_id IS NOT NULL;
|
|
44
219
|
CREATE TABLE IF NOT EXISTS events (
|
|
45
220
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
46
|
-
|
|
221
|
+
task_id TEXT NOT NULL REFERENCES tasks(id),
|
|
47
222
|
seq INTEGER NOT NULL,
|
|
48
223
|
type TEXT NOT NULL,
|
|
49
224
|
data TEXT NOT NULL DEFAULT '{}',
|
|
50
|
-
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
|
|
51
227
|
);
|
|
52
|
-
CREATE INDEX IF NOT EXISTS
|
|
228
|
+
CREATE INDEX IF NOT EXISTS idx_events_task ON events(task_id, seq);
|
|
53
229
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|
54
230
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
231
|
endpoint TEXT NOT NULL UNIQUE,
|
|
@@ -58,54 +234,13 @@ export class Store {
|
|
|
58
234
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
59
235
|
);
|
|
60
236
|
`);
|
|
61
|
-
//
|
|
62
|
-
const cols = this.db.prepare("PRAGMA table_info(sessions)").all();
|
|
63
|
-
const colNames = new Set(cols.map((c) => c.name));
|
|
64
|
-
if (!colNames.has("title")) {
|
|
65
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN title TEXT");
|
|
66
|
-
}
|
|
67
|
-
if (!colNames.has("last_active_at")) {
|
|
68
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN last_active_at TEXT");
|
|
69
|
-
// Backfill from created_at
|
|
70
|
-
this.db.exec("UPDATE sessions SET last_active_at = created_at WHERE last_active_at IS NULL");
|
|
71
|
-
}
|
|
72
|
-
if (!colNames.has("model")) {
|
|
73
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN model TEXT");
|
|
74
|
-
}
|
|
75
|
-
if (!colNames.has("mode")) {
|
|
76
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN mode TEXT");
|
|
77
|
-
}
|
|
78
|
-
if (!colNames.has("reasoning_effort")) {
|
|
79
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN reasoning_effort TEXT");
|
|
80
|
-
}
|
|
81
|
-
if (!colNames.has("source")) {
|
|
82
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'auto'");
|
|
83
|
-
}
|
|
84
|
-
if (!colNames.has("deleted_at")) {
|
|
85
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN deleted_at INTEGER");
|
|
86
|
-
}
|
|
87
|
-
// One-time dual-ID migration. Existing WebAgent session IDs were also the
|
|
88
|
-
// ACP agent's IDs, so preserve the public IDs and record that identity
|
|
89
|
-
// mapping under the agent command active during the upgrade.
|
|
90
|
-
this.db
|
|
91
|
-
.prepare(`
|
|
92
|
-
INSERT INTO agent_sessions (
|
|
93
|
-
agent_key, agent_session_id, web_session_id, created_at
|
|
94
|
-
)
|
|
95
|
-
SELECT ?, s.id, s.id, s.created_at
|
|
96
|
-
FROM sessions s
|
|
97
|
-
WHERE NOT EXISTS (
|
|
98
|
-
SELECT 1 FROM agent_sessions a WHERE a.web_session_id = s.id
|
|
99
|
-
)
|
|
100
|
-
`)
|
|
101
|
-
.run(this.agentKey);
|
|
102
|
-
// messages — pending unbound notifications. POST /api/v1/messages with
|
|
237
|
+
// inbox_messages — pending unbound notifications. POST /api/v1/messages with
|
|
103
238
|
// `to = "user"` lands here; consumeMessageTx transactionally moves the
|
|
104
|
-
// content into an existing ACP-backed
|
|
105
|
-
// 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
|
|
106
241
|
// straight to `events`.
|
|
107
242
|
this.db.exec(`
|
|
108
|
-
CREATE TABLE IF NOT EXISTS
|
|
243
|
+
CREATE TABLE IF NOT EXISTS inbox_messages (
|
|
109
244
|
id TEXT PRIMARY KEY,
|
|
110
245
|
from_ref TEXT NOT NULL,
|
|
111
246
|
from_label TEXT,
|
|
@@ -117,85 +252,85 @@ export class Store {
|
|
|
117
252
|
cwd TEXT,
|
|
118
253
|
created_at INTEGER NOT NULL
|
|
119
254
|
);
|
|
120
|
-
CREATE INDEX IF NOT EXISTS
|
|
121
|
-
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);
|
|
122
295
|
`);
|
|
123
296
|
// client-server-split M2: idempotency for mutating REST calls. Stores
|
|
124
|
-
// the cached response per (
|
|
297
|
+
// the cached response per (task_id, client_op_id) so retries (after
|
|
125
298
|
// network/SSE reconnect) return the same result instead of re-executing
|
|
126
299
|
// side effects.
|
|
127
300
|
this.db.exec(`
|
|
128
301
|
CREATE TABLE IF NOT EXISTS client_ops (
|
|
129
|
-
|
|
302
|
+
task_id TEXT NOT NULL,
|
|
130
303
|
client_op_id TEXT NOT NULL,
|
|
131
304
|
result_json TEXT NOT NULL,
|
|
132
305
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
|
|
133
|
-
PRIMARY KEY (
|
|
306
|
+
PRIMARY KEY (task_id, client_op_id)
|
|
134
307
|
);
|
|
135
308
|
`);
|
|
136
309
|
// recent_paths: LRU path list for /new menu
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
FROM sessions GROUP BY cwd;
|
|
152
|
-
`);
|
|
153
|
-
}
|
|
154
|
-
// events.from_ref — origin marker for every event row.
|
|
155
|
-
// Values: 'user' | 'system' | 'agent' | 'msg:<id>'. The 'msg:<id>'
|
|
156
|
-
// form is reserved for events authored by consuming an inbox message
|
|
157
|
-
// (see C7+). Bucketed backfill runs once for legacy rows.
|
|
158
|
-
const eventCols = this.db
|
|
159
|
-
.prepare("PRAGMA table_info(events)")
|
|
160
|
-
.all();
|
|
161
|
-
const eventColNames = new Set(eventCols.map((c) => c.name));
|
|
162
|
-
if (!eventColNames.has("from_ref")) {
|
|
163
|
-
this.db.exec("ALTER TABLE events ADD COLUMN from_ref TEXT");
|
|
164
|
-
// Buckets:
|
|
165
|
-
// user — user-authored input
|
|
166
|
-
// system — client-originated side-channel actions + host responses
|
|
167
|
-
// (permission responses, local bash, system messages)
|
|
168
|
-
// agent — everything else (assistant_message, thinking, tool_call,
|
|
169
|
-
// tool_call_update, plan, prompt_done, permission_request,
|
|
170
|
-
// etc.)
|
|
171
|
-
this.db.exec(`
|
|
172
|
-
UPDATE events SET from_ref = CASE
|
|
173
|
-
WHEN type = 'user_message' THEN 'user'
|
|
174
|
-
WHEN type IN ('permission_response', 'bash_command', 'bash_result',
|
|
175
|
-
'system_message') THEN 'system'
|
|
176
|
-
ELSE 'agent'
|
|
177
|
-
END
|
|
178
|
-
WHERE from_ref IS NULL
|
|
179
|
-
`);
|
|
180
|
-
}
|
|
181
|
-
// One-time orphan cleanup: rows whose session_id no longer exists in
|
|
182
|
-
// `sessions`. Pre-FK writes could leave these behind (a session DELETE
|
|
183
|
-
// that didn't cascade because the FK pragma was off). Must run before
|
|
184
|
-
// enabling FK pragma.
|
|
185
|
-
this.db.exec("DELETE FROM events WHERE session_id NOT IN (SELECT id FROM sessions)");
|
|
186
|
-
// Secondary index for events queried by (session_id, type, created_at)
|
|
187
|
-
// -- used by upcoming inbox/message consume queries.
|
|
188
|
-
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)");
|
|
189
324
|
// shares — public read-only share links (share-plan §4.1).
|
|
190
325
|
// State machine: preview (shared_at NULL) → active (shared_at set).
|
|
191
326
|
// Revocation = hard-delete the row (no audit trail kept).
|
|
192
|
-
// Multiple active siblings per
|
|
327
|
+
// Multiple active siblings per task allowed (v4 multi-share).
|
|
193
328
|
// Partial unique index enforces at most one un-activated preview per
|
|
194
|
-
//
|
|
329
|
+
// task at any time.
|
|
195
330
|
this.db.exec(`
|
|
196
331
|
CREATE TABLE IF NOT EXISTS shares (
|
|
197
332
|
token TEXT PRIMARY KEY,
|
|
198
|
-
|
|
333
|
+
task_id TEXT NOT NULL REFERENCES tasks(id),
|
|
199
334
|
shared_at INTEGER,
|
|
200
335
|
share_snapshot_seq INTEGER NOT NULL,
|
|
201
336
|
ttl_hours INTEGER,
|
|
@@ -204,32 +339,17 @@ export class Store {
|
|
|
204
339
|
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER) * 1000),
|
|
205
340
|
last_accessed_at INTEGER
|
|
206
341
|
);
|
|
207
|
-
CREATE INDEX IF NOT EXISTS
|
|
342
|
+
CREATE INDEX IF NOT EXISTS idx_shares_task ON shares(task_id, created_at DESC);
|
|
208
343
|
`);
|
|
209
|
-
// Migrate: drop revoked_at column from existing tables (v0.5+).
|
|
210
|
-
// Revocation is now hard-delete; kept rows are always live.
|
|
211
|
-
const shareCols = this.db
|
|
212
|
-
.prepare("PRAGMA table_info(shares)")
|
|
213
|
-
.all();
|
|
214
|
-
const shareColNames = new Set(shareCols.map((c) => c.name));
|
|
215
|
-
if (shareColNames.has("revoked_at")) {
|
|
216
|
-
// Hard-delete any pre-existing revoked rows so the migration
|
|
217
|
-
// doesn't resurrect them as "live" shares after dropping the column.
|
|
218
|
-
this.db.exec("DELETE FROM shares WHERE revoked_at IS NOT NULL");
|
|
219
|
-
// Drop the partial unique index that references revoked_at, then
|
|
220
|
-
// the column, then recreate the index without the revoked_at clause.
|
|
221
|
-
this.db.exec("DROP INDEX IF EXISTS shares_one_active_preview");
|
|
222
|
-
this.db.exec("ALTER TABLE shares DROP COLUMN revoked_at");
|
|
223
|
-
}
|
|
224
344
|
this.db.exec(`
|
|
225
345
|
CREATE UNIQUE INDEX IF NOT EXISTS shares_one_active_preview
|
|
226
|
-
ON shares(
|
|
346
|
+
ON shares(task_id)
|
|
227
347
|
WHERE shared_at IS NULL;
|
|
228
348
|
`);
|
|
229
|
-
// attachments — server-managed file uploads bound to a
|
|
230
|
-
// Lifecycle =
|
|
231
|
-
//
|
|
232
|
-
//
|
|
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
|
|
233
353
|
// file references for active shares.
|
|
234
354
|
//
|
|
235
355
|
// upload_seq = MAX(events.seq) at upload time. The share proxy uses
|
|
@@ -242,7 +362,7 @@ export class Store {
|
|
|
242
362
|
this.db.exec(`
|
|
243
363
|
CREATE TABLE IF NOT EXISTS attachments (
|
|
244
364
|
id TEXT PRIMARY KEY,
|
|
245
|
-
|
|
365
|
+
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
246
366
|
kind TEXT NOT NULL,
|
|
247
367
|
name TEXT NOT NULL,
|
|
248
368
|
mime TEXT NOT NULL,
|
|
@@ -253,18 +373,8 @@ export class Store {
|
|
|
253
373
|
height INTEGER,
|
|
254
374
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
255
375
|
);
|
|
256
|
-
CREATE INDEX IF NOT EXISTS
|
|
376
|
+
CREATE INDEX IF NOT EXISTS idx_attachments_task ON attachments(task_id);
|
|
257
377
|
`);
|
|
258
|
-
const attachmentCols = this.db
|
|
259
|
-
.prepare("PRAGMA table_info(attachments)")
|
|
260
|
-
.all();
|
|
261
|
-
const attachmentColNames = new Set(attachmentCols.map((c) => c.name));
|
|
262
|
-
if (!attachmentColNames.has("width")) {
|
|
263
|
-
this.db.exec("ALTER TABLE attachments ADD COLUMN width INTEGER");
|
|
264
|
-
}
|
|
265
|
-
if (!attachmentColNames.has("height")) {
|
|
266
|
-
this.db.exec("ALTER TABLE attachments ADD COLUMN height INTEGER");
|
|
267
|
-
}
|
|
268
378
|
// owner_prefs — key-value store for owner-scoped defaults (display_name,
|
|
269
379
|
// last /by selection, etc). Single-user model = single owner scope.
|
|
270
380
|
// Stored as plain key/value so we don't grow a new table per pref.
|
|
@@ -276,168 +386,479 @@ export class Store {
|
|
|
276
386
|
);
|
|
277
387
|
`);
|
|
278
388
|
}
|
|
279
|
-
|
|
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
|
+
})();
|
|
435
|
+
}
|
|
436
|
+
createTask(id, cwd, source = "auto", agentSessionId = id, parentId = null, opts = {}) {
|
|
280
437
|
return this.db.transaction(() => {
|
|
281
438
|
this.db
|
|
282
|
-
.prepare(
|
|
283
|
-
|
|
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");
|
|
284
443
|
this.db
|
|
285
|
-
.prepare("INSERT INTO agent_sessions (agent_key, agent_session_id,
|
|
444
|
+
.prepare("INSERT INTO agent_sessions (agent_key, agent_session_id, task_id) VALUES (?, ?, ?)")
|
|
286
445
|
.run(this.agentKey, agentSessionId, id);
|
|
446
|
+
if (opts.initialMessage) {
|
|
447
|
+
this.createCollaborationMessage({
|
|
448
|
+
...opts.initialMessage,
|
|
449
|
+
directTargetTaskId: id,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
287
452
|
return this.db
|
|
288
|
-
.prepare("SELECT * FROM
|
|
453
|
+
.prepare("SELECT * FROM tasks WHERE id = ?")
|
|
289
454
|
.get(id);
|
|
290
455
|
})();
|
|
291
456
|
}
|
|
292
|
-
|
|
457
|
+
listTasks(opts) {
|
|
293
458
|
if (opts?.source) {
|
|
294
459
|
return this.db
|
|
295
|
-
.prepare(`SELECT s
|
|
296
|
-
|
|
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
|
|
297
471
|
WHERE a.agent_key = ? AND s.source = ? AND s.deleted_at IS NULL
|
|
298
472
|
ORDER BY COALESCE(s.last_active_at, s.created_at) DESC`)
|
|
299
473
|
.all(this.agentKey, opts.source);
|
|
300
474
|
}
|
|
301
475
|
return this.db
|
|
302
|
-
.prepare(`SELECT s
|
|
303
|
-
|
|
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
|
|
304
487
|
WHERE a.agent_key = ? AND s.deleted_at IS NULL
|
|
305
488
|
ORDER BY COALESCE(s.last_active_at, s.created_at) DESC`)
|
|
306
489
|
.all(this.agentKey);
|
|
307
490
|
}
|
|
308
|
-
/** Returns live
|
|
309
|
-
|
|
491
|
+
/** Returns live tasks only. Soft-deleted (tombstone) rows are hidden. */
|
|
492
|
+
getTask(id) {
|
|
310
493
|
return this.db
|
|
311
|
-
.prepare(`SELECT s.* FROM
|
|
312
|
-
JOIN agent_sessions a ON a.
|
|
494
|
+
.prepare(`SELECT s.* FROM tasks s
|
|
495
|
+
JOIN agent_sessions a ON a.task_id = s.id
|
|
313
496
|
WHERE s.id = ? AND a.agent_key = ? AND s.deleted_at IS NULL`)
|
|
314
497
|
.get(id, this.agentKey);
|
|
315
498
|
}
|
|
316
499
|
registerInternalAgentSession(agentSessionId) {
|
|
317
500
|
this.db
|
|
318
|
-
.prepare("INSERT OR IGNORE INTO agent_sessions (agent_key, agent_session_id,
|
|
501
|
+
.prepare("INSERT OR IGNORE INTO agent_sessions (agent_key, agent_session_id, task_id) VALUES (?, ?, NULL)")
|
|
319
502
|
.run(this.agentKey, agentSessionId);
|
|
320
503
|
const row = this.db
|
|
321
504
|
.prepare("SELECT * FROM agent_sessions WHERE agent_key = ? AND agent_session_id = ?")
|
|
322
505
|
.get(this.agentKey, agentSessionId);
|
|
323
|
-
if (row.
|
|
324
|
-
throw new Error("Agent reused a user-visible
|
|
506
|
+
if (row.task_id) {
|
|
507
|
+
throw new Error("Agent reused a user-visible task ID internally");
|
|
325
508
|
}
|
|
326
509
|
return row;
|
|
327
510
|
}
|
|
328
|
-
getAgentSessionId(
|
|
511
|
+
getAgentSessionId(taskId) {
|
|
329
512
|
return this.db
|
|
330
|
-
.prepare("SELECT agent_session_id FROM agent_sessions WHERE agent_key = ? AND
|
|
331
|
-
.get(this.agentKey,
|
|
513
|
+
.prepare("SELECT agent_session_id FROM agent_sessions WHERE agent_key = ? AND task_id = ?")
|
|
514
|
+
.get(this.agentKey, taskId)?.agent_session_id;
|
|
332
515
|
}
|
|
333
|
-
|
|
516
|
+
getTaskId(agentSessionId) {
|
|
334
517
|
return this.db
|
|
335
|
-
.prepare("SELECT
|
|
336
|
-
.get(this.agentKey, agentSessionId)?.
|
|
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;
|
|
337
520
|
}
|
|
338
|
-
getAgentSessionBinding(
|
|
521
|
+
getAgentSessionBinding(taskId) {
|
|
339
522
|
return this.db
|
|
340
|
-
.prepare("SELECT * FROM agent_sessions WHERE agent_key = ? AND
|
|
341
|
-
.get(this.agentKey,
|
|
523
|
+
.prepare("SELECT * FROM agent_sessions WHERE agent_key = ? AND task_id = ?")
|
|
524
|
+
.get(this.agentKey, taskId);
|
|
342
525
|
}
|
|
343
|
-
|
|
344
|
-
|
|
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;
|
|
345
575
|
}
|
|
346
576
|
/**
|
|
347
|
-
* Returns a
|
|
577
|
+
* Returns a task row even if soft-deleted. Used by the public share
|
|
348
578
|
* viewer, which must keep working after the owner deletes the source
|
|
349
|
-
*
|
|
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.
|
|
610
|
+
*/
|
|
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);
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Every descendant of a task, transitively (used to gate destructive
|
|
634
|
+
* operations such as the DELETE busy check against in-flight children).
|
|
350
635
|
*/
|
|
351
|
-
|
|
352
|
-
|
|
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;
|
|
353
687
|
}
|
|
354
688
|
/**
|
|
355
|
-
* Delete a
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
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.
|
|
360
696
|
*
|
|
361
|
-
* Returns
|
|
362
|
-
*
|
|
363
|
-
*
|
|
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.
|
|
364
700
|
*/
|
|
365
|
-
|
|
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);
|
|
366
713
|
// Drop preview shares regardless — they are owner-only drafts and
|
|
367
|
-
// share the
|
|
714
|
+
// share the task's lifecycle by design.
|
|
368
715
|
this.db
|
|
369
|
-
.prepare("DELETE FROM shares WHERE
|
|
716
|
+
.prepare("DELETE FROM shares WHERE task_id = ? AND shared_at IS NULL")
|
|
370
717
|
.run(id);
|
|
371
718
|
const activeShareCount = this.db
|
|
372
|
-
.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")
|
|
373
720
|
.get(id).n;
|
|
374
|
-
this.db.prepare("DELETE FROM client_ops WHERE
|
|
721
|
+
this.db.prepare("DELETE FROM client_ops WHERE task_id = ?").run(id);
|
|
375
722
|
if (activeShareCount > 0) {
|
|
376
|
-
// Soft-delete: keep events +
|
|
377
|
-
// can still resolve.
|
|
378
|
-
//
|
|
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.
|
|
379
727
|
this.db
|
|
380
|
-
.prepare("UPDATE
|
|
728
|
+
.prepare("UPDATE tasks SET deleted_at = ? WHERE id = ?")
|
|
381
729
|
.run(Date.now(), id);
|
|
382
|
-
|
|
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 };
|
|
383
741
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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 };
|
|
387
754
|
}
|
|
388
755
|
/**
|
|
389
|
-
* Hard-delete events +
|
|
390
|
-
* 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
|
|
391
758
|
* is still live (deleted_at IS NULL) or still has active shares.
|
|
392
759
|
* Returns true if a tombstone was reaped.
|
|
393
760
|
*/
|
|
394
|
-
reapTombstoneIfOrphaned(
|
|
395
|
-
const
|
|
396
|
-
.prepare("SELECT id FROM
|
|
397
|
-
.get(
|
|
398
|
-
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)
|
|
399
766
|
return false;
|
|
400
767
|
const remaining = this.db
|
|
401
|
-
.prepare("SELECT COUNT(*) AS n FROM shares WHERE
|
|
402
|
-
.get(
|
|
768
|
+
.prepare("SELECT COUNT(*) AS n FROM shares WHERE task_id = ?")
|
|
769
|
+
.get(taskId).n;
|
|
403
770
|
if (remaining > 0)
|
|
404
771
|
return false;
|
|
405
|
-
|
|
406
|
-
|
|
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);
|
|
407
779
|
return true;
|
|
408
780
|
}
|
|
409
|
-
/** Delete
|
|
410
|
-
|
|
781
|
+
/** Delete tasks that have zero events and are older than minAgeS seconds. Returns IDs deleted. */
|
|
782
|
+
deleteEmptyTasks(minAgeS) {
|
|
411
783
|
const empties = this.db
|
|
412
784
|
.prepare(`
|
|
413
|
-
SELECT s.id FROM
|
|
414
|
-
JOIN agent_sessions a ON a.
|
|
415
|
-
LEFT JOIN events e ON e.
|
|
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
|
|
416
788
|
WHERE e.id IS NULL
|
|
789
|
+
AND s.id != ?
|
|
417
790
|
AND a.agent_key = ?
|
|
418
791
|
AND s.deleted_at IS NULL
|
|
419
792
|
AND strftime('%s', 'now') - strftime('%s', s.created_at) >= ?
|
|
420
793
|
`)
|
|
421
|
-
.all(this.agentKey, minAgeS);
|
|
794
|
+
.all(ROOT_TASK_ID, this.agentKey, minAgeS);
|
|
422
795
|
if (empties.length === 0)
|
|
423
796
|
return [];
|
|
424
|
-
const del = this.db.prepare("DELETE FROM
|
|
425
|
-
|
|
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);
|
|
426
804
|
del.run(r.id);
|
|
427
|
-
|
|
805
|
+
removed.push({ id: r.id, agentSessionId: r.agent_session_id });
|
|
806
|
+
}
|
|
807
|
+
return removed;
|
|
808
|
+
}
|
|
809
|
+
updateTaskTitle(id, title) {
|
|
810
|
+
this.db.prepare("UPDATE tasks SET title = ? WHERE id = ?").run(title, id);
|
|
428
811
|
}
|
|
429
|
-
|
|
812
|
+
updateTaskWorkflowStatus(id, status) {
|
|
430
813
|
this.db
|
|
431
|
-
.prepare("UPDATE
|
|
432
|
-
.run(
|
|
814
|
+
.prepare("UPDATE tasks SET workflow_status = ? WHERE id = ?")
|
|
815
|
+
.run(status, id);
|
|
433
816
|
}
|
|
434
|
-
|
|
817
|
+
updateTaskLastActive(id) {
|
|
435
818
|
this.db
|
|
436
|
-
.prepare("UPDATE
|
|
819
|
+
.prepare("UPDATE tasks SET last_active_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = ?")
|
|
437
820
|
.run(id);
|
|
438
821
|
}
|
|
439
|
-
/**
|
|
440
|
-
|
|
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) {
|
|
441
862
|
const column = {
|
|
442
863
|
model: "model",
|
|
443
864
|
mode: "mode",
|
|
@@ -447,32 +868,37 @@ export class Store {
|
|
|
447
868
|
if (!column)
|
|
448
869
|
return;
|
|
449
870
|
this.db
|
|
450
|
-
.prepare(`UPDATE
|
|
871
|
+
.prepare(`UPDATE tasks SET ${column} = ? WHERE id = ?`)
|
|
451
872
|
.run(value, id);
|
|
452
873
|
}
|
|
453
|
-
saveEvent(
|
|
874
|
+
saveEvent(taskId, type, data = {}, opts) {
|
|
454
875
|
const seq = this.db
|
|
455
|
-
.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM events WHERE
|
|
456
|
-
.get(
|
|
876
|
+
.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM events WHERE task_id = ?")
|
|
877
|
+
.get(taskId).next;
|
|
457
878
|
// Origin marker is required. Every writer must pass an explicit value;
|
|
458
879
|
// missing/empty fails loudly so a forgotten retrofit can't silently
|
|
459
880
|
// mis-bucket a row in production. Valid values:
|
|
460
881
|
// 'user' | 'system' | 'agent' | 'msg:<id>'.
|
|
461
882
|
const fromRef = opts?.from_ref;
|
|
462
883
|
if (!fromRef) {
|
|
463
|
-
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>' }`);
|
|
464
885
|
}
|
|
465
886
|
this.db
|
|
466
|
-
.prepare("INSERT INTO events (
|
|
467
|
-
.run(
|
|
887
|
+
.prepare("INSERT INTO events (task_id, seq, type, data, from_ref) VALUES (?, ?, ?, ?, ?)")
|
|
888
|
+
.run(taskId, seq, type, JSON.stringify(data), fromRef);
|
|
889
|
+
return this.db
|
|
890
|
+
.prepare("SELECT * FROM events WHERE task_id = ? AND seq = ?")
|
|
891
|
+
.get(taskId, seq);
|
|
892
|
+
}
|
|
893
|
+
getEvent(taskId, seq) {
|
|
468
894
|
return this.db
|
|
469
|
-
.prepare("SELECT * FROM events WHERE
|
|
470
|
-
.get(
|
|
895
|
+
.prepare("SELECT * FROM events WHERE task_id = ? AND seq = ?")
|
|
896
|
+
.get(taskId, seq);
|
|
471
897
|
}
|
|
472
|
-
getEvents(
|
|
898
|
+
getEvents(taskId, opts) {
|
|
473
899
|
const hasLimit = opts?.limit != null && opts.limit > 0;
|
|
474
|
-
const conditions = ["
|
|
475
|
-
const params = [
|
|
900
|
+
const conditions = ["task_id = ?"];
|
|
901
|
+
const params = [taskId];
|
|
476
902
|
if (opts?.afterSeq != null) {
|
|
477
903
|
conditions.push("seq > ?");
|
|
478
904
|
params.push(opts.afterSeq);
|
|
@@ -484,6 +910,11 @@ export class Store {
|
|
|
484
910
|
if (opts?.excludeThinking) {
|
|
485
911
|
conditions.push("type != 'thinking'");
|
|
486
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
|
+
}
|
|
487
918
|
const where = conditions.join(" AND ");
|
|
488
919
|
if (hasLimit) {
|
|
489
920
|
// Fetch the last N matching rows: subquery orders DESC with LIMIT,
|
|
@@ -496,37 +927,37 @@ export class Store {
|
|
|
496
927
|
.prepare(`SELECT * FROM events WHERE ${where} ORDER BY seq`)
|
|
497
928
|
.all(...params);
|
|
498
929
|
}
|
|
499
|
-
getEventCount(
|
|
500
|
-
let query = "SELECT COUNT(*) as count FROM events WHERE
|
|
501
|
-
const params = [
|
|
930
|
+
getEventCount(taskId, opts) {
|
|
931
|
+
let query = "SELECT COUNT(*) as count FROM events WHERE task_id = ?";
|
|
932
|
+
const params = [taskId];
|
|
502
933
|
if (opts?.excludeThinking) {
|
|
503
934
|
query += " AND type != 'thinking'";
|
|
504
935
|
}
|
|
505
936
|
return this.db.prepare(query).get(...params).count;
|
|
506
937
|
}
|
|
507
|
-
/** Highest seq of any stored event for this
|
|
508
|
-
getLastEventSeq(
|
|
938
|
+
/** Highest seq of any stored event for this task (0 when empty). */
|
|
939
|
+
getLastEventSeq(taskId) {
|
|
509
940
|
const row = this.db
|
|
510
|
-
.prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE
|
|
511
|
-
.get(
|
|
941
|
+
.prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE task_id = ?")
|
|
942
|
+
.get(taskId);
|
|
512
943
|
return row.seq;
|
|
513
944
|
}
|
|
514
945
|
/** Check if the most recent agent turn lacks a completion or error terminal event. */
|
|
515
|
-
hasInterruptedTurn(
|
|
946
|
+
hasInterruptedTurn(taskId) {
|
|
516
947
|
const row = this.db
|
|
517
948
|
.prepare(`
|
|
518
949
|
SELECT 1 FROM events
|
|
519
|
-
WHERE
|
|
950
|
+
WHERE task_id = ? AND type = 'user_message'
|
|
520
951
|
AND seq > COALESCE(
|
|
521
952
|
(
|
|
522
953
|
SELECT MAX(seq) FROM events
|
|
523
|
-
WHERE
|
|
954
|
+
WHERE task_id = ? AND type IN ('prompt_done', 'error')
|
|
524
955
|
),
|
|
525
956
|
0
|
|
526
957
|
)
|
|
527
958
|
LIMIT 1
|
|
528
959
|
`)
|
|
529
|
-
.get(
|
|
960
|
+
.get(taskId, taskId);
|
|
530
961
|
return Boolean(row);
|
|
531
962
|
}
|
|
532
963
|
// --- Push subscriptions ---
|
|
@@ -575,30 +1006,34 @@ export class Store {
|
|
|
575
1006
|
deleteRecentPath(cwd) {
|
|
576
1007
|
this.db.prepare("DELETE FROM recent_paths WHERE cwd = ?").run(cwd);
|
|
577
1008
|
}
|
|
578
|
-
// ===== messages (pending unbound notifications) =====
|
|
1009
|
+
// ===== inbox messages (pending unbound notifications) =====
|
|
579
1010
|
createMessage(input) {
|
|
580
1011
|
this.db
|
|
581
|
-
.prepare(`INSERT INTO
|
|
1012
|
+
.prepare(`INSERT INTO inbox_messages
|
|
582
1013
|
(id, from_ref, from_label, to_ref, deliver, dedup_key, title, body, cwd, created_at)
|
|
583
1014
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
584
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);
|
|
585
1016
|
}
|
|
586
1017
|
getMessage(id) {
|
|
587
|
-
return this.db
|
|
1018
|
+
return this.db
|
|
1019
|
+
.prepare("SELECT * FROM inbox_messages WHERE id = ?")
|
|
1020
|
+
.get(id);
|
|
588
1021
|
}
|
|
589
1022
|
listUnprocessed() {
|
|
590
1023
|
return this.db
|
|
591
|
-
.prepare("SELECT * FROM
|
|
1024
|
+
.prepare("SELECT * FROM inbox_messages ORDER BY created_at DESC")
|
|
592
1025
|
.all();
|
|
593
1026
|
}
|
|
594
1027
|
countUnprocessed() {
|
|
595
1028
|
const row = this.db
|
|
596
|
-
.prepare("SELECT COUNT(*) AS count FROM
|
|
1029
|
+
.prepare("SELECT COUNT(*) AS count FROM inbox_messages")
|
|
597
1030
|
.get();
|
|
598
1031
|
return row.count;
|
|
599
1032
|
}
|
|
600
1033
|
deleteMessage(id) {
|
|
601
|
-
const info = this.db
|
|
1034
|
+
const info = this.db
|
|
1035
|
+
.prepare("DELETE FROM inbox_messages WHERE id = ?")
|
|
1036
|
+
.run(id);
|
|
602
1037
|
return info.changes;
|
|
603
1038
|
}
|
|
604
1039
|
/**
|
|
@@ -607,7 +1042,7 @@ export class Store {
|
|
|
607
1042
|
*/
|
|
608
1043
|
deleteOlderThan(thresholdMs) {
|
|
609
1044
|
const info = this.db
|
|
610
|
-
.prepare("DELETE FROM
|
|
1045
|
+
.prepare("DELETE FROM inbox_messages WHERE created_at < ?")
|
|
611
1046
|
.run(thresholdMs);
|
|
612
1047
|
return info.changes;
|
|
613
1048
|
}
|
|
@@ -616,25 +1051,25 @@ export class Store {
|
|
|
616
1051
|
if (!dedup_key)
|
|
617
1052
|
return undefined;
|
|
618
1053
|
return this.db
|
|
619
|
-
.prepare("SELECT * FROM
|
|
1054
|
+
.prepare("SELECT * FROM inbox_messages WHERE to_ref = ? AND dedup_key = ? LIMIT 1")
|
|
620
1055
|
.get(to_ref, dedup_key);
|
|
621
1056
|
}
|
|
622
1057
|
/**
|
|
623
|
-
* Atomically move a pending message into an existing
|
|
1058
|
+
* Atomically move a pending message into an existing task. Task
|
|
624
1059
|
* lifecycle belongs to SessionManager because ACP creation is asynchronous
|
|
625
1060
|
* and cannot participate in this SQLite transaction.
|
|
626
1061
|
*/
|
|
627
|
-
consumeMessageTx(messageId,
|
|
628
|
-
const existing = this.
|
|
1062
|
+
consumeMessageTx(messageId, taskId) {
|
|
1063
|
+
const existing = this.findConsumedMessageTask(messageId);
|
|
629
1064
|
if (existing) {
|
|
630
|
-
return {
|
|
1065
|
+
return { taskId: existing, alreadyConsumed: true };
|
|
631
1066
|
}
|
|
632
1067
|
const row = this.getMessage(messageId);
|
|
633
1068
|
if (!row) {
|
|
634
1069
|
throw new MessageNotFoundError(messageId);
|
|
635
1070
|
}
|
|
636
1071
|
const tx = this.db.transaction(() => {
|
|
637
|
-
this.saveEvent(
|
|
1072
|
+
this.saveEvent(taskId, "message", {
|
|
638
1073
|
message_id: row.id,
|
|
639
1074
|
from_ref: row.from_ref,
|
|
640
1075
|
from_label: row.from_label,
|
|
@@ -643,33 +1078,225 @@ export class Store {
|
|
|
643
1078
|
cwd: row.cwd,
|
|
644
1079
|
}, { from_ref: row.from_ref });
|
|
645
1080
|
const del = this.db
|
|
646
|
-
.prepare("DELETE FROM
|
|
1081
|
+
.prepare("DELETE FROM inbox_messages WHERE id = ?")
|
|
647
1082
|
.run(messageId);
|
|
648
1083
|
if (del.changes === 0) {
|
|
649
1084
|
throw new MessageNotFoundError(messageId);
|
|
650
1085
|
}
|
|
651
1086
|
});
|
|
652
1087
|
tx();
|
|
653
|
-
return {
|
|
1088
|
+
return { taskId, alreadyConsumed: false };
|
|
654
1089
|
}
|
|
655
|
-
|
|
1090
|
+
findConsumedMessageTask(messageId) {
|
|
656
1091
|
const row = this.db
|
|
657
|
-
.prepare(`SELECT
|
|
1092
|
+
.prepare(`SELECT task_id FROM events
|
|
658
1093
|
WHERE type = 'message'
|
|
659
1094
|
AND json_extract(data, '$.message_id') = ?
|
|
660
1095
|
LIMIT 1`)
|
|
661
1096
|
.get(messageId);
|
|
662
|
-
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);
|
|
663
1290
|
}
|
|
664
1291
|
// --- client-server-split M2: client_ops idempotency ---
|
|
665
1292
|
/**
|
|
666
|
-
* Look up a previously-cached response for (
|
|
1293
|
+
* Look up a previously-cached response for (taskId, clientOpId).
|
|
667
1294
|
* Returns the parsed result or null if no cached entry exists.
|
|
668
1295
|
*/
|
|
669
|
-
getClientOp(
|
|
1296
|
+
getClientOp(taskId, clientOpId) {
|
|
670
1297
|
const row = this.db
|
|
671
|
-
.prepare("SELECT result_json FROM client_ops WHERE
|
|
672
|
-
.get(
|
|
1298
|
+
.prepare("SELECT result_json FROM client_ops WHERE task_id = ? AND client_op_id = ?")
|
|
1299
|
+
.get(taskId, clientOpId);
|
|
673
1300
|
if (!row)
|
|
674
1301
|
return null;
|
|
675
1302
|
try {
|
|
@@ -680,13 +1307,13 @@ export class Store {
|
|
|
680
1307
|
}
|
|
681
1308
|
}
|
|
682
1309
|
/**
|
|
683
|
-
* Cache a successful response for (
|
|
1310
|
+
* Cache a successful response for (taskId, clientOpId). Uses
|
|
684
1311
|
* INSERT OR IGNORE so a concurrent winner is preserved.
|
|
685
1312
|
*/
|
|
686
|
-
saveClientOp(
|
|
1313
|
+
saveClientOp(taskId, clientOpId, result) {
|
|
687
1314
|
this.db
|
|
688
|
-
.prepare("INSERT OR IGNORE INTO client_ops (
|
|
689
|
-
.run(
|
|
1315
|
+
.prepare("INSERT OR IGNORE INTO client_ops (task_id, client_op_id, result_json) VALUES (?, ?, ?)")
|
|
1316
|
+
.run(taskId, clientOpId, JSON.stringify(result));
|
|
690
1317
|
}
|
|
691
1318
|
/** Prune client_ops rows older than `maxAgeMs` (milliseconds). Returns rows deleted. */
|
|
692
1319
|
pruneClientOps(maxAgeMs) {
|
|
@@ -699,55 +1326,55 @@ export class Store {
|
|
|
699
1326
|
// ===== attachments (uploads-plan v2.6 §1.2) =====
|
|
700
1327
|
/**
|
|
701
1328
|
* Insert a new attachment row. upload_seq is computed as
|
|
702
|
-
* `COALESCE(MAX(events.seq), 0)` for the
|
|
1329
|
+
* `COALESCE(MAX(events.seq), 0)` for the task at insert time. Callers
|
|
703
1330
|
* must have already written the file under
|
|
704
|
-
* <data_dir>/
|
|
705
|
-
* 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.
|
|
706
1333
|
*/
|
|
707
1334
|
insertAttachment(input) {
|
|
708
1335
|
const seqRow = this.db
|
|
709
|
-
.prepare("SELECT COALESCE(MAX(seq), 0) AS s FROM events WHERE
|
|
710
|
-
.get(input.
|
|
1336
|
+
.prepare("SELECT COALESCE(MAX(seq), 0) AS s FROM events WHERE task_id = ?")
|
|
1337
|
+
.get(input.taskId);
|
|
711
1338
|
const uploadSeq = seqRow.s;
|
|
712
1339
|
this.db
|
|
713
1340
|
.prepare(`INSERT INTO attachments
|
|
714
|
-
(id,
|
|
1341
|
+
(id, task_id, kind, name, mime, size, realpath, upload_seq, width, height)
|
|
715
1342
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
716
|
-
.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);
|
|
717
1344
|
return this.db
|
|
718
1345
|
.prepare("SELECT * FROM attachments WHERE id = ?")
|
|
719
1346
|
.get(input.id);
|
|
720
1347
|
}
|
|
721
|
-
/** Look up an attachment row by (
|
|
722
|
-
getAttachment(
|
|
1348
|
+
/** Look up an attachment row by (task_id, id). */
|
|
1349
|
+
getAttachment(taskId, id) {
|
|
723
1350
|
return this.db
|
|
724
|
-
.prepare("SELECT * FROM attachments WHERE
|
|
725
|
-
.get(
|
|
1351
|
+
.prepare("SELECT * FROM attachments WHERE task_id = ? AND id = ?")
|
|
1352
|
+
.get(taskId, id);
|
|
726
1353
|
}
|
|
727
1354
|
/**
|
|
728
1355
|
* For the permission interceptor: list all attachment realpaths for a
|
|
729
|
-
*
|
|
1356
|
+
* task so we can compare against `toolCall.locations[].path` after
|
|
730
1357
|
* realpath-ing each side. The set is small (≤ a few hundred per
|
|
731
|
-
*
|
|
1358
|
+
* task) so we hand back an in-memory array.
|
|
732
1359
|
*/
|
|
733
|
-
listAttachmentRealpaths(
|
|
1360
|
+
listAttachmentRealpaths(taskId) {
|
|
734
1361
|
const rows = this.db
|
|
735
|
-
.prepare("SELECT realpath FROM attachments WHERE
|
|
736
|
-
.all(
|
|
1362
|
+
.prepare("SELECT realpath FROM attachments WHERE task_id = ?")
|
|
1363
|
+
.all(taskId);
|
|
737
1364
|
return rows.map((r) => r.realpath);
|
|
738
1365
|
}
|
|
739
1366
|
/**
|
|
740
1367
|
* For the egress label-rewrite (CLAUDE.md "Attachment label egress
|
|
741
1368
|
* rewrite"): list each attachment's id, user-supplied name, and
|
|
742
|
-
* realpath for a
|
|
1369
|
+
* realpath for a task. Caller (task-manager label cache)
|
|
743
1370
|
* derives the label string `<name> [#<id4>]`. Pure DB read; no
|
|
744
1371
|
* realpath syscalls (the stored realpath is already resolved at
|
|
745
1372
|
* upload time).
|
|
746
1373
|
*/
|
|
747
|
-
listAttachmentLabels(
|
|
1374
|
+
listAttachmentLabels(taskId) {
|
|
748
1375
|
const rows = this.db
|
|
749
|
-
.prepare("SELECT id, name, realpath FROM attachments WHERE
|
|
750
|
-
.all(
|
|
1376
|
+
.prepare("SELECT id, name, realpath FROM attachments WHERE task_id = ?")
|
|
1377
|
+
.all(taskId);
|
|
751
1378
|
return rows;
|
|
752
1379
|
}
|
|
753
1380
|
/**
|
|
@@ -755,10 +1382,10 @@ export class Store {
|
|
|
755
1382
|
* filename portion of its URL (`<id>.<ext>`). The id is the uuid prefix
|
|
756
1383
|
* of the file segment.
|
|
757
1384
|
*/
|
|
758
|
-
getAttachmentByFile(
|
|
1385
|
+
getAttachmentByFile(taskId, file) {
|
|
759
1386
|
const dot = file.indexOf(".");
|
|
760
1387
|
const id = dot === -1 ? file : file.slice(0, dot);
|
|
761
|
-
return this.getAttachment(
|
|
1388
|
+
return this.getAttachment(taskId, id);
|
|
762
1389
|
}
|
|
763
1390
|
close() {
|
|
764
1391
|
this.db.close();
|
|
@@ -770,22 +1397,22 @@ export class Store {
|
|
|
770
1397
|
* §4.3 R1-c2). Returns the inserted row.
|
|
771
1398
|
*
|
|
772
1399
|
* May throw SQLITE_CONSTRAINT_UNIQUE on shares_one_active_preview;
|
|
773
|
-
* callers handle via
|
|
1400
|
+
* callers handle via findActivePreviewByTask fallback (§4.3 R2-c2).
|
|
774
1401
|
*/
|
|
775
1402
|
insertSharePreview(input) {
|
|
776
1403
|
this.db
|
|
777
|
-
.prepare(`INSERT INTO shares (token,
|
|
1404
|
+
.prepare(`INSERT INTO shares (token, task_id, share_snapshot_seq, ttl_hours, display_name, owner_label)
|
|
778
1405
|
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
779
|
-
.run(input.token, input.
|
|
1406
|
+
.run(input.token, input.taskId, input.snapshotSeq, input.ttlHours ?? null, input.displayName ?? null, input.ownerLabel ?? null);
|
|
780
1407
|
return this.getShareByToken(input.token);
|
|
781
1408
|
}
|
|
782
|
-
/** SELECT the single un-activated preview for this
|
|
783
|
-
|
|
1409
|
+
/** SELECT the single un-activated preview for this task (partial unique). */
|
|
1410
|
+
findActivePreviewByTask(taskId) {
|
|
784
1411
|
return this.db
|
|
785
1412
|
.prepare(`SELECT * FROM shares
|
|
786
|
-
WHERE
|
|
1413
|
+
WHERE task_id = ? AND shared_at IS NULL
|
|
787
1414
|
ORDER BY created_at DESC LIMIT 1`)
|
|
788
|
-
.get(
|
|
1415
|
+
.get(taskId);
|
|
789
1416
|
}
|
|
790
1417
|
getShareByToken(token) {
|
|
791
1418
|
return this.db
|
|
@@ -840,8 +1467,8 @@ export class Store {
|
|
|
840
1467
|
return this.db
|
|
841
1468
|
.prepare(`SELECT
|
|
842
1469
|
s.token AS token,
|
|
843
|
-
s.
|
|
844
|
-
|
|
1470
|
+
s.task_id AS task_id,
|
|
1471
|
+
t.title AS task_title,
|
|
845
1472
|
s.shared_at AS shared_at,
|
|
846
1473
|
s.created_at AS created_at,
|
|
847
1474
|
s.display_name AS display_name,
|
|
@@ -850,8 +1477,8 @@ export class Store {
|
|
|
850
1477
|
s.ttl_hours AS ttl_hours,
|
|
851
1478
|
s.last_accessed_at AS last_accessed_at
|
|
852
1479
|
FROM shares s
|
|
853
|
-
JOIN agent_sessions a ON a.
|
|
854
|
-
LEFT JOIN
|
|
1480
|
+
JOIN agent_sessions a ON a.task_id = s.task_id
|
|
1481
|
+
LEFT JOIN tasks t ON t.id = s.task_id
|
|
855
1482
|
WHERE a.agent_key = ?
|
|
856
1483
|
ORDER BY s.created_at DESC`)
|
|
857
1484
|
.all(this.agentKey);
|