bothouse-dsh 0.1.0 → 0.1.41
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/client/client.js +18267 -648
- package/collab/approvals.mjs +177 -0
- package/collab/attachments.mjs +144 -0
- package/collab/avatar-uploads.mjs +53 -0
- package/collab/avatars.mjs +1 -1
- package/collab/cli-member.mjs +117 -32
- package/collab/cloudweb.mjs +173 -114
- package/collab/dsh-member.mjs +588 -44
- package/collab/gate-context.mjs +80 -0
- package/collab/handoff-rules.mjs +122 -0
- package/collab/hub.mjs +23 -4
- package/collab/later.mjs +146 -0
- package/collab/memory-index.mjs +61 -0
- package/collab/mentions.mjs +8 -0
- package/collab/page.html +24 -3
- package/collab/personas.mjs +4 -0
- package/collab/plugin.mjs +385 -32
- package/collab/quota-cooling.mjs +169 -0
- package/collab/roster.mjs +81 -16
- package/collab/store.mjs +220 -45
- package/collab/tasks.mjs +646 -38
- package/collab/team-exit.mjs +189 -11
- package/collab/verify-multichannel.mjs +42 -0
- package/package.json +13 -3
package/collab/store.mjs
CHANGED
|
@@ -10,17 +10,29 @@ export const MAX_TEXT = 128 * 1024;
|
|
|
10
10
|
// 「默认混合模型」); the same names as the bridge's dispatch_mode config.
|
|
11
11
|
export const DISPATCH_MODES = ['solo', 'team', 'mixed'];
|
|
12
12
|
export const DEFAULT_DISPATCH_MODE = 'mixed';
|
|
13
|
-
// v7 is #133 (reply_to), v8 is #139 team mode A, v9 is #139 team mode B tasks
|
|
14
|
-
|
|
13
|
+
// v7 is #133 (reply_to), v8 is #139 team mode A, v9 is #139 team mode B tasks, v10 is #177
|
|
14
|
+
// (room_settings.dispatch_explicit); columns are added by ensureSchema() on every open
|
|
15
|
+
// (checked with PRAGMA table_info), so either merge or release order ends with every column;
|
|
16
|
+
// user_version only records the newest schema known.
|
|
17
|
+
// v11 is #148 (edited_at).
|
|
18
|
+
const SCHEMA_VERSION = 11;
|
|
15
19
|
/** Columns added after v1, as [table, column, definition]; ensureSchema() adds any missing one. */
|
|
16
20
|
const COLUMNS = [
|
|
21
|
+
['members', 'name_customized', 'INTEGER'],
|
|
22
|
+
['bindings', 'workspace_path', 'TEXT'], // Keep a CLI conversation in its original directory until /new. // NULL keeps existing names unchanged; only new UI members opt in.
|
|
23
|
+
|
|
17
24
|
['jobs', 'input_id', 'TEXT'], // v2: jobs remember the id of the input they admitted, to bind its consuming turn.
|
|
25
|
+
['members', 'rooms', 'TEXT'], // NULL or [] retains legacy global visibility.
|
|
18
26
|
['members', 'cli', 'TEXT'], // v4: a member may be a local CLI agent (claude | codex | gemini) instead of a model route.
|
|
19
27
|
['members', 'avatar', 'TEXT'], // v5: a member may show an avatar (built-in slug); NULL means the initial badge.
|
|
28
|
+
// A local CLI member's DSH permission preset (cli-member.mjs PERMISSION_PRESETS); NULL means DSH's default.
|
|
29
|
+
['members', 'permission_preset', 'TEXT'],
|
|
20
30
|
// v6 (#121): a streamed reply is stored as a draft (shown, not fanned out) until commit
|
|
21
31
|
// clears the flag. Durable, so a restart between commit and finishJob never fans it out twice.
|
|
22
32
|
['messages', 'draft', 'INTEGER NOT NULL DEFAULT 0'],
|
|
23
33
|
['messages', 'reply_to', 'INTEGER'], // v7 (#133): the message a user reply quotes.
|
|
34
|
+
// #144: JSON list of uploaded files ({id, name, mime, size}); bytes live under dataDir/attachments.
|
|
35
|
+
['messages', 'attachments', 'TEXT'],
|
|
24
36
|
// v8 (#139 team mode A): a room is a chat channel or a workspace under one chat channel.
|
|
25
37
|
['rooms', 'kind', "TEXT NOT NULL DEFAULT 'chat' CHECK (kind IN ('chat', 'workspace'))"],
|
|
26
38
|
['rooms', 'parent_id', 'INTEGER REFERENCES rooms(id)'],
|
|
@@ -30,12 +42,18 @@ const COLUMNS = [
|
|
|
30
42
|
// The room's highest task id when its owner last claimed it; a claim no task followed
|
|
31
43
|
// yet (the handoff is not worked on) keeps it owned when an earlier task ends (Tasks.releaseIfDone).
|
|
32
44
|
['rooms', 'claim_task', 'INTEGER'],
|
|
45
|
+
['rooms', 'claim_at', 'REAL'], // A fresh claim gets time to open its handoff before idle recovery.
|
|
33
46
|
// 1 = the bridge claimed it (cloudweb.mjs op claim/create): its lead is a bridge session, so
|
|
34
47
|
// only the bridge's op release (lead reset) frees it, never Tasks.releaseIfDone.
|
|
35
48
|
['rooms', 'claim_bridge', 'INTEGER NOT NULL DEFAULT 0'],
|
|
36
49
|
// Id of the current claim, bumped by every claim (claimWorkspace, createWorkspace): a release
|
|
37
50
|
// naming an earlier claim (the bridge's late op release) is refused ('stale-claim').
|
|
38
51
|
['rooms', 'claim_seq', 'INTEGER NOT NULL DEFAULT 0'],
|
|
52
|
+
['rooms', 'workspace_path', 'TEXT'], // Optional chat directory; workspace rooms inherit their current parent.
|
|
53
|
+
['messages', 'edited_at', 'REAL'], // v11 (#148): when the owner last edited their own message; NULL if never.
|
|
54
|
+
// #597 G34: when a role last changed the row in place (a streamed draft growing, a progress card
|
|
55
|
+
// edited, the draft committed); NULL if never. The task watch counts it as progress (Tasks.progressed).
|
|
56
|
+
['messages', 'changed_at', 'REAL'],
|
|
39
57
|
];
|
|
40
58
|
/** Workspace rooms are named 工作区<n> (setting added in #139 team mode A, not user-specified). */
|
|
41
59
|
export const WORKSPACE_PREFIX = '工作区';
|
|
@@ -51,6 +69,9 @@ export class Store {
|
|
|
51
69
|
constructor(path) {
|
|
52
70
|
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
|
|
53
71
|
this.db = new DatabaseSync(path);
|
|
72
|
+
this.closed = false;
|
|
73
|
+
/** fn(roomId) when a workspace's sessions are dropped for a new claim (resetSessions). */
|
|
74
|
+
this.sessionResets = [];
|
|
54
75
|
this.db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
|
|
55
76
|
let version = this.db.prepare('PRAGMA user_version').get().user_version;
|
|
56
77
|
if (version > SCHEMA_VERSION) throw Error(`collab: store schema ${version} is newer than ${SCHEMA_VERSION}`);
|
|
@@ -60,25 +81,30 @@ export class Store {
|
|
|
60
81
|
rooms: this.db.prepare('SELECT id, name, kind, parent_id FROM rooms ORDER BY id'),
|
|
61
82
|
children: this.db.prepare("SELECT id, name FROM rooms WHERE kind = 'workspace' AND parent_id = ? ORDER BY id"),
|
|
62
83
|
// A new workspace is claimed with no task yet (claim_task 0): its first task is the handoff.
|
|
63
|
-
createWorkspace: this.db.prepare(`INSERT INTO rooms (id, name, kind, parent_id, claim_task, claim_bridge)
|
|
84
|
+
createWorkspace: this.db.prepare(`INSERT INTO rooms (id, name, kind, parent_id, claim_task, claim_bridge, claim_at)
|
|
64
85
|
SELECT MAX(${WORKSPACE_ID_MIN}, COALESCE((SELECT MAX(id) + 1 FROM rooms WHERE id >= ${WORKSPACE_ID_MIN}), ${WORKSPACE_ID_MIN})),
|
|
65
|
-
?, 'workspace', ?, 0,
|
|
66
|
-
ensureRoom: this.db.prepare('INSERT INTO rooms (id, name) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name'),
|
|
67
|
-
|
|
68
|
-
|
|
86
|
+
?, 'workspace', ?, 0, ?, unixepoch() RETURNING id, name, kind, parent_id`),
|
|
87
|
+
ensureRoom: this.db.prepare('INSERT INTO rooms (id, name, workspace_path) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, workspace_path = excluded.workspace_path'),
|
|
88
|
+
workspacePath: this.db.prepare(`SELECT COALESCE(parent.workspace_path, room.workspace_path) AS path
|
|
89
|
+
FROM rooms room LEFT JOIN rooms parent ON room.kind = 'workspace' AND parent.id = room.parent_id WHERE room.id = ?`),
|
|
90
|
+
insert: this.db.prepare(`INSERT INTO messages (room_id, author_kind, author, name, text, created_at, from_turn, card, client_key, draft, reply_to, attachments)
|
|
91
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`),
|
|
69
92
|
byKey: this.db.prepare('SELECT * FROM messages WHERE client_key = ?'),
|
|
70
93
|
byId: this.db.prepare('SELECT * FROM messages WHERE id = ? AND deleted = 0'),
|
|
71
|
-
edit: this.db.prepare('UPDATE messages SET text = ?, card = ? WHERE id = ? AND author_kind = ? AND author = ? AND deleted = 0 RETURNING *'),
|
|
72
|
-
commit: this.db.prepare('UPDATE messages SET text = ?, from_turn = ?, draft = 0 WHERE id = ? AND author_kind = ? AND author = ? AND deleted = 0 AND card IS NULL AND draft = 1 RETURNING *'),
|
|
94
|
+
edit: this.db.prepare('UPDATE messages SET text = ?, card = ?, changed_at = ? WHERE id = ? AND author_kind = ? AND author = ? AND deleted = 0 RETURNING *'),
|
|
95
|
+
commit: this.db.prepare('UPDATE messages SET text = ?, from_turn = ?, draft = 0, changed_at = ? WHERE id = ? AND author_kind = ? AND author = ? AND deleted = 0 AND card IS NULL AND draft = 1 RETURNING *'),
|
|
96
|
+
editUser: this.db.prepare(`UPDATE messages SET text = ?, edited_at = ? WHERE id = ? AND author_kind = 'user' AND author = ?
|
|
97
|
+
AND deleted = 0 AND draft = 0 AND card IS NULL RETURNING *`),
|
|
73
98
|
del: this.db.prepare('UPDATE messages SET deleted = 1 WHERE id = ? AND author_kind = ? AND author = ? AND deleted = 0 RETURNING *'),
|
|
74
99
|
before: this.db.prepare('SELECT * FROM messages WHERE room_id = ? AND id < ? AND deleted = 0 ORDER BY id DESC LIMIT ?'),
|
|
75
100
|
after: this.db.prepare('SELECT * FROM messages WHERE room_id = ? AND id > ? AND deleted = 0 ORDER BY id ASC LIMIT ?'),
|
|
76
|
-
relay: this.db.prepare(`SELECT COUNT(*) AS n FROM messages WHERE room_id = ? AND id <= ? AND deleted = 0 AND author_kind = 'role'
|
|
101
|
+
relay: this.db.prepare(`SELECT COUNT(*) AS n FROM messages WHERE room_id = ? AND id <= ? AND deleted = 0 AND author_kind = 'role' AND card IS NULL
|
|
77
102
|
AND id > COALESCE((SELECT MAX(id) FROM messages WHERE room_id = ? AND id <= ? AND author_kind = 'user'), 0)`),
|
|
78
103
|
binding: this.db.prepare('SELECT session_id, context_cursor FROM bindings WHERE member_id = ? AND room_id = ?'),
|
|
79
104
|
// A fresh session starts reading after the room's last release (room_resets), not from its start.
|
|
80
105
|
bind: this.db.prepare(`INSERT INTO bindings (member_id, room_id, session_id, context_cursor)
|
|
81
106
|
VALUES (?, ?, ?, COALESCE((SELECT message_id FROM room_resets WHERE room_id = ?), 0))`),
|
|
107
|
+
rebind: this.db.prepare('UPDATE bindings SET session_id = ? WHERE member_id = ? AND room_id = ?'),
|
|
82
108
|
cursor: this.db.prepare('UPDATE bindings SET context_cursor = MAX(context_cursor, ?) WHERE member_id = ? AND room_id = ?'),
|
|
83
109
|
firstDraft: this.db.prepare(`SELECT MIN(id) AS id FROM messages WHERE room_id = ? AND id <= ? AND author <> ?
|
|
84
110
|
AND draft = 1 AND deleted = 0 AND card IS NULL`),
|
|
@@ -87,19 +113,22 @@ export class Store {
|
|
|
87
113
|
addJob: this.db.prepare(`INSERT INTO jobs (member_id, room_id, trigger_id, state, created_at)
|
|
88
114
|
VALUES (?, ?, ?, 'pending', ?) ON CONFLICT(member_id, trigger_id) DO NOTHING RETURNING *`),
|
|
89
115
|
job: this.db.prepare('SELECT * FROM jobs WHERE id = ?'),
|
|
116
|
+
jobByTrigger: this.db.prepare('SELECT * FROM jobs WHERE member_id = ? AND trigger_id = ?'),
|
|
117
|
+
resetJob: this.db.prepare("UPDATE jobs SET state = ?, created_at = ?, reply_id = NULL, session_id = NULL, seq_before = NULL, input_id = NULL WHERE id = ? RETURNING *"),
|
|
118
|
+
lastUserMessage: this.db.prepare("SELECT * FROM messages WHERE room_id = ? AND id < ? AND author_kind = 'user' AND deleted = 0 ORDER BY id DESC LIMIT 1"),
|
|
90
119
|
openJobs: this.db.prepare("SELECT * FROM jobs WHERE state IN ('pending', 'sent') ORDER BY id"),
|
|
91
120
|
sent: this.db.prepare("UPDATE jobs SET state = 'sent', session_id = ?, seq_before = ?, input_id = ? WHERE id = ? AND state = 'pending' RETURNING *"),
|
|
92
121
|
finish: this.db.prepare("UPDATE jobs SET state = ?, reply_id = ? WHERE id = ? AND state IN ('pending', 'sent') RETURNING *"),
|
|
93
122
|
createRoom: this.db.prepare("INSERT INTO rooms (name, kind) VALUES (?, 'chat') RETURNING id, name, kind, parent_id"),
|
|
94
123
|
members: this.db.prepare('SELECT * FROM members WHERE removed = 0 ORDER BY created_at, id'),
|
|
95
124
|
member: this.db.prepare('SELECT * FROM members WHERE id = ?'),
|
|
96
|
-
seedMember: this.db.prepare(`INSERT INTO members (id, name, provider, model, cli, persona, avatar, source, created_at, removed)
|
|
97
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, 'config', ?, 0)
|
|
125
|
+
seedMember: this.db.prepare(`INSERT INTO members (id, name, provider, model, cli, persona, avatar, permission_preset, rooms, source, created_at, removed)
|
|
126
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'config', ?, 0)
|
|
98
127
|
ON CONFLICT(id) DO UPDATE SET name = excluded.name, provider = excluded.provider, model = excluded.model,
|
|
99
|
-
cli = excluded.cli, persona = excluded.persona, avatar = excluded.avatar, source = 'config', removed = 0`),
|
|
100
|
-
addMember: this.db.prepare(`INSERT INTO members (id, name, provider, model, cli, persona, avatar, source, created_at, removed)
|
|
101
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, 'ui', ?, 0) RETURNING *`),
|
|
102
|
-
updateMember: this.db.prepare(`UPDATE members SET name = ?, provider = ?, model = ?, cli = ?, persona = ?, avatar = ?
|
|
128
|
+
cli = excluded.cli, persona = excluded.persona, avatar = excluded.avatar, permission_preset = excluded.permission_preset, rooms = excluded.rooms, source = 'config', removed = 0`),
|
|
129
|
+
addMember: this.db.prepare(`INSERT INTO members (id, name, provider, model, cli, persona, avatar, permission_preset, name_customized, rooms, source, created_at, removed)
|
|
130
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ui', ?, 0) RETURNING *`),
|
|
131
|
+
updateMember: this.db.prepare(`UPDATE members SET name = ?, provider = ?, model = ?, cli = ?, persona = ?, avatar = ?, permission_preset = ?, name_customized = ?, rooms = ?
|
|
103
132
|
WHERE id = ? AND source = 'ui' AND removed = 0 RETURNING *`),
|
|
104
133
|
removeMember: this.db.prepare("UPDATE members SET removed = 1 WHERE id = ? AND source = 'ui' AND removed = 0 RETURNING *"),
|
|
105
134
|
};
|
|
@@ -157,6 +186,8 @@ export class Store {
|
|
|
157
186
|
// Old mixed rows were also generated by reads, so they are not explicit choices.
|
|
158
187
|
this.db.exec("UPDATE room_settings SET dispatch_explicit = 1 WHERE dispatch_mode <> 'mixed'");
|
|
159
188
|
}
|
|
189
|
+
if (!settingColumns.some(c => c.name === 'lead_id')) this.db.exec('ALTER TABLE room_settings ADD COLUMN lead_id TEXT');
|
|
190
|
+
if (!settingColumns.some(c => c.name === 'workspace_path')) this.db.exec('ALTER TABLE room_settings ADD COLUMN workspace_path TEXT');
|
|
160
191
|
this.db.exec('DROP TABLE IF EXISTS collab_settings');
|
|
161
192
|
if (version < SCHEMA_VERSION) this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
162
193
|
});
|
|
@@ -186,13 +217,27 @@ export class Store {
|
|
|
186
217
|
updated_at REAL NOT NULL,
|
|
187
218
|
returned_at REAL,
|
|
188
219
|
remind_msg_id INTEGER,
|
|
189
|
-
reason TEXT
|
|
220
|
+
reason TEXT,
|
|
221
|
+
paused_at REAL,
|
|
222
|
+
paused_chat INTEGER,
|
|
223
|
+
source TEXT,
|
|
224
|
+
ref TEXT,
|
|
225
|
+
base TEXT,
|
|
226
|
+
card_member_id TEXT,
|
|
227
|
+
renewals INTEGER NOT NULL DEFAULT 0)`);
|
|
190
228
|
} else {
|
|
191
229
|
const cols = new Set(this.db.prepare('PRAGMA table_info(tasks)').all().map(c => c.name));
|
|
192
230
|
for (const [name, type] of [['parent_task', 'INTEGER'], ['next_check_at', 'REAL'], ['result_msg_id', 'INTEGER'],
|
|
193
|
-
['assign_msg_id', 'INTEGER'], ['note', 'TEXT'], ['returned_at', 'REAL'], ['remind_msg_id', 'INTEGER'], ['reason', 'TEXT']]) {
|
|
231
|
+
['assign_msg_id', 'INTEGER'], ['note', 'TEXT'], ['returned_at', 'REAL'], ['remind_msg_id', 'INTEGER'], ['reason', 'TEXT'], ['paused_at', 'REAL'], ['paused_chat', 'INTEGER'], ['source', 'TEXT'], ['ref', 'TEXT'], ['base', 'TEXT'], ['renewals', 'INTEGER NOT NULL DEFAULT 0'], ['card_member_id', 'TEXT']]) {
|
|
194
232
|
if (!cols.has(name)) this.db.exec(`ALTER TABLE tasks ADD COLUMN ${name} ${type}`);
|
|
195
233
|
}
|
|
234
|
+
// #574 B2 upgrade: workspace tasks in work with no check time start their 10-minute watch
|
|
235
|
+
// now (tasks.mjs CHECK_AFTER_S), not from their last change, so an upgrade parks none at once.
|
|
236
|
+
if (!cols.has('renewals')) {
|
|
237
|
+
this.db.prepare(`UPDATE tasks SET next_check_at = ? WHERE next_check_at IS NULL AND paused_at IS NULL
|
|
238
|
+
AND status IN ('assigned', 'accepted', 'asked', 'rework') AND room_id IN (SELECT id FROM rooms WHERE kind = 'workspace')`)
|
|
239
|
+
.run(Date.now() / 1000 + 10 * 60);
|
|
240
|
+
}
|
|
196
241
|
}
|
|
197
242
|
this.db.exec('CREATE INDEX IF NOT EXISTS tasks_room ON tasks (room_id, id)');
|
|
198
243
|
}
|
|
@@ -273,7 +318,11 @@ export class Store {
|
|
|
273
318
|
UNIQUE (member_id, trigger_id));`);
|
|
274
319
|
}
|
|
275
320
|
|
|
276
|
-
close() {
|
|
321
|
+
close() {
|
|
322
|
+
if (this.closed) return;
|
|
323
|
+
this.db.close();
|
|
324
|
+
this.closed = true;
|
|
325
|
+
}
|
|
277
326
|
|
|
278
327
|
/** Run fn in one write transaction; a throw rolls back every statement it made. */
|
|
279
328
|
transaction(fn) {
|
|
@@ -294,15 +343,26 @@ export class Store {
|
|
|
294
343
|
* predates that range or someone configured an id inside it. Refuse loudly instead of
|
|
295
344
|
* moving the workspace: bot sessions are keyed room-<id> and must never see it change.
|
|
296
345
|
*/
|
|
297
|
-
ensureRoom(id, name) {
|
|
346
|
+
ensureRoom(id, name, workspacePath) {
|
|
298
347
|
this.transaction(() => {
|
|
299
348
|
if (this.q.room.get(id)?.kind === 'workspace') {
|
|
300
349
|
throw new CollabError('workspace-id-conflict',
|
|
301
350
|
`config room id ${id} collides with workspace room ${id}; workspace ids start at ${WORKSPACE_ID_MIN} and cannot be reassigned`);
|
|
302
351
|
}
|
|
303
|
-
this.
|
|
352
|
+
const saved = this.db.prepare('SELECT workspace_path FROM room_settings WHERE room_id = ?').get(id)?.workspace_path;
|
|
353
|
+
this.q.ensureRoom.run(id, name, saved || workspacePath || null);
|
|
304
354
|
});
|
|
305
355
|
}
|
|
356
|
+
/** Rename the channel without changing any room, message or session identity. */
|
|
357
|
+
renameRoom(id, name) {
|
|
358
|
+
if (typeof name !== 'string' || !name || name.length > 32 || /\s/.test(name)) {
|
|
359
|
+
throw new CollabError('invalid', 'room name must be 1-32 characters without spaces');
|
|
360
|
+
}
|
|
361
|
+
const room = this.room(id);
|
|
362
|
+
if (room === undefined) throw new CollabError('room-missing', `room ${id} does not exist`);
|
|
363
|
+
if (room.kind === 'workspace') throw new CollabError('invalid', 'workspace names cannot be changed');
|
|
364
|
+
return this.db.prepare('UPDATE rooms SET name = ? WHERE id = ? RETURNING id, name, kind, parent_id').get(name, id);
|
|
365
|
+
}
|
|
306
366
|
/** Create a chat channel; its first workspace is made on the first createWorkspace() call. */
|
|
307
367
|
createRoom(name) {
|
|
308
368
|
const room = this.transaction(() => this.q.createRoom.get(name));
|
|
@@ -326,8 +386,9 @@ export class Store {
|
|
|
326
386
|
const num = w => Number(re.exec(w.name)?.[1] ?? 0);
|
|
327
387
|
const free = all.filter(w => w.free && num(w) > 0 && this.activeTasks(w.id) === 0).sort((a, b) => num(a) - num(b))[0];
|
|
328
388
|
if (free !== undefined) {
|
|
389
|
+
this.resetSessions(free.id);
|
|
329
390
|
return this.db.prepare(`UPDATE rooms SET parent_id = ?, free = 0, claim_bridge = ?, claim_seq = claim_seq + 1,
|
|
330
|
-
claim_task = (SELECT COALESCE(MAX(id), 0) FROM tasks WHERE room_id = ?) WHERE id = ? RETURNING id, name, kind, parent_id`)
|
|
391
|
+
claim_task = (SELECT COALESCE(MAX(id), 0) FROM tasks WHERE room_id = ?), claim_at = unixepoch() WHERE id = ? RETURNING id, name, kind, parent_id`)
|
|
331
392
|
.get(parent, bridge ? 1 : 0, free.id, free.id);
|
|
332
393
|
}
|
|
333
394
|
const n = Math.max(0, ...all.map(num));
|
|
@@ -341,9 +402,11 @@ export class Store {
|
|
|
341
402
|
* channel at a time: one another chat channel owns (not free) is refused with
|
|
342
403
|
* 'workspace-owned' until it is released after its last task (releaseWorkspace).
|
|
343
404
|
* chatId may be a workspace (then its chat channel is used). bridge: the claim comes from the
|
|
344
|
-
* bridge (cloudweb.mjs), whose lead session the bridge releases itself.
|
|
405
|
+
* bridge (cloudweb.mjs), whose lead session the bridge releases itself. handoff: the task this
|
|
406
|
+
* claim is for when it already exists (a paused task resumed, Tasks.resumeClaim), so handoffDone
|
|
407
|
+
* waits for that task instead of one dispatched later. Returns the workspace room.
|
|
345
408
|
*/
|
|
346
|
-
claimWorkspace(chatId, workspaceId, { bridge = false } = {}) {
|
|
409
|
+
claimWorkspace(chatId, workspaceId, { bridge = false, handoff = null } = {}) {
|
|
347
410
|
return this.transaction(() => {
|
|
348
411
|
const chat = this.q.room.get(chatId);
|
|
349
412
|
const ws = this.q.room.get(workspaceId);
|
|
@@ -354,9 +417,14 @@ export class Store {
|
|
|
354
417
|
if (owner != null && owner !== parent) {
|
|
355
418
|
throw new CollabError('workspace-owned', `workspace ${ws.name} belongs to room ${owner} until it finishes its last task`);
|
|
356
419
|
}
|
|
420
|
+
// #597 G35: a released workspace keeps its sessions until a new claim. Claimed back for a task of
|
|
421
|
+
// its last claim (rework, a paused task resumed: Tasks.reworkClaim/resumeClaim; claim_task stays
|
|
422
|
+
// after the release), the members go on in the same sessions; any other claim starts fresh.
|
|
423
|
+
const last = this.db.prepare('SELECT claim_task FROM rooms WHERE id = ?').get(ws.id).claim_task;
|
|
424
|
+
if (owner == null && !(handoff != null && last != null && Number(handoff) > last)) this.resetSessions(ws.id);
|
|
357
425
|
return this.db.prepare(`UPDATE rooms SET parent_id = ?, free = 0, claim_bridge = ?, claim_seq = claim_seq + 1,
|
|
358
|
-
claim_task = (SELECT COALESCE(MAX(id), 0) FROM tasks WHERE room_id = ?) WHERE id = ? RETURNING id, name, kind, parent_id`)
|
|
359
|
-
.get(parent, bridge ? 1 : 0, ws.id, ws.id);
|
|
426
|
+
claim_task = COALESCE(?, (SELECT COALESCE(MAX(id), 0) FROM tasks WHERE room_id = ?)), claim_at = unixepoch() WHERE id = ? RETURNING id, name, kind, parent_id`)
|
|
427
|
+
.get(parent, bridge ? 1 : 0, handoff == null ? null : Number(handoff) - 1, ws.id, ws.id);
|
|
360
428
|
});
|
|
361
429
|
}
|
|
362
430
|
|
|
@@ -386,16 +454,24 @@ export class Store {
|
|
|
386
454
|
return handoff !== undefined && (handoff.status === 'returned' || handoff.status === 'done');
|
|
387
455
|
}
|
|
388
456
|
|
|
389
|
-
/**
|
|
457
|
+
/**
|
|
458
|
+
* True while workspace roomId is held by a bridge claim: the bridge's handoff watch checks it
|
|
459
|
+
* (core/team_watch.go), so the task watch here leaves it alone (one watcher per handoff).
|
|
460
|
+
*/
|
|
461
|
+
bridgeClaimed(roomId) {
|
|
462
|
+
return Boolean(this.db.prepare('SELECT claim_bridge FROM rooms WHERE id = ?').get(roomId)?.claim_bridge);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Tasks in work in roomId (same statuses as tasks.mjs WORKING; paused ones do not count). */
|
|
390
466
|
activeTasks(roomId) {
|
|
391
467
|
return this.db.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE room_id = ?
|
|
392
|
-
AND status IN ('assigned', 'accepted', 'asked', 'rework')`).get(roomId).n;
|
|
468
|
+
AND status IN ('assigned', 'accepted', 'asked', 'rework') AND paused_at IS NULL`).get(roomId).n;
|
|
393
469
|
}
|
|
394
470
|
|
|
395
471
|
/**
|
|
396
472
|
* Release workspace roomId after its last task: it goes back to the pool (free) for any chat
|
|
397
|
-
* channel
|
|
398
|
-
*
|
|
473
|
+
* channel. Its sessions stay until a new claim takes it (resetSessions), so a rework or a resumed
|
|
474
|
+
* task goes on in them (#597 G35). Refused with 'busy' while tasks are in work there.
|
|
399
475
|
* Returns false if roomId is no owned workspace (already free, or a chat channel). claim (from
|
|
400
476
|
* the bridge's op release): the claim being ended; refused with 'stale-claim' once the
|
|
401
477
|
* workspace was claimed again, so a late release never frees the new claim. null = any claim.
|
|
@@ -409,15 +485,28 @@ export class Store {
|
|
|
409
485
|
throw new CollabError('stale-claim', `workspace ${ws.name} was claimed again since claim ${claim}`);
|
|
410
486
|
}
|
|
411
487
|
if (this.activeTasks(ws.id) > 0) throw new CollabError('busy', `workspace ${ws.name} has tasks in work`);
|
|
412
|
-
|
|
413
|
-
this.db.prepare('
|
|
414
|
-
const last = this.db.prepare('SELECT MAX(id) AS id FROM messages WHERE room_id = ?').get(ws.id).id ?? 0;
|
|
415
|
-
this.db.prepare(`INSERT INTO room_resets (room_id, message_id) VALUES (?, ?)
|
|
416
|
-
ON CONFLICT (room_id) DO UPDATE SET message_id = excluded.message_id`).run(ws.id, last);
|
|
488
|
+
// claim_task stays: it tells a claim back for this claim's own task from a new one (claimWorkspace).
|
|
489
|
+
this.db.prepare('UPDATE rooms SET free = 1, claim_bridge = 0 WHERE id = ?').run(ws.id);
|
|
417
490
|
return true;
|
|
418
491
|
});
|
|
419
492
|
}
|
|
420
493
|
|
|
494
|
+
/** Register fn(roomId), called when a workspace's sessions are dropped (resetSessions). */
|
|
495
|
+
onSessionsReset(fn) { this.sessionResets.push(fn); }
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* A free workspace taken by a new claim starts over: its sessions are cleared (bindings dropped;
|
|
499
|
+
* fresh ones start reading after the room's last message, room_resets) and the listeners drop
|
|
500
|
+
* what they keep per conversation (dsh-member resetRoom).
|
|
501
|
+
*/
|
|
502
|
+
resetSessions(roomId) {
|
|
503
|
+
this.db.prepare('DELETE FROM bindings WHERE room_id = ?').run(roomId);
|
|
504
|
+
const last = this.db.prepare('SELECT MAX(id) AS id FROM messages WHERE room_id = ?').get(roomId).id ?? 0;
|
|
505
|
+
this.db.prepare(`INSERT INTO room_resets (room_id, message_id) VALUES (?, ?)
|
|
506
|
+
ON CONFLICT (room_id) DO UPDATE SET message_id = excluded.message_id`).run(roomId, last);
|
|
507
|
+
for (const fn of this.sessionResets) { try { fn(roomId); } catch { /* in-memory only: the store is already reset */ } }
|
|
508
|
+
}
|
|
509
|
+
|
|
421
510
|
ensureRoomSettings(roomId) {
|
|
422
511
|
this.db.prepare('INSERT OR IGNORE INTO room_settings (room_id) VALUES (?)').run(roomId);
|
|
423
512
|
}
|
|
@@ -451,25 +540,69 @@ export class Store {
|
|
|
451
540
|
return mode;
|
|
452
541
|
}
|
|
453
542
|
|
|
543
|
+
/** Keep legacy members visible in all channels, including future ones. */
|
|
544
|
+
migrateMemberRooms() {
|
|
545
|
+
return this.db.prepare("UPDATE members SET rooms = '[]' WHERE rooms IS NULL").run().changes > 0;
|
|
546
|
+
}
|
|
547
|
+
|
|
454
548
|
/** Active DSH members, config seeds and UI-created alike. */
|
|
455
549
|
members() { return this.q.members.all(); }
|
|
456
550
|
member(id) { return this.q.member.get(id); }
|
|
457
551
|
/** Upsert a config member; config always wins for its own ids. */
|
|
458
552
|
seedMember(m, now = Date.now() / 1000) {
|
|
459
|
-
this.q.seedMember.run(m.id, m.name, m.model?.provider ?? null, m.model?.model ?? null, m.cli ?? null, m.persona ?? '', m.avatar || null, now);
|
|
553
|
+
this.q.seedMember.run(m.id, m.name, m.model?.provider ?? null, m.model?.model ?? null, m.cli ?? null, m.persona ?? '', m.avatar || null, m.permissionPreset ?? null, JSON.stringify(m.rooms ?? []), now);
|
|
460
554
|
}
|
|
461
555
|
addMember(m, now = Date.now() / 1000) {
|
|
462
|
-
return this.q.addMember.get(m.id, m.name, m.model?.provider ?? null, m.model?.model ?? null, m.cli ?? null, m.persona ?? '', m.avatar || null, now);
|
|
556
|
+
return this.q.addMember.get(m.id, m.name, m.model?.provider ?? null, m.model?.model ?? null, m.cli ?? null, m.persona ?? '', m.avatar || null, m.permissionPreset ?? null, m.nameCustomized == null ? null : Number(m.nameCustomized), JSON.stringify(m.rooms ?? []), now);
|
|
463
557
|
}
|
|
464
558
|
/** Edit a UI-created member; config members and removed ones are refused (undefined). */
|
|
465
559
|
updateMember(id, m) {
|
|
466
|
-
return this.q.updateMember.get(m.name, m.model?.provider ?? null, m.model?.model ?? null, m.cli ?? null, m.persona ?? '', m.avatar || null, id);
|
|
560
|
+
return this.q.updateMember.get(m.name, m.model?.provider ?? null, m.model?.model ?? null, m.cli ?? null, m.persona ?? '', m.avatar || null, m.permissionPreset ?? null, m.nameCustomized == null ? null : Number(m.nameCustomized), JSON.stringify(m.rooms ?? []), id);
|
|
467
561
|
}
|
|
468
562
|
removeMember(id) { return this.q.removeMember.get(id); }
|
|
563
|
+
leadId(roomId) {
|
|
564
|
+
return this.db.prepare('SELECT lead_id FROM room_settings WHERE room_id = ?').get(this.settingsRoom(roomId))?.lead_id ?? null;
|
|
565
|
+
}
|
|
566
|
+
setLead(roomId, id) {
|
|
567
|
+
roomId = this.settingsRoom(roomId);
|
|
568
|
+
this.ensureRoomSettings(roomId);
|
|
569
|
+
this.db.prepare('UPDATE room_settings SET lead_id = ? WHERE room_id = ?').run(id, roomId);
|
|
570
|
+
}
|
|
571
|
+
bindingWorkspacePath(memberId, roomId, fallback) {
|
|
572
|
+
return this.db.prepare('SELECT workspace_path FROM bindings WHERE member_id = ? AND room_id = ?').get(memberId, roomId)?.workspace_path || fallback;
|
|
573
|
+
}
|
|
574
|
+
setBindingWorkspacePath(memberId, roomId, path) {
|
|
575
|
+
this.db.prepare('UPDATE bindings SET workspace_path = ? WHERE member_id = ? AND room_id = ?').run(path, memberId, roomId);
|
|
576
|
+
}
|
|
577
|
+
setWorkspacePath(roomId, path, fallback) {
|
|
578
|
+
roomId = this.settingsRoom(roomId);
|
|
579
|
+
const previous = this.workspacePath(roomId, fallback);
|
|
580
|
+
if (previous) this.db.prepare(`UPDATE bindings SET workspace_path = ? WHERE workspace_path IS NULL AND
|
|
581
|
+
(room_id = ? OR room_id IN (SELECT id FROM rooms WHERE parent_id = ?))`).run(previous, roomId, roomId);
|
|
582
|
+
this.ensureRoomSettings(roomId);
|
|
583
|
+
this.db.prepare('UPDATE room_settings SET workspace_path = ? WHERE room_id = ?').run(path, roomId);
|
|
584
|
+
this.db.prepare('UPDATE rooms SET workspace_path = ? WHERE id = ?').run(path, roomId);
|
|
585
|
+
}
|
|
586
|
+
workspacePath(roomId, fallback) {
|
|
587
|
+
const saved = this.db.prepare('SELECT workspace_path FROM room_settings WHERE room_id = ?').get(this.settingsRoom(roomId))?.workspace_path;
|
|
588
|
+
return saved || this.q.workspacePath.get(roomId)?.path || fallback;
|
|
589
|
+
}
|
|
469
590
|
room(id) { return this.q.room.get(id); }
|
|
470
591
|
rooms() { return this.q.rooms.all(); }
|
|
592
|
+
/** Return stale, empty claims to the pool without taking bridge or paused work. */
|
|
593
|
+
releaseIdleWorkspaces() {
|
|
594
|
+
const idle = this.db.prepare(`SELECT id FROM rooms WHERE kind = 'workspace' AND free = 0
|
|
595
|
+
AND claim_bridge = 0 AND claim_task > 0
|
|
596
|
+
AND (claim_at IS NULL OR claim_at < unixepoch() - 1800)
|
|
597
|
+
AND NOT EXISTS (SELECT 1 FROM tasks WHERE room_id = rooms.id
|
|
598
|
+
AND status IN ('assigned', 'accepted', 'asked', 'rework'))`).all();
|
|
599
|
+
for (const room of idle) this.releaseWorkspace(room.id);
|
|
600
|
+
return idle.length;
|
|
601
|
+
}
|
|
602
|
+
|
|
471
603
|
/** Rooms for clients: each carries kind, parent_id and its open task count. */
|
|
472
604
|
roomsView() {
|
|
605
|
+
this.releaseIdleWorkspaces();
|
|
473
606
|
const counts = new Map(this.db.prepare(`SELECT room_id, COUNT(*) AS n FROM tasks
|
|
474
607
|
WHERE status IN ('assigned', 'accepted', 'asked', 'rework') GROUP BY room_id`).all().map(t => [t.room_id, t.n]));
|
|
475
608
|
return this.rooms().map(r => ({ ...r, active: counts.get(r.id) ?? 0, ...this.dispatchSetting(r.id) }));
|
|
@@ -482,7 +615,8 @@ export class Store {
|
|
|
482
615
|
append(roomId, m, now = Date.now() / 1000) {
|
|
483
616
|
if (this.room(roomId) === undefined) throw new CollabError('room-missing', `room ${roomId} does not exist`);
|
|
484
617
|
const text = String(m.text ?? '');
|
|
485
|
-
|
|
618
|
+
const attachments = Array.isArray(m.attachments) && m.attachments.length ? m.attachments : null;
|
|
619
|
+
if (!text.trim() && m.card == null && attachments === null) throw new CollabError('empty', 'message text is empty');
|
|
486
620
|
if (text.length > MAX_TEXT) throw new CollabError('too-long', `message exceeds ${MAX_TEXT} characters`);
|
|
487
621
|
if (m.clientKey != null) {
|
|
488
622
|
const existing = this.q.byKey.get(m.clientKey);
|
|
@@ -496,7 +630,8 @@ export class Store {
|
|
|
496
630
|
replyTo = ref.id;
|
|
497
631
|
}
|
|
498
632
|
const row = this.q.insert.get(roomId, m.authorKind, m.author, m.name, text, now,
|
|
499
|
-
m.fromTurn == null ? null : (m.fromTurn ? 1 : 0), m.card ?? null, m.clientKey ?? null, m.draft ? 1 : 0, replyTo
|
|
633
|
+
m.fromTurn == null ? null : (m.fromTurn ? 1 : 0), m.card ?? null, m.clientKey ?? null, m.draft ? 1 : 0, replyTo,
|
|
634
|
+
attachments === null ? null : JSON.stringify(attachments));
|
|
500
635
|
return { message: row, created: true };
|
|
501
636
|
}
|
|
502
637
|
|
|
@@ -516,25 +651,41 @@ export class Store {
|
|
|
516
651
|
return { id: ref.id, author_kind: ref.author_kind, author: ref.author, name: ref.name, text };
|
|
517
652
|
}
|
|
518
653
|
/** Edit or delete only a row the same author wrote; returns the row or undefined. */
|
|
519
|
-
|
|
654
|
+
/** #144: {id, name, mime, size} of a stored attachment, or undefined when no live message carries it. */
|
|
655
|
+
attachmentMeta(id) {
|
|
656
|
+
const row = this.db.prepare("SELECT attachments FROM messages WHERE deleted = 0 AND attachments LIKE ? LIMIT 1").get(`%"${id}"%`);
|
|
657
|
+
return messageAttachments(row).find(a => a.id === id);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
edit(id, authorKind, author, text, card, now = Date.now() / 1000) { return this.q.edit.get(text, card ?? null, now, id, authorKind, author); }
|
|
520
661
|
/**
|
|
521
662
|
* Finish a streamed draft row with its complete text (#121). Returns
|
|
522
663
|
* { message, first }: first is true only when this call turned the draft
|
|
523
664
|
* into a reply (the caller fans out once). A row already committed gets the
|
|
524
665
|
* text and first = false. undefined if it is not that author's live text row.
|
|
525
666
|
*/
|
|
526
|
-
commit(id, authorKind, author, text, fromTurn) {
|
|
667
|
+
commit(id, authorKind, author, text, fromTurn, now = Date.now() / 1000) {
|
|
527
668
|
if (!text.trim() || text.length > MAX_TEXT) return undefined;
|
|
528
|
-
const row = this.q.commit.get(text, fromTurn == null ? null : (fromTurn ? 1 : 0), id, authorKind, author);
|
|
669
|
+
const row = this.q.commit.get(text, fromTurn == null ? null : (fromTurn ? 1 : 0), now, id, authorKind, author);
|
|
529
670
|
if (row !== undefined) return { message: row, first: true };
|
|
530
671
|
const old = this.message(id);
|
|
531
672
|
if (old === undefined || old.card != null) return undefined;
|
|
532
|
-
const again = this.edit(id, authorKind, author, text);
|
|
673
|
+
const again = this.edit(id, authorKind, author, text, undefined, now);
|
|
533
674
|
return again === undefined ? undefined : { message: again, first: false };
|
|
534
675
|
}
|
|
676
|
+
/** #148: a person edits their own plain message; returns the row, or undefined if it is not theirs. */
|
|
677
|
+
editUser(id, author, text, now = Date.now() / 1000) {
|
|
678
|
+
text = String(text ?? '');
|
|
679
|
+
if (!text.trim()) throw new CollabError('empty', 'message text is empty');
|
|
680
|
+
if (text.length > MAX_TEXT) throw new CollabError('too-long', `message exceeds ${MAX_TEXT} characters`);
|
|
681
|
+
return this.q.editUser.get(text, now, id, author);
|
|
682
|
+
}
|
|
535
683
|
remove(id, authorKind, author) { return this.q.del.get(id, authorKind, author); }
|
|
536
684
|
/** One author's live text drafts (#121), oldest first. */
|
|
537
|
-
drafts(authorKind, author) {
|
|
685
|
+
drafts(authorKind, author) {
|
|
686
|
+
// WebSocket close events may arrive after plugin teardown has closed SQLite.
|
|
687
|
+
return this.closed ? [] : this.q.authorDrafts.all(authorKind, author);
|
|
688
|
+
}
|
|
538
689
|
|
|
539
690
|
/** Up to `limit` messages before `beforeId` (exclusive), oldest first. */
|
|
540
691
|
page(roomId, beforeId, limit) {
|
|
@@ -549,6 +700,8 @@ export class Store {
|
|
|
549
700
|
|
|
550
701
|
binding(memberId, roomId) { return this.q.binding.get(memberId, roomId); }
|
|
551
702
|
bind(memberId, roomId, sessionId) { this.q.bind.run(memberId, roomId, sessionId, roomId); }
|
|
703
|
+
/** Point an existing binding at a new Session; the context cursor stays. */
|
|
704
|
+
rebind(memberId, roomId, sessionId) { this.q.rebind.run(sessionId, memberId, roomId); }
|
|
552
705
|
/**
|
|
553
706
|
* Move a member's context cursor up to messageId, but never past a
|
|
554
707
|
* teammate's uncommitted text draft (#121): it keeps its old id when it
|
|
@@ -563,6 +716,19 @@ export class Store {
|
|
|
563
716
|
addJob(memberId, roomId, triggerId, now = Date.now() / 1000) {
|
|
564
717
|
return this.q.addJob.get(memberId, roomId, triggerId, now);
|
|
565
718
|
}
|
|
719
|
+
jobByTrigger(memberId, triggerId) {
|
|
720
|
+
return this.q.jobByTrigger.get(memberId, triggerId);
|
|
721
|
+
}
|
|
722
|
+
retryJob(memberId, roomId, triggerId, now = Date.now() / 1000) {
|
|
723
|
+
const existing = this.jobByTrigger(memberId, triggerId);
|
|
724
|
+
if (existing !== undefined) {
|
|
725
|
+
return this.q.resetJob.get('pending', now, existing.id);
|
|
726
|
+
}
|
|
727
|
+
return this.addJob(memberId, roomId, triggerId, now);
|
|
728
|
+
}
|
|
729
|
+
lastUserMessage(roomId, beforeId) {
|
|
730
|
+
return this.q.lastUserMessage.get(roomId, beforeId);
|
|
731
|
+
}
|
|
566
732
|
job(id) { return this.q.job.get(id); }
|
|
567
733
|
openJobs() { return this.q.openJobs.all(); }
|
|
568
734
|
/** Record admission before the prompt reaches the Agent; false if already moved on. */
|
|
@@ -587,12 +753,21 @@ export function publicMessage(row, store) {
|
|
|
587
753
|
const out = {
|
|
588
754
|
id: row.id, room_id: row.room_id, author_kind: row.author_kind, author: row.author, name: row.name,
|
|
589
755
|
text: row.text, created_at: row.created_at, card: row.card, from_turn: row.from_turn,
|
|
756
|
+
...(row.edited_at != null ? { edited_at: row.edited_at } : {}),
|
|
590
757
|
};
|
|
591
758
|
const quote = store?.quote(row);
|
|
592
759
|
if (quote !== undefined) out.reply_to = quote;
|
|
760
|
+
const attachments = messageAttachments(row);
|
|
761
|
+
if (attachments.length) out.attachments = attachments;
|
|
593
762
|
return out;
|
|
594
763
|
}
|
|
595
764
|
|
|
765
|
+
/** #144: the uploaded files a message carries ([] when none or unreadable). */
|
|
766
|
+
export function messageAttachments(row) {
|
|
767
|
+
if (!row?.attachments) return [];
|
|
768
|
+
try { const list = JSON.parse(row.attachments); return Array.isArray(list) ? list : []; } catch { return []; }
|
|
769
|
+
}
|
|
770
|
+
|
|
596
771
|
/** Discord-style quote prefix for agent prompts: `[回复 作者:前40字…] ` (discord.go:1645-1650). */
|
|
597
772
|
export function quotePrefix(quote) {
|
|
598
773
|
if (quote === undefined) return '';
|