ai_co_discuss 0.2.62 → 0.2.64
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/.agents/skills/discuss-participant/references/free.md +1 -1
- package/backend/free_room.py +1042 -994
- package/backend/server.py +1 -1
- package/docs/architecture.md +1 -1
- package/docs/cli.md +1 -1
- package/docs/runner.md +7 -1
- package/docs/workflow.md +2 -2
- package/package.json +1 -1
- package/runner/discussion_service.mjs +3 -2
- package/runner/free_runner.mjs +63 -22
- package/runner/scheduler.mjs +25 -11
- package/web/app.js +12 -1
- package/web/modern.css +1 -1
- package/web/modern.js +1046 -972
package/backend/free_room.py
CHANGED
|
@@ -1,994 +1,1042 @@
|
|
|
1
|
-
"""Free collaboration rooms: member credentials, durable requests and per-member executions.
|
|
2
|
-
|
|
3
|
-
A free room has no floor, turns or rounds. The user (holding the room control credential)
|
|
4
|
-
names members; each named member gets one durable request per message. The local runner
|
|
5
|
-
delivers a runner member's open requests as one execution, and the member's answer completes
|
|
6
|
-
them. A manual member finds its own requests and answers by request id; an online member's
|
|
7
|
-
answer is forwarded by the owner. The user is the client, never the host: one member that
|
|
8
|
-
answers by itself always hosts, routes unnamed messages, names members in its answers, and is
|
|
9
|
-
woken once when everything it dispatched has finished. No software is launched here.
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
import hmac
|
|
13
|
-
import json
|
|
14
|
-
import re
|
|
15
|
-
import time
|
|
16
|
-
import uuid
|
|
17
|
-
|
|
18
|
-
from .identity import MAX_ROOM_AGENTS, is_ai
|
|
19
|
-
from .runtime import LEASE_SECONDS, begin_write, require_owner, token_hash
|
|
20
|
-
|
|
21
|
-
FREE_EXECUTION_MINUTES = 20
|
|
22
|
-
RECENT_REQUESTS = 100
|
|
23
|
-
RECENT_EXECUTIONS = 100
|
|
24
|
-
RECENT_CHANGES = 50
|
|
25
|
-
MAX_CHANGED_FILES = 200
|
|
26
|
-
MAX_PATH_LENGTH = 500
|
|
27
|
-
ACTIVE = ("dispatched", "started")
|
|
28
|
-
ACCESS_MODES = ("runner", "manual", "online")
|
|
29
|
-
SCHEDULER_PROVIDERS = ("jev", "decisions")
|
|
30
|
-
RECOMMEND_SECONDS = 60
|
|
31
|
-
# A click sent just before the hint disappears still counts.
|
|
32
|
-
RECOMMEND_GRACE_SECONDS = 5
|
|
33
|
-
# AI-created requests (host dispatches and hand-offs) allowed per chain; each owner message starts a chain.
|
|
34
|
-
MAX_AUTO_HOPS = 8
|
|
35
|
-
# A host that left gives up the role; the first active runner member (else manual) then takes it.
|
|
36
|
-
RELEASE_HOST = "UPDATE room_members SET role='member' WHERE room_id=? AND role='host' AND status<>'active'"
|
|
37
|
-
ASSIGN_HOST = """UPDATE room_members SET role='host' WHERE id=(SELECT id FROM room_members WHERE room_id=? AND status='active'
|
|
38
|
-
AND access<>'online' ORDER BY access='runner' DESC, id LIMIT 1)
|
|
39
|
-
AND NOT EXISTS (SELECT 1 FROM room_members WHERE room_id=? AND role='host' AND status='active')"""
|
|
40
|
-
TERMINAL = ("completed", "cancelled", "failed")
|
|
41
|
-
SESSION_MODES = ("new", "borrowed")
|
|
42
|
-
FREE_OPERATIONS = ("messages", "attachments", "move", "folder", "archive", "restore", "delete")
|
|
43
|
-
MEMBER_FIELDS = ("id", "name", "adapter", "role", "status", "access", "session_mode", "read_until")
|
|
44
|
-
EXECUTION_FIELDS = ("id", "member_id", "status", "session_id", "lease_until", "created_at", "finished_at", "result")
|
|
45
|
-
# Columns added after the first free-room tables; Python databases created by that build gain them here.
|
|
46
|
-
LATER_COLUMNS = {"room_members": {"access": "TEXT NOT NULL DEFAULT 'runner'", "session_mode": "TEXT NOT NULL DEFAULT 'new'"},
|
|
47
|
-
"member_executions": {"changes": "TEXT NOT NULL DEFAULT ''", "cwd": "TEXT NOT NULL DEFAULT ''"},
|
|
48
|
-
"member_requests": {"chain_id": "INTEGER NOT NULL DEFAULT 0", "batch_id": "INTEGER NOT NULL DEFAULT 0",
|
|
49
|
-
"purpose": "TEXT NOT NULL DEFAULT 'ask'", "created_by": "TEXT NOT NULL DEFAULT 'user'"}}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def migrate_free_tables(connection):
|
|
53
|
-
"""Add columns that early free-room databases lack; repeated runs change nothing."""
|
|
54
|
-
for table, columns in LATER_COLUMNS.items():
|
|
55
|
-
present = {row[1] for row in connection.execute(f"PRAGMA table_info({table})")}
|
|
56
|
-
for name, definition in columns.items():
|
|
57
|
-
if name not in present:
|
|
58
|
-
connection.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def room_mode(connection, room_id):
|
|
62
|
-
"""Return 'free' for free rooms; every legacy room is a brainstorm room."""
|
|
63
|
-
row = connection.execute("SELECT mode FROM room_modes WHERE room_id=?", (room_id,)).fetchone()
|
|
64
|
-
return row[0] if row else "brainstorm"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def member_value(member):
|
|
68
|
-
"""Validate one member and its client-generated credential."""
|
|
69
|
-
if not isinstance(member, dict) or not is_ai(member.get("name")):
|
|
70
|
-
raise ValueError("成员须为有效 AI 身份")
|
|
71
|
-
if not token_hash(member.get("token")):
|
|
72
|
-
raise ValueError("每位成员须提供 32–256 字符的成员凭证")
|
|
73
|
-
adapter = member.get("adapter", "")
|
|
74
|
-
if not isinstance(adapter, str) or len(adapter) > 40:
|
|
75
|
-
raise ValueError("成员适配器名称无效")
|
|
76
|
-
access, session_mode = member.get("access", "runner"), member.get("session_mode", "new")
|
|
77
|
-
if access not in ACCESS_MODES or session_mode not in SESSION_MODES:
|
|
78
|
-
raise ValueError("成员接入方式须为 runner/manual/online,会话方式须为 new/borrowed")
|
|
79
|
-
return {"name": member["name"], "adapter": adapter, "token": member["token"], "access": access, "session_mode": session_mode}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def member_values(members):
|
|
83
|
-
"""Validate independent member credentials for a room."""
|
|
84
|
-
if not isinstance(members, list) or not len(members) <= MAX_ROOM_AGENTS:
|
|
85
|
-
raise ValueError("房间成员须为列表,且不超过成员上限")
|
|
86
|
-
result = [member_value(member) for member in members]
|
|
87
|
-
if len({member["name"] for member in result}) != len(result):
|
|
88
|
-
raise ValueError("成员名称不能重复")
|
|
89
|
-
if len({member["token"] for member in result}) != len(result):
|
|
90
|
-
raise ValueError("每位成员须使用独立凭证")
|
|
91
|
-
return result
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def insert_member(connection, room_id, member, now):
|
|
95
|
-
"""Store one member with only its credential hash."""
|
|
96
|
-
connection.execute("""INSERT INTO room_members (room_id,name,adapter,token_hash,access,session_mode,created_at)
|
|
97
|
-
VALUES (?,?,?,?,?,?,?)""", (room_id, member["name"], member["adapter"], token_hash(member["token"]),
|
|
98
|
-
member["access"], member["session_mode"], now))
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def save_room_access(connection, room_id, control_token, members, settings):
|
|
102
|
-
"""Persist the mode, the owner's control hash and the members in the creating transaction."""
|
|
103
|
-
control = token_hash(control_token)
|
|
104
|
-
if not control:
|
|
105
|
-
raise ValueError("建房须提供 32–256 字符的房间控制凭据")
|
|
106
|
-
if any(token_hash(member["token"]) == control for member in members):
|
|
107
|
-
raise ValueError("成员凭证不能与房主控制凭据相同")
|
|
108
|
-
connection.execute("INSERT INTO room_modes (room_id,mode,control_hash) VALUES (?,?,?)", (room_id, settings["mode"], control))
|
|
109
|
-
for member in members:
|
|
110
|
-
insert_member(connection, room_id, member, settings["created_at"])
|
|
111
|
-
connection.execute("UPDATE room_members SET role='host' WHERE room_id=? AND name=?", (room_id, settings["host"]))
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def discussion_access_ok(access, capability, automatic, is_host):
|
|
115
|
-
"""Online experts stay online; a woken discussion also takes manual experts, but never a manual host."""
|
|
116
|
-
if capability == "online":
|
|
117
|
-
return access == "online"
|
|
118
|
-
if not automatic:
|
|
119
|
-
return access == "manual"
|
|
120
|
-
return access == "runner" or (access == "manual" and not is_host)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def mark_manual(member, automatic):
|
|
124
|
-
"""A manual expert of a woken discussion carries adapter 'manual', so mode switches never turn it into a woken member."""
|
|
125
|
-
return {**member, "adapter": "manual"} if automatic and member["access"] == "manual" else member
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def discussion_members(payload, settings):
|
|
129
|
-
"""Validate credentials against the room's declared participants and delivery mode."""
|
|
130
|
-
members = member_values(payload.get("members")) if settings["experts"] else []
|
|
131
|
-
if {member["name"] for member in members} != set(settings["experts"]):
|
|
132
|
-
raise ValueError("成员凭证名单须与参与者名单一致")
|
|
133
|
-
for member in members:
|
|
134
|
-
if not discussion_access_ok(member["access"], settings["experts"][member["name"]],
|
|
135
|
-
payload.get("runtime_mode") == "automatic", member["name"] == settings["host"]):
|
|
136
|
-
raise ValueError("成员接入方式与房间参与能力、任务交付方式不符")
|
|
137
|
-
return [mark_manual(member, payload.get("runtime_mode") == "automatic") for member in members]
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def sync_discussion_members(connection, room_id, payload, settings):
|
|
141
|
-
"""Keep existing credentials; require fresh credentials for added or changed members."""
|
|
142
|
-
current = {row["name"]: row for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status='active'", (room_id,))}
|
|
143
|
-
supplied = {member["name"]: member_value(member) for member in payload.get("members", [])}
|
|
144
|
-
run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
|
|
145
|
-
for name, capability in settings["experts"].items():
|
|
146
|
-
automatic = bool(run and run[0] == "automatic")
|
|
147
|
-
old = current.get(name)
|
|
148
|
-
if old and (old["access"] == "online") == (capability == "online"):
|
|
149
|
-
if name in supplied and token_hash(supplied[name]["token"]) != old["token_hash"]:
|
|
150
|
-
raise FileExistsError("成员凭证与已登记身份不符;请核对原接入文件")
|
|
151
|
-
continue
|
|
152
|
-
if name not in supplied or not discussion_access_ok(supplied[name]["access"], capability, automatic, name == settings["host"]):
|
|
153
|
-
raise ValueError(f"新增或改变接入方式的成员 {name} 须提供独立的新凭证")
|
|
154
|
-
member = mark_manual(supplied[name], automatic)
|
|
155
|
-
hashed = token_hash(member["token"])
|
|
156
|
-
if connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND token_hash=? UNION ALL SELECT 1 FROM room_modes WHERE room_id=? AND control_hash=?", (room_id, hashed, room_id, hashed)).fetchone():
|
|
157
|
-
raise ValueError("新成员凭证不能复用已有凭证")
|
|
158
|
-
connection.execute("UPDATE room_members SET status='removed' WHERE room_id=? AND name=?", (room_id, name))
|
|
159
|
-
insert_member(connection, room_id, member, int(time.time()))
|
|
160
|
-
for name in current.keys() - settings["experts"].keys():
|
|
161
|
-
connection.execute("UPDATE room_members SET status='removed' WHERE room_id=? AND name=?", (room_id, name))
|
|
162
|
-
connection.execute("UPDATE room_members SET role=CASE WHEN name=? THEN 'host' ELSE 'member' END WHERE room_id=?", (settings["host"], room_id))
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def sync_participants(connection, room_id):
|
|
166
|
-
"""Keep one active AI host, then mirror member capabilities and the host into shared room metadata."""
|
|
167
|
-
connection.execute(RELEASE_HOST, (room_id,))
|
|
168
|
-
connection.execute(ASSIGN_HOST, (room_id, room_id))
|
|
169
|
-
members = connection.execute("SELECT name,access,role,status FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)).fetchall()
|
|
170
|
-
experts = {row["name"]: "online" if row["access"] == "online" else "cli" for row in members}
|
|
171
|
-
host = next((row["name"] for row in members if row["role"] == "host" and row["status"] == "active"), "用户")
|
|
172
|
-
connection.execute("UPDATE topic_settings SET experts=?,host=? WHERE room_id=?",
|
|
173
|
-
(json.dumps(experts, ensure_ascii=False), host, room_id))
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def execution_minutes(connection, room_id):
|
|
177
|
-
"""Free rooms always bound one execution; zero in the shared limits means the default."""
|
|
178
|
-
row = connection.execute("SELECT execution_minutes FROM discussion_limits WHERE room_id=?", (room_id,)).fetchone()
|
|
179
|
-
return (row[0] if row and row[0] else FREE_EXECUTION_MINUTES)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def public_execution(row):
|
|
183
|
-
"""Return execution state without any credential material."""
|
|
184
|
-
return {key: row[key] for key in EXECUTION_FIELDS} if row else None
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def recent_executions(connection, room_id):
|
|
188
|
-
"""Return bounded public reports, including requests no longer in the recent request list."""
|
|
189
|
-
rows = connection.execute("""SELECT e.*,
|
|
190
|
-
(SELECT json_group_array(id) FROM member_requests WHERE execution_id=e.id) AS request_ids,
|
|
191
|
-
(SELECT MAX(answer_id) FROM member_requests WHERE execution_id=e.id) AS answer_id,
|
|
192
|
-
(SELECT MIN(message_id) FROM member_requests WHERE execution_id=e.id) AS request_message_id
|
|
193
|
-
FROM member_executions e WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_EXECUTIONS))
|
|
194
|
-
return [{**public_execution(row), "cwd": row["cwd"], "changes": json.loads(row["changes"] or "[]"),
|
|
195
|
-
"changes_recorded": bool(row["changes"]), "request_ids": json.loads(row["request_ids"]),
|
|
196
|
-
"answer_id": row["answer_id"], "request_message_id": row["request_message_id"]} for row in rows]
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
def possible_conflicts(connection, room_id, names):
|
|
200
|
-
"""Report files changed by executions of different members whose time windows overlapped.
|
|
201
|
-
|
|
202
|
-
This is an observation, not attribution: it cannot tell who wrote which part or catch
|
|
203
|
-
writes outside the scanned working directory.
|
|
204
|
-
"""
|
|
205
|
-
now = int(time.time())
|
|
206
|
-
rows = [dict(row) for row in connection.execute(
|
|
207
|
-
"""SELECT id,member_id,created_at,finished_at,changes,cwd FROM member_executions
|
|
208
|
-
WHERE room_id=? AND changes<>'' ORDER BY id DESC LIMIT ?""", (room_id, RECENT_CHANGES))]
|
|
209
|
-
for row in rows:
|
|
210
|
-
row["files"] = set(json.loads(row["changes"]))
|
|
211
|
-
row["end"] = row["finished_at"] or now
|
|
212
|
-
conflicts = []
|
|
213
|
-
for index, first in enumerate(rows):
|
|
214
|
-
for second in rows[index + 1:]:
|
|
215
|
-
if first["member_id"] == second["member_id"] or first["cwd"] != second["cwd"]:
|
|
216
|
-
continue
|
|
217
|
-
if first["created_at"] > second["end"] or second["created_at"] > first["end"]:
|
|
218
|
-
continue
|
|
219
|
-
files = sorted(first["files"] & second["files"])
|
|
220
|
-
if files:
|
|
221
|
-
conflicts.append({"members": [names.get(second["member_id"], ""), names.get(first["member_id"], "")],
|
|
222
|
-
"executions": [second["id"], first["id"]], "files": files})
|
|
223
|
-
return conflicts
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
""
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
row
|
|
265
|
-
|
|
266
|
-
return row
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
def
|
|
270
|
-
"""
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
""
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
return [
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
""
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
if
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
return [
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
insert_request(connection, room_id,
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
return
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
def
|
|
591
|
-
"""
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
connection.execute("
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
if
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
raise FileExistsError("
|
|
734
|
-
if not connection.execute("SELECT 1 FROM
|
|
735
|
-
raise FileExistsError("
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
if not
|
|
781
|
-
raise
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
connection.execute("
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
def
|
|
854
|
-
"""
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
""
|
|
858
|
-
member
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
member
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
if
|
|
881
|
-
raise
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
return
|
|
931
|
-
|
|
932
|
-
raise
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
connection.execute("UPDATE room_members SET status=? WHERE id=?", (
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1
|
+
"""Free collaboration rooms: member credentials, durable requests and per-member executions.
|
|
2
|
+
|
|
3
|
+
A free room has no floor, turns or rounds. The user (holding the room control credential)
|
|
4
|
+
names members; each named member gets one durable request per message. The local runner
|
|
5
|
+
delivers a runner member's open requests as one execution, and the member's answer completes
|
|
6
|
+
them. A manual member finds its own requests and answers by request id; an online member's
|
|
7
|
+
answer is forwarded by the owner. The user is the client, never the host: one member that
|
|
8
|
+
answers by itself always hosts, routes unnamed messages, names members in its answers, and is
|
|
9
|
+
woken once when everything it dispatched has finished. No software is launched here.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import hmac
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
|
|
18
|
+
from .identity import MAX_ROOM_AGENTS, is_ai
|
|
19
|
+
from .runtime import LEASE_SECONDS, begin_write, require_owner, token_hash
|
|
20
|
+
|
|
21
|
+
FREE_EXECUTION_MINUTES = 20
|
|
22
|
+
RECENT_REQUESTS = 100
|
|
23
|
+
RECENT_EXECUTIONS = 100
|
|
24
|
+
RECENT_CHANGES = 50
|
|
25
|
+
MAX_CHANGED_FILES = 200
|
|
26
|
+
MAX_PATH_LENGTH = 500
|
|
27
|
+
ACTIVE = ("dispatched", "started")
|
|
28
|
+
ACCESS_MODES = ("runner", "manual", "online")
|
|
29
|
+
SCHEDULER_PROVIDERS = ("jev", "decisions")
|
|
30
|
+
RECOMMEND_SECONDS = 60
|
|
31
|
+
# A click sent just before the hint disappears still counts.
|
|
32
|
+
RECOMMEND_GRACE_SECONDS = 5
|
|
33
|
+
# AI-created requests (host dispatches and hand-offs) allowed per chain; each owner message starts a chain.
|
|
34
|
+
MAX_AUTO_HOPS = 8
|
|
35
|
+
# A host that left gives up the role; the first active runner member (else manual) then takes it.
|
|
36
|
+
RELEASE_HOST = "UPDATE room_members SET role='member' WHERE room_id=? AND role='host' AND status<>'active'"
|
|
37
|
+
ASSIGN_HOST = """UPDATE room_members SET role='host' WHERE id=(SELECT id FROM room_members WHERE room_id=? AND status='active'
|
|
38
|
+
AND access<>'online' ORDER BY access='runner' DESC, id LIMIT 1)
|
|
39
|
+
AND NOT EXISTS (SELECT 1 FROM room_members WHERE room_id=? AND role='host' AND status='active')"""
|
|
40
|
+
TERMINAL = ("completed", "cancelled", "failed")
|
|
41
|
+
SESSION_MODES = ("new", "borrowed")
|
|
42
|
+
FREE_OPERATIONS = ("messages", "attachments", "move", "folder", "archive", "restore", "delete")
|
|
43
|
+
MEMBER_FIELDS = ("id", "name", "adapter", "role", "status", "access", "session_mode", "read_until")
|
|
44
|
+
EXECUTION_FIELDS = ("id", "member_id", "status", "session_id", "lease_until", "created_at", "finished_at", "result")
|
|
45
|
+
# Columns added after the first free-room tables; Python databases created by that build gain them here.
|
|
46
|
+
LATER_COLUMNS = {"room_members": {"access": "TEXT NOT NULL DEFAULT 'runner'", "session_mode": "TEXT NOT NULL DEFAULT 'new'"},
|
|
47
|
+
"member_executions": {"changes": "TEXT NOT NULL DEFAULT ''", "cwd": "TEXT NOT NULL DEFAULT ''"},
|
|
48
|
+
"member_requests": {"chain_id": "INTEGER NOT NULL DEFAULT 0", "batch_id": "INTEGER NOT NULL DEFAULT 0",
|
|
49
|
+
"purpose": "TEXT NOT NULL DEFAULT 'ask'", "created_by": "TEXT NOT NULL DEFAULT 'user'"}}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def migrate_free_tables(connection):
|
|
53
|
+
"""Add columns that early free-room databases lack; repeated runs change nothing."""
|
|
54
|
+
for table, columns in LATER_COLUMNS.items():
|
|
55
|
+
present = {row[1] for row in connection.execute(f"PRAGMA table_info({table})")}
|
|
56
|
+
for name, definition in columns.items():
|
|
57
|
+
if name not in present:
|
|
58
|
+
connection.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def room_mode(connection, room_id):
|
|
62
|
+
"""Return 'free' for free rooms; every legacy room is a brainstorm room."""
|
|
63
|
+
row = connection.execute("SELECT mode FROM room_modes WHERE room_id=?", (room_id,)).fetchone()
|
|
64
|
+
return row[0] if row else "brainstorm"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def member_value(member):
|
|
68
|
+
"""Validate one member and its client-generated credential."""
|
|
69
|
+
if not isinstance(member, dict) or not is_ai(member.get("name")):
|
|
70
|
+
raise ValueError("成员须为有效 AI 身份")
|
|
71
|
+
if not token_hash(member.get("token")):
|
|
72
|
+
raise ValueError("每位成员须提供 32–256 字符的成员凭证")
|
|
73
|
+
adapter = member.get("adapter", "")
|
|
74
|
+
if not isinstance(adapter, str) or len(adapter) > 40:
|
|
75
|
+
raise ValueError("成员适配器名称无效")
|
|
76
|
+
access, session_mode = member.get("access", "runner"), member.get("session_mode", "new")
|
|
77
|
+
if access not in ACCESS_MODES or session_mode not in SESSION_MODES:
|
|
78
|
+
raise ValueError("成员接入方式须为 runner/manual/online,会话方式须为 new/borrowed")
|
|
79
|
+
return {"name": member["name"], "adapter": adapter, "token": member["token"], "access": access, "session_mode": session_mode}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def member_values(members):
|
|
83
|
+
"""Validate independent member credentials for a room."""
|
|
84
|
+
if not isinstance(members, list) or not len(members) <= MAX_ROOM_AGENTS:
|
|
85
|
+
raise ValueError("房间成员须为列表,且不超过成员上限")
|
|
86
|
+
result = [member_value(member) for member in members]
|
|
87
|
+
if len({member["name"] for member in result}) != len(result):
|
|
88
|
+
raise ValueError("成员名称不能重复")
|
|
89
|
+
if len({member["token"] for member in result}) != len(result):
|
|
90
|
+
raise ValueError("每位成员须使用独立凭证")
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def insert_member(connection, room_id, member, now):
|
|
95
|
+
"""Store one member with only its credential hash."""
|
|
96
|
+
connection.execute("""INSERT INTO room_members (room_id,name,adapter,token_hash,access,session_mode,created_at)
|
|
97
|
+
VALUES (?,?,?,?,?,?,?)""", (room_id, member["name"], member["adapter"], token_hash(member["token"]),
|
|
98
|
+
member["access"], member["session_mode"], now))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def save_room_access(connection, room_id, control_token, members, settings):
|
|
102
|
+
"""Persist the mode, the owner's control hash and the members in the creating transaction."""
|
|
103
|
+
control = token_hash(control_token)
|
|
104
|
+
if not control:
|
|
105
|
+
raise ValueError("建房须提供 32–256 字符的房间控制凭据")
|
|
106
|
+
if any(token_hash(member["token"]) == control for member in members):
|
|
107
|
+
raise ValueError("成员凭证不能与房主控制凭据相同")
|
|
108
|
+
connection.execute("INSERT INTO room_modes (room_id,mode,control_hash) VALUES (?,?,?)", (room_id, settings["mode"], control))
|
|
109
|
+
for member in members:
|
|
110
|
+
insert_member(connection, room_id, member, settings["created_at"])
|
|
111
|
+
connection.execute("UPDATE room_members SET role='host' WHERE room_id=? AND name=?", (room_id, settings["host"]))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def discussion_access_ok(access, capability, automatic, is_host):
|
|
115
|
+
"""Online experts stay online; a woken discussion also takes manual experts, but never a manual host."""
|
|
116
|
+
if capability == "online":
|
|
117
|
+
return access == "online"
|
|
118
|
+
if not automatic:
|
|
119
|
+
return access == "manual"
|
|
120
|
+
return access == "runner" or (access == "manual" and not is_host)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def mark_manual(member, automatic):
|
|
124
|
+
"""A manual expert of a woken discussion carries adapter 'manual', so mode switches never turn it into a woken member."""
|
|
125
|
+
return {**member, "adapter": "manual"} if automatic and member["access"] == "manual" else member
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def discussion_members(payload, settings):
|
|
129
|
+
"""Validate credentials against the room's declared participants and delivery mode."""
|
|
130
|
+
members = member_values(payload.get("members")) if settings["experts"] else []
|
|
131
|
+
if {member["name"] for member in members} != set(settings["experts"]):
|
|
132
|
+
raise ValueError("成员凭证名单须与参与者名单一致")
|
|
133
|
+
for member in members:
|
|
134
|
+
if not discussion_access_ok(member["access"], settings["experts"][member["name"]],
|
|
135
|
+
payload.get("runtime_mode") == "automatic", member["name"] == settings["host"]):
|
|
136
|
+
raise ValueError("成员接入方式与房间参与能力、任务交付方式不符")
|
|
137
|
+
return [mark_manual(member, payload.get("runtime_mode") == "automatic") for member in members]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def sync_discussion_members(connection, room_id, payload, settings):
|
|
141
|
+
"""Keep existing credentials; require fresh credentials for added or changed members."""
|
|
142
|
+
current = {row["name"]: row for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status='active'", (room_id,))}
|
|
143
|
+
supplied = {member["name"]: member_value(member) for member in payload.get("members", [])}
|
|
144
|
+
run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
|
|
145
|
+
for name, capability in settings["experts"].items():
|
|
146
|
+
automatic = bool(run and run[0] == "automatic")
|
|
147
|
+
old = current.get(name)
|
|
148
|
+
if old and (old["access"] == "online") == (capability == "online"):
|
|
149
|
+
if name in supplied and token_hash(supplied[name]["token"]) != old["token_hash"]:
|
|
150
|
+
raise FileExistsError("成员凭证与已登记身份不符;请核对原接入文件")
|
|
151
|
+
continue
|
|
152
|
+
if name not in supplied or not discussion_access_ok(supplied[name]["access"], capability, automatic, name == settings["host"]):
|
|
153
|
+
raise ValueError(f"新增或改变接入方式的成员 {name} 须提供独立的新凭证")
|
|
154
|
+
member = mark_manual(supplied[name], automatic)
|
|
155
|
+
hashed = token_hash(member["token"])
|
|
156
|
+
if connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND token_hash=? UNION ALL SELECT 1 FROM room_modes WHERE room_id=? AND control_hash=?", (room_id, hashed, room_id, hashed)).fetchone():
|
|
157
|
+
raise ValueError("新成员凭证不能复用已有凭证")
|
|
158
|
+
connection.execute("UPDATE room_members SET status='removed' WHERE room_id=? AND name=?", (room_id, name))
|
|
159
|
+
insert_member(connection, room_id, member, int(time.time()))
|
|
160
|
+
for name in current.keys() - settings["experts"].keys():
|
|
161
|
+
connection.execute("UPDATE room_members SET status='removed' WHERE room_id=? AND name=?", (room_id, name))
|
|
162
|
+
connection.execute("UPDATE room_members SET role=CASE WHEN name=? THEN 'host' ELSE 'member' END WHERE room_id=?", (settings["host"], room_id))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def sync_participants(connection, room_id):
|
|
166
|
+
"""Keep one active AI host, then mirror member capabilities and the host into shared room metadata."""
|
|
167
|
+
connection.execute(RELEASE_HOST, (room_id,))
|
|
168
|
+
connection.execute(ASSIGN_HOST, (room_id, room_id))
|
|
169
|
+
members = connection.execute("SELECT name,access,role,status FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)).fetchall()
|
|
170
|
+
experts = {row["name"]: "online" if row["access"] == "online" else "cli" for row in members}
|
|
171
|
+
host = next((row["name"] for row in members if row["role"] == "host" and row["status"] == "active"), "用户")
|
|
172
|
+
connection.execute("UPDATE topic_settings SET experts=?,host=? WHERE room_id=?",
|
|
173
|
+
(json.dumps(experts, ensure_ascii=False), host, room_id))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def execution_minutes(connection, room_id):
|
|
177
|
+
"""Free rooms always bound one execution; zero in the shared limits means the default."""
|
|
178
|
+
row = connection.execute("SELECT execution_minutes FROM discussion_limits WHERE room_id=?", (room_id,)).fetchone()
|
|
179
|
+
return (row[0] if row and row[0] else FREE_EXECUTION_MINUTES)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def public_execution(row):
|
|
183
|
+
"""Return execution state without any credential material."""
|
|
184
|
+
return {key: row[key] for key in EXECUTION_FIELDS} if row else None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def recent_executions(connection, room_id):
|
|
188
|
+
"""Return bounded public reports, including requests no longer in the recent request list."""
|
|
189
|
+
rows = connection.execute("""SELECT e.*,
|
|
190
|
+
(SELECT json_group_array(id) FROM member_requests WHERE execution_id=e.id) AS request_ids,
|
|
191
|
+
(SELECT MAX(answer_id) FROM member_requests WHERE execution_id=e.id) AS answer_id,
|
|
192
|
+
(SELECT MIN(message_id) FROM member_requests WHERE execution_id=e.id) AS request_message_id
|
|
193
|
+
FROM member_executions e WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_EXECUTIONS))
|
|
194
|
+
return [{**public_execution(row), "cwd": row["cwd"], "changes": json.loads(row["changes"] or "[]"),
|
|
195
|
+
"changes_recorded": bool(row["changes"]), "request_ids": json.loads(row["request_ids"]),
|
|
196
|
+
"answer_id": row["answer_id"], "request_message_id": row["request_message_id"]} for row in rows]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def possible_conflicts(connection, room_id, names):
|
|
200
|
+
"""Report files changed by executions of different members whose time windows overlapped.
|
|
201
|
+
|
|
202
|
+
This is an observation, not attribution: it cannot tell who wrote which part or catch
|
|
203
|
+
writes outside the scanned working directory.
|
|
204
|
+
"""
|
|
205
|
+
now = int(time.time())
|
|
206
|
+
rows = [dict(row) for row in connection.execute(
|
|
207
|
+
"""SELECT id,member_id,created_at,finished_at,changes,cwd FROM member_executions
|
|
208
|
+
WHERE room_id=? AND changes<>'' ORDER BY id DESC LIMIT ?""", (room_id, RECENT_CHANGES))]
|
|
209
|
+
for row in rows:
|
|
210
|
+
row["files"] = set(json.loads(row["changes"]))
|
|
211
|
+
row["end"] = row["finished_at"] or now
|
|
212
|
+
conflicts = []
|
|
213
|
+
for index, first in enumerate(rows):
|
|
214
|
+
for second in rows[index + 1:]:
|
|
215
|
+
if first["member_id"] == second["member_id"] or first["cwd"] != second["cwd"]:
|
|
216
|
+
continue
|
|
217
|
+
if first["created_at"] > second["end"] or second["created_at"] > first["end"]:
|
|
218
|
+
continue
|
|
219
|
+
files = sorted(first["files"] & second["files"])
|
|
220
|
+
if files:
|
|
221
|
+
conflicts.append({"members": [names.get(second["member_id"], ""), names.get(first["member_id"], "")],
|
|
222
|
+
"executions": [second["id"], first["id"]], "files": files})
|
|
223
|
+
return conflicts
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# An invited manual member that has not registered counts as absent: it receives no request and is never a target.
|
|
227
|
+
REGISTERED_MEMBER = """(room_members.access<>'manual' OR EXISTS(SELECT 1 FROM member_registrations r
|
|
228
|
+
WHERE r.member_id=room_members.id AND r.token_hash=room_members.token_hash))"""
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def free_view(connection, room_id):
|
|
232
|
+
"""Summarize members, their live execution, recent requests and possible file conflicts."""
|
|
233
|
+
now = int(time.time())
|
|
234
|
+
members = []
|
|
235
|
+
for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)):
|
|
236
|
+
member = {key: row[key] for key in MEMBER_FIELDS}
|
|
237
|
+
member["registered_at"] = registered_at(connection, row)
|
|
238
|
+
reported = connection.execute("SELECT session_id,client FROM member_sessions WHERE member_id=?", (row["id"],)).fetchone()
|
|
239
|
+
member["reported_session"], member["reported_client"] = (reported["session_id"], reported["client"]) if reported else ("", "")
|
|
240
|
+
latest = connection.execute("SELECT * FROM member_executions WHERE member_id=? ORDER BY id DESC LIMIT 1",
|
|
241
|
+
(row["id"],)).fetchone()
|
|
242
|
+
member["execution"] = public_execution(latest)
|
|
243
|
+
if latest and latest["status"] in ACTIVE:
|
|
244
|
+
member["execution"]["stale"] = latest["lease_until"] <= now
|
|
245
|
+
member["pending"] = connection.execute("SELECT COUNT(*) FROM member_requests WHERE member_id=? AND status='pending'",
|
|
246
|
+
(row["id"],)).fetchone()[0]
|
|
247
|
+
members.append(member)
|
|
248
|
+
names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
|
|
249
|
+
requests = [{**dict(row), "member": names.get(row["member_id"], "")} for row in connection.execute(
|
|
250
|
+
"""SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by FROM member_requests
|
|
251
|
+
WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
|
|
252
|
+
chains = [dict(row) for row in connection.execute(
|
|
253
|
+
"SELECT chain_id,reason FROM free_chains WHERE room_id=? ORDER BY chain_id DESC LIMIT 10", (room_id,))]
|
|
254
|
+
return {"members": members, "requests": requests[::-1], "executions": recent_executions(connection, room_id), "execution_minutes": execution_minutes(connection, room_id),
|
|
255
|
+
"conflicts": possible_conflicts(connection, room_id, names), "halted_chains": chains, "max_auto_hops": MAX_AUTO_HOPS,
|
|
256
|
+
"recommendations": recent_recommendations(connection, room_id, now)[::-1]}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def require_member(connection, room_id, token, name=None):
|
|
260
|
+
"""Resolve an active member strictly from its own credential."""
|
|
261
|
+
hashed = token_hash(token)
|
|
262
|
+
row = connection.execute("SELECT * FROM room_members WHERE room_id=? AND token_hash=? AND status='active'",
|
|
263
|
+
(room_id, hashed)).fetchone() if hashed else None
|
|
264
|
+
if not row or (name is not None and row["name"] != name):
|
|
265
|
+
raise PermissionError("成员凭证无效、已停用或与发言身份不符")
|
|
266
|
+
return row
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def registered_at(connection, member):
|
|
270
|
+
"""Return when the member registered with its current credential, or '' while it has not."""
|
|
271
|
+
row = connection.execute("SELECT registered_at FROM member_registrations WHERE member_id=? AND token_hash=?",
|
|
272
|
+
(member["id"], member["token_hash"])).fetchone()
|
|
273
|
+
return row[0] if row else ""
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def register_member(connection, room_id, token, now):
|
|
277
|
+
"""A manual member announces itself with its own credential; repeating it keeps the first time.
|
|
278
|
+
|
|
279
|
+
The session ID is not part of the request: the CLI attaches it to every call and the room records it separately.
|
|
280
|
+
"""
|
|
281
|
+
begin_write(connection)
|
|
282
|
+
member = require_member(connection, room_id, token)
|
|
283
|
+
if member["access"] != "manual":
|
|
284
|
+
raise ValueError("只有手动接入的成员需要注册")
|
|
285
|
+
previous = registered_at(connection, member)
|
|
286
|
+
stamp = previous or now
|
|
287
|
+
connection.execute("""INSERT INTO member_registrations (member_id,room_id,token_hash,registered_at) VALUES (?,?,?,?)
|
|
288
|
+
ON CONFLICT(member_id) DO UPDATE SET token_hash=excluded.token_hash,registered_at=excluded.registered_at""",
|
|
289
|
+
(member["id"], room_id, member["token_hash"], stamp))
|
|
290
|
+
if not previous:
|
|
291
|
+
queue_invited_expert(connection, room_id, member["name"], now)
|
|
292
|
+
reported = connection.execute("SELECT client FROM member_sessions WHERE member_id=?", (member["id"],)).fetchone()
|
|
293
|
+
return {"member": member["name"], "registered_at": stamp, "session_client": reported[0] if reported else ""}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def authorize_free_write(connection, room_id, token, operation, payload):
|
|
297
|
+
"""Decide who is writing: the owner, or one member posting its own answer.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
"用户" for the owner, or the member row for a member credential.
|
|
301
|
+
"""
|
|
302
|
+
begin_write(connection)
|
|
303
|
+
if operation not in FREE_OPERATIONS:
|
|
304
|
+
raise ValueError("自由房间不使用发言权、安排、阶段或轮次")
|
|
305
|
+
try:
|
|
306
|
+
require_owner(connection, room_id, token)
|
|
307
|
+
return "用户"
|
|
308
|
+
except PermissionError:
|
|
309
|
+
if operation not in ("messages", "attachments"):
|
|
310
|
+
raise
|
|
311
|
+
author = payload.get("author")
|
|
312
|
+
if author == "用户" or payload.get("submitted_by", author) != author:
|
|
313
|
+
raise PermissionError("需要本房间的控制凭据;成员不能以用户身份或代他人发言")
|
|
314
|
+
return require_member(connection, room_id, token, author)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def active_members(connection, room_id, registered_only=True):
|
|
318
|
+
"""Map active member names to their rows; unregistered manual members are left out unless a discussion start needs them pending."""
|
|
319
|
+
return {row["name"]: row for row in connection.execute(
|
|
320
|
+
f"SELECT * FROM room_members WHERE room_id=? AND status='active' AND ({REGISTERED_MEMBER} OR ?=0)", (room_id, int(registered_only)))}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def named_members(active, names, exclude=None):
|
|
324
|
+
"""Validate a structured recipient list against the active members."""
|
|
325
|
+
if not isinstance(names, list) or any(name not in active or name == exclude for name in names):
|
|
326
|
+
raise ValueError("点名对象须为本房间在场成员" + (",且不能点名自己" if exclude else ""))
|
|
327
|
+
return [active[name]["id"] for name in dict.fromkeys(names)]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def recipients(connection, room_id, payload):
|
|
331
|
+
"""Resolve an owner message's recipients as (member id, purpose) pairs.
|
|
332
|
+
|
|
333
|
+
A single active member is named by default. With several, an unnamed message goes to the
|
|
334
|
+
AI host for routing; only a room of online members has none, and then it stays a plain note.
|
|
335
|
+
"""
|
|
336
|
+
# Rooms created while the user could host get their AI host on the next message.
|
|
337
|
+
sync_participants(connection, room_id)
|
|
338
|
+
active = active_members(connection, room_id)
|
|
339
|
+
if payload.get("to") is not None:
|
|
340
|
+
return [(member_id, "ask") for member_id in named_members(active, payload["to"])]
|
|
341
|
+
if len(active) == 1:
|
|
342
|
+
return [(next(iter(active.values()))["id"], "ask")]
|
|
343
|
+
host = next((row for row in active.values() if row["role"] == "host"), None)
|
|
344
|
+
return [(host["id"], "route")] if host else []
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def proxy_member(connection, room_id, author):
|
|
348
|
+
"""Return the active online member the owner may forward an answer for."""
|
|
349
|
+
row = connection.execute("SELECT * FROM room_members WHERE room_id=? AND name=? AND status='active' AND access='online'",
|
|
350
|
+
(room_id, author)).fetchone()
|
|
351
|
+
if not row:
|
|
352
|
+
raise PermissionError("房间控制凭据只能以用户身份发言,或代发在线成员的回答")
|
|
353
|
+
return row
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def check_answer(connection, room_id, member, payload):
|
|
357
|
+
"""A runner member completes a live execution; a manual member may close its own open requests.
|
|
358
|
+
|
|
359
|
+
Only the AI host dispatches work; other members' targets remain suggestions.
|
|
360
|
+
"""
|
|
361
|
+
if payload.get("to") is not None:
|
|
362
|
+
named_members(active_members(connection, room_id), payload["to"], exclude=member["name"])
|
|
363
|
+
if member["access"] == "online":
|
|
364
|
+
raise PermissionError("在线成员只读,回答由用户代发")
|
|
365
|
+
if member["access"] == "manual":
|
|
366
|
+
request_ids = payload.get("request_ids", [])
|
|
367
|
+
if payload.get("execution_id") is not None or not isinstance(request_ids, list) or any(type(item) is not int for item in request_ids):
|
|
368
|
+
raise ValueError("手动接入成员用 request_ids 关联请求,不使用执行编号")
|
|
369
|
+
purposes = []
|
|
370
|
+
for request_id in request_ids:
|
|
371
|
+
row = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND id=?", (room_id, request_id)).fetchone()
|
|
372
|
+
if not row or row["member_id"] != member["id"] or row["status"] != "pending":
|
|
373
|
+
raise FileExistsError(f"请求 {request_id} 不属于本成员或已处理")
|
|
374
|
+
purposes.append(row["purpose"])
|
|
375
|
+
check_dispatch(member, payload, purposes)
|
|
376
|
+
return None
|
|
377
|
+
execution_id = payload.get("execution_id")
|
|
378
|
+
if type(execution_id) is not int:
|
|
379
|
+
raise ValueError("成员回答须关联本次执行编号 execution_id")
|
|
380
|
+
row = connection.execute("SELECT * FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
|
|
381
|
+
if not row or row["member_id"] != member["id"]:
|
|
382
|
+
raise PermissionError("执行编号不属于本成员")
|
|
383
|
+
if row["status"] not in ACTIVE or row["lease_until"] <= int(time.time()):
|
|
384
|
+
raise FileExistsError("本次执行已结束、被打断或已过期;回答不能改作普通发言,请保留草稿")
|
|
385
|
+
check_dispatch(member, payload, [item[0] for item in connection.execute(
|
|
386
|
+
"SELECT purpose FROM member_requests WHERE execution_id=? AND status='delivering'", (execution_id,))])
|
|
387
|
+
return row
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def check_dispatch(member, payload, purposes):
|
|
391
|
+
"""A host answering only its share of an everyone request answers as a member, so it may not dispatch others."""
|
|
392
|
+
if member["role"] == "host" and payload.get("to") and purposes and all(purpose == "all" for purpose in purposes):
|
|
393
|
+
raise ValueError("这次是请全体成员各自回应,其他成员已收到请求;主持人只回答自己的部分,不能用 --to 派活")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def check_proxy(connection, room_id, member, payload):
|
|
397
|
+
"""The owner forwards an online member's answer and may close that member's open requests."""
|
|
398
|
+
request_ids = payload.get("request_ids", [])
|
|
399
|
+
if not isinstance(request_ids, list) or any(type(item) is not int for item in request_ids):
|
|
400
|
+
raise ValueError("request_ids 须为请求编号列表")
|
|
401
|
+
for request_id in request_ids:
|
|
402
|
+
row = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND id=?", (room_id, request_id)).fetchone()
|
|
403
|
+
if not row or row["member_id"] != member["id"] or row["status"] != "pending":
|
|
404
|
+
raise FileExistsError(f"请求 {request_id} 不属于该在线成员或已处理")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def insert_request(connection, room_id, member_id, message_id, chain_id, purpose, created_by, batch_id=0):
|
|
408
|
+
"""Queue one request; every request belongs to the chain its owner message started."""
|
|
409
|
+
connection.execute("""INSERT INTO member_requests (room_id,member_id,message_id,chain_id,batch_id,purpose,created_by,created_at)
|
|
410
|
+
VALUES (?,?,?,?,?,?,?,?)""", (room_id, member_id, message_id, chain_id, batch_id, purpose, created_by, int(time.time())))
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def mention_prose(text):
|
|
414
|
+
"""Remove Markdown code and quote blocks before interpreting response suggestions."""
|
|
415
|
+
lines, fence, quoted = [], "", False
|
|
416
|
+
for line in text.splitlines():
|
|
417
|
+
marker = re.match(r"^ {0,3}(`{3,}|~{3,})", line)
|
|
418
|
+
if marker:
|
|
419
|
+
value = marker[1]
|
|
420
|
+
if not fence:
|
|
421
|
+
fence = value
|
|
422
|
+
elif value[0] == fence[0] and len(value) >= len(fence):
|
|
423
|
+
fence = ""
|
|
424
|
+
continue
|
|
425
|
+
if fence or re.match(r"^( {4}|\t)", line):
|
|
426
|
+
continue
|
|
427
|
+
if re.match(r"^ {0,3}>", line):
|
|
428
|
+
quoted = True
|
|
429
|
+
continue
|
|
430
|
+
if not line.strip():
|
|
431
|
+
quoted = False
|
|
432
|
+
if not quoted:
|
|
433
|
+
lines.append(line)
|
|
434
|
+
return re.sub(r"(`+)[\s\S]*?\1(?!`)", "", "\n".join(lines))
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def save_suggestions(connection, room_id, message_id, member, payload):
|
|
438
|
+
"""Save ordinary members' explicit targets and prose mentions without waking anyone."""
|
|
439
|
+
if member == "用户" or member["role"] == "host":
|
|
440
|
+
return
|
|
441
|
+
active = active_members(connection, room_id)
|
|
442
|
+
targets = set(named_members(active, payload["to"] if payload.get("to") is not None else [], exclude=member["name"]))
|
|
443
|
+
prose = mention_prose(str(payload.get("text", "")))
|
|
444
|
+
for name, target in active.items():
|
|
445
|
+
if name != member["name"] and re.search(r"(^|[\s((,,。;;::])@" + re.escape(name) + r"(?![A-Za-z0-9_\-\u4e00-\u9fff])", prose):
|
|
446
|
+
targets.add(target["id"])
|
|
447
|
+
for target_id in targets:
|
|
448
|
+
connection.execute("INSERT INTO member_suggestions (message_id,member_id) VALUES (?,?)", (message_id, target_id))
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def message_suggestions(connection, message_id):
|
|
452
|
+
"""Expose suggested members and the durable request, including terminal conversions."""
|
|
453
|
+
return [dict(row) for row in connection.execute("""SELECT s.member_id,m.name AS member,m.status,
|
|
454
|
+
r.id AS request_id,r.status AS request_status FROM member_suggestions s
|
|
455
|
+
JOIN room_members m ON m.id=s.member_id
|
|
456
|
+
LEFT JOIN member_requests r ON r.message_id=s.message_id AND r.member_id=s.member_id
|
|
457
|
+
WHERE s.message_id=? ORDER BY s.member_id""", (message_id,))]
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def convert_suggestion(connection, room_id, token, payload):
|
|
461
|
+
"""Let the owner turn a saved suggestion into one request and a fresh user chain."""
|
|
462
|
+
begin_write(connection)
|
|
463
|
+
require_owner(connection, room_id, token)
|
|
464
|
+
message_id, member_id = payload.get("message_id"), payload.get("member_id")
|
|
465
|
+
if type(message_id) is not int or type(member_id) is not int:
|
|
466
|
+
raise ValueError("须提供原消息编号 message_id 与成员编号 member_id")
|
|
467
|
+
target = connection.execute("""SELECT m.name,m.status FROM member_suggestions s
|
|
468
|
+
JOIN messages source ON source.id=s.message_id JOIN room_members m ON m.id=s.member_id
|
|
469
|
+
WHERE source.room_id=? AND m.room_id=? AND s.message_id=? AND s.member_id=?""",
|
|
470
|
+
(room_id, room_id, message_id, member_id)).fetchone()
|
|
471
|
+
if not target:
|
|
472
|
+
raise LookupError("这条消息没有该成员的回应建议")
|
|
473
|
+
if target["status"] != "active":
|
|
474
|
+
raise FileExistsError("该成员已离开,不能转为请求")
|
|
475
|
+
if connection.execute("SELECT 1 FROM room_directory WHERE room_id=? AND archived=1", (room_id,)).fetchone():
|
|
476
|
+
raise ValueError("房间已存档;恢复后才能继续协作")
|
|
477
|
+
previous = connection.execute("SELECT * FROM member_requests WHERE message_id=? AND member_id=?", (message_id, member_id)).fetchone()
|
|
478
|
+
if previous:
|
|
479
|
+
return {"request": dict(previous), "duplicate": True}
|
|
480
|
+
confirmation = connection.execute("""INSERT INTO messages (room_id,author,text,reply_to,source,submission_id,created_at)
|
|
481
|
+
VALUES (?,'用户',?,?,'user',?,strftime('%Y-%m-%dT%H:%M:%fZ','now'))""",
|
|
482
|
+
(room_id, f"请 @{target['name']} 回应 #{message_id} 的建议。", message_id, uuid.uuid4().hex))
|
|
483
|
+
connection.execute("INSERT INTO message_details (message_id,kind,submitted_by) VALUES (?,'chat','用户')", (confirmation.lastrowid,))
|
|
484
|
+
insert_request(connection, room_id, member_id, message_id, confirmation.lastrowid, "ask", "user")
|
|
485
|
+
request = connection.execute("SELECT * FROM member_requests WHERE message_id=? AND member_id=?", (message_id, member_id)).fetchone()
|
|
486
|
+
return {"request": dict(request), "duplicate": False}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def halt_chain(connection, room_id, chain_id, reason):
|
|
490
|
+
"""Stop automatic progress on a chain; the user's next message starts a new one."""
|
|
491
|
+
connection.execute("INSERT OR IGNORE INTO free_chains (room_id,chain_id,reason) VALUES (?,?,?)", (room_id, chain_id, reason))
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def chain_budget(connection, room_id, chain_id):
|
|
495
|
+
"""AI-created requests left in a chain, or zero once it is halted."""
|
|
496
|
+
if connection.execute("SELECT 1 FROM free_chains WHERE room_id=? AND chain_id=?", (room_id, chain_id)).fetchone():
|
|
497
|
+
return 0
|
|
498
|
+
used = connection.execute("SELECT COUNT(*) FROM member_requests WHERE room_id=? AND chain_id=? AND created_by='host'",
|
|
499
|
+
(room_id, chain_id)).fetchone()[0]
|
|
500
|
+
return MAX_AUTO_HOPS - used
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def dispatch(connection, room_id, host, message_id, names, chain_id):
|
|
504
|
+
"""Queue the host's named members as one batch that hands back to the host when it ends."""
|
|
505
|
+
member_ids = named_members(active_members(connection, room_id), names, exclude=host["name"])
|
|
506
|
+
if not member_ids or not chain_id:
|
|
507
|
+
return
|
|
508
|
+
if chain_budget(connection, room_id, chain_id) < len(member_ids):
|
|
509
|
+
halt_chain(connection, room_id, chain_id, "budget")
|
|
510
|
+
return
|
|
511
|
+
for member_id in member_ids:
|
|
512
|
+
insert_request(connection, room_id, member_id, message_id, chain_id, "ask", "host", batch_id=message_id)
|
|
513
|
+
connection.execute("INSERT INTO free_batches (room_id,batch_id,chain_id,host_id) VALUES (?,?,?,?)",
|
|
514
|
+
(room_id, message_id, chain_id, host["id"]))
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def settle_batches(connection, room_id):
|
|
518
|
+
"""Hand each finished batch back to its host once; an all-failed batch halts its chain instead."""
|
|
519
|
+
for batch in connection.execute("SELECT * FROM free_batches WHERE room_id=? AND settled=0", (room_id,)).fetchall():
|
|
520
|
+
statuses = [row[0] for row in connection.execute("SELECT status FROM member_requests WHERE room_id=? AND batch_id=?",
|
|
521
|
+
(room_id, batch["batch_id"]))]
|
|
522
|
+
if any(status not in TERMINAL for status in statuses):
|
|
523
|
+
continue
|
|
524
|
+
connection.execute("UPDATE free_batches SET settled=1 WHERE room_id=? AND batch_id=?", (room_id, batch["batch_id"]))
|
|
525
|
+
host = connection.execute("SELECT * FROM room_members WHERE id=? AND status='active' AND role='host'", (batch["host_id"],)).fetchone()
|
|
526
|
+
if "completed" not in statuses:
|
|
527
|
+
halt_chain(connection, room_id, batch["chain_id"], "all_failed")
|
|
528
|
+
elif not host or len(statuses) < 2:
|
|
529
|
+
# One member's answer already reaches the user; only several answers need the host to put them together.
|
|
530
|
+
continue
|
|
531
|
+
elif chain_budget(connection, room_id, batch["chain_id"]) < 1:
|
|
532
|
+
halt_chain(connection, room_id, batch["chain_id"], "budget")
|
|
533
|
+
else:
|
|
534
|
+
insert_request(connection, room_id, host["id"], batch["batch_id"], batch["chain_id"], "handoff", "host")
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def answered_chain(connection, payload, actor):
|
|
538
|
+
"""The chain an answer continues: the newest chain among the requests it completes."""
|
|
539
|
+
if actor["access"] == "manual":
|
|
540
|
+
ids = payload.get("request_ids") or [0]
|
|
541
|
+
marks = ",".join("?" * len(ids))
|
|
542
|
+
row = connection.execute(f"SELECT MAX(chain_id) FROM member_requests WHERE id IN ({marks})", ids).fetchone()
|
|
543
|
+
else:
|
|
544
|
+
row = connection.execute("SELECT MAX(chain_id) FROM member_requests WHERE execution_id=?", (payload["execution_id"],)).fetchone()
|
|
545
|
+
return row[0] or 0
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def after_free_message(connection, room_id, message_id, actor, payload):
|
|
549
|
+
"""Create requests for the owner's message, or close what the member's answer covers."""
|
|
550
|
+
now = int(time.time())
|
|
551
|
+
if actor == "用户":
|
|
552
|
+
for member_id, purpose in recipients(connection, room_id, payload):
|
|
553
|
+
insert_request(connection, room_id, member_id, message_id, message_id, purpose, "user")
|
|
554
|
+
return
|
|
555
|
+
if actor["access"] == "online":
|
|
556
|
+
for request_id in payload.get("request_ids", []):
|
|
557
|
+
connection.execute("UPDATE member_requests SET status='completed',answer_id=? WHERE id=?", (message_id, request_id))
|
|
558
|
+
settle_batches(connection, room_id)
|
|
559
|
+
return
|
|
560
|
+
chain_id = answered_chain(connection, payload, actor)
|
|
561
|
+
if actor["access"] == "manual":
|
|
562
|
+
for request_id in payload.get("request_ids", []):
|
|
563
|
+
connection.execute("UPDATE member_requests SET status='completed',answer_id=? WHERE id=?", (message_id, request_id))
|
|
564
|
+
else:
|
|
565
|
+
execution_id = payload["execution_id"]
|
|
566
|
+
connection.execute("UPDATE member_executions SET status='completed',finished_at=?,result=? WHERE id=?",
|
|
567
|
+
(now, f"message:{message_id}", execution_id))
|
|
568
|
+
connection.execute("UPDATE member_requests SET status='completed',answer_id=? WHERE execution_id=? AND status='delivering'",
|
|
569
|
+
(message_id, execution_id))
|
|
570
|
+
connection.execute("UPDATE room_members SET read_until=MAX(read_until,?) WHERE id=?", (message_id, actor["id"]))
|
|
571
|
+
if actor["role"] == "host" and payload.get("to"):
|
|
572
|
+
dispatch(connection, room_id, actor, message_id, payload["to"], chain_id)
|
|
573
|
+
settle_batches(connection, room_id)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def discussion_host_handles(connection, room):
|
|
577
|
+
"""A running discussion already wakes its host for every user message, so a question to the host needs no request of its own."""
|
|
578
|
+
return room["phase"] not in ("paused", "concluded") and bool(connection.execute(
|
|
579
|
+
"SELECT 1 FROM discussion_runtime WHERE room_id=? AND status='running'", (room["id"],)).fetchone())
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def checked_asks(connection, room, payload):
|
|
583
|
+
"""Validate the user's direct questions to members of a discussion before the message is stored."""
|
|
584
|
+
names = payload.get("to")
|
|
585
|
+
if names is None:
|
|
586
|
+
return []
|
|
587
|
+
return named_members(active_members(connection, room["id"]), names)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def ask_members(connection, room, message_id, member_ids):
|
|
591
|
+
"""Queue one 'ask' request per named member, outside the discussion's turns, rounds and limits."""
|
|
592
|
+
host_busy = discussion_host_handles(connection, room)
|
|
593
|
+
for member_id in member_ids:
|
|
594
|
+
row = connection.execute("SELECT name FROM room_members WHERE id=?", (member_id,)).fetchone()
|
|
595
|
+
if host_busy and row["name"] == room["host"]:
|
|
596
|
+
continue
|
|
597
|
+
insert_request(connection, room["id"], member_id, message_id, message_id, "ask", "user")
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def asks_view(connection, room_id):
|
|
601
|
+
"""Direct questions of a discussion room: recent requests with their member, for status badges beside messages."""
|
|
602
|
+
names = {row["id"]: row["name"] for row in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
|
|
603
|
+
requests = [{**dict(row), "member": names.get(row["member_id"], "")} for row in connection.execute(
|
|
604
|
+
"""SELECT id,member_id,message_id,status,execution_id,answer_id,chain_id,batch_id,purpose,created_by FROM member_requests
|
|
605
|
+
WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
|
|
606
|
+
return {"requests": requests[::-1], "executions": recent_executions(connection, room_id), "execution_minutes": execution_minutes(connection, room_id)}
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def expire_executions(connection, room_id):
|
|
610
|
+
"""Fail executions whose lease lapsed; their requests stay visible as failed, never silently retried."""
|
|
611
|
+
now = int(time.time())
|
|
612
|
+
limit_seconds = execution_minutes(connection, room_id) * 60
|
|
613
|
+
stale = connection.execute(
|
|
614
|
+
"SELECT id,created_at,lease_until FROM member_executions WHERE room_id=? AND status IN ('dispatched','started') AND lease_until<=?", (room_id, now)).fetchall()
|
|
615
|
+
for row in stale:
|
|
616
|
+
reason = "已达到单次执行时限" if row["lease_until"] >= row["created_at"] + limit_seconds else "执行租约到期,接入程序未续租"
|
|
617
|
+
finish_execution(connection, row["id"], "failed", reason, "failed")
|
|
618
|
+
if stale:
|
|
619
|
+
settle_batches(connection, room_id)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def finish_execution(connection, execution_id, status, result, request_status):
|
|
623
|
+
"""End one execution and move its delivered requests in the same transaction."""
|
|
624
|
+
connection.execute("UPDATE member_executions SET status=?,finished_at=?,result=? WHERE id=? AND status IN ('dispatched','started')",
|
|
625
|
+
(status, int(time.time()), result[:500], execution_id))
|
|
626
|
+
connection.execute("UPDATE member_requests SET status=?,execution_id=CASE WHEN ?='pending' THEN NULL ELSE execution_id END WHERE execution_id=? AND status='delivering'",
|
|
627
|
+
(request_status, request_status, execution_id))
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def stop_member_work(connection, member_id, reason):
|
|
631
|
+
"""Void a member's live execution and every open request, so nothing restarts by itself."""
|
|
632
|
+
for row in connection.execute("SELECT id FROM member_executions WHERE member_id=? AND status IN ('dispatched','started')",
|
|
633
|
+
(member_id,)).fetchall():
|
|
634
|
+
finish_execution(connection, row["id"], "cancelled", reason, "cancelled")
|
|
635
|
+
connection.execute("UPDATE member_requests SET status='cancelled' WHERE member_id=? AND status='pending'", (member_id,))
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def inbox(connection, room_id, token):
|
|
639
|
+
"""List what the runner should deliver or reconcile; owner credential only."""
|
|
640
|
+
require_owner(connection, room_id, token)
|
|
641
|
+
begin_write(connection)
|
|
642
|
+
expire_executions(connection, room_id)
|
|
643
|
+
release_recommendations(connection, room_id)
|
|
644
|
+
archived = connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0]
|
|
645
|
+
members, observers = [], []
|
|
646
|
+
# Manual and online members are never delivered to; the runner only reads them so smart scheduling can route to them too.
|
|
647
|
+
for row in connection.execute(f"SELECT * FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER} ORDER BY id", (room_id,)):
|
|
648
|
+
pending = [dict(item) for item in connection.execute(
|
|
649
|
+
"SELECT id,message_id,purpose FROM member_requests WHERE member_id=? AND status='pending' ORDER BY id", (row["id"],))]
|
|
650
|
+
(members if row["access"] == "runner" else observers).append({**{key: row[key] for key in MEMBER_FIELDS}, "pending": pending})
|
|
651
|
+
executions = [{**public_execution(row), "member": row["name"]} for row in connection.execute(
|
|
652
|
+
"""SELECT member_executions.*,room_members.name FROM member_executions JOIN room_members ON room_members.id=member_id
|
|
653
|
+
WHERE member_executions.room_id=? AND member_executions.status IN ('dispatched','started') ORDER BY member_executions.id""", (room_id,))]
|
|
654
|
+
return {"room_id": room_id, "archived": bool(archived), "members": members, "observers": observers, "executions": executions,
|
|
655
|
+
"execution_minutes": execution_minutes(connection, room_id)}
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def host_route_request(connection, message_id):
|
|
659
|
+
"""The host's request for one unnamed owner message, if the message was routed."""
|
|
660
|
+
return connection.execute("SELECT * FROM member_requests WHERE message_id=? AND purpose='route'", (message_id,)).fetchone()
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def recommend_member(connection, room_id, token, payload):
|
|
664
|
+
"""Smart scheduling suggests another member for an unnamed message; the host keeps it until the user adopts."""
|
|
665
|
+
require_owner(connection, room_id, token)
|
|
666
|
+
begin_write(connection)
|
|
667
|
+
confidence = payload.get("confidence")
|
|
668
|
+
if payload.get("provider") not in SCHEDULER_PROVIDERS or type(confidence) not in (int, float) or not 0 <= confidence <= 1:
|
|
669
|
+
raise ValueError("须提供智能调度供应商 provider 与 0–1 的置信度 confidence")
|
|
670
|
+
message_id = payload.get("message_id")
|
|
671
|
+
route = host_route_request(connection, message_id) if type(message_id) is int else None
|
|
672
|
+
if not route or route["room_id"] != room_id:
|
|
673
|
+
raise LookupError("只能为交给主持人的未点名消息给出推荐")
|
|
674
|
+
target = connection.execute(f"""SELECT * FROM room_members WHERE room_id=? AND id=? AND status='active'
|
|
675
|
+
AND {REGISTERED_MEMBER} AND id<>?""", (room_id, payload.get("member_id"), route["member_id"])).fetchone()
|
|
676
|
+
if not target:
|
|
677
|
+
raise LookupError("推荐对象须为在场的其他成员")
|
|
678
|
+
now = int(time.time())
|
|
679
|
+
connection.execute("""INSERT OR IGNORE INTO member_recommendations (message_id,room_id,member_id,provider,confidence,created_at,expires_at)
|
|
680
|
+
VALUES (?,?,?,?,?,?,?)""", (message_id, room_id, target["id"], payload["provider"], float(confidence), now, now + RECOMMEND_SECONDS))
|
|
681
|
+
return dict(connection.execute("SELECT * FROM member_recommendations WHERE message_id=?", (message_id,)).fetchone())
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def release_recommendations(connection, room_id):
|
|
685
|
+
"""Ask each adopted-later member once the host's request for that message has ended in any way."""
|
|
686
|
+
for row in connection.execute("""SELECT rec.* FROM member_recommendations rec JOIN member_requests r ON r.message_id=rec.message_id AND r.purpose='route'
|
|
687
|
+
WHERE rec.room_id=? AND rec.status='waiting' AND r.status IN ('completed','cancelled','failed')""", (room_id,)).fetchall():
|
|
688
|
+
connection.execute("UPDATE member_recommendations SET status='adopted' WHERE message_id=?", (row["message_id"],))
|
|
689
|
+
if connection.execute("SELECT 1 FROM room_members WHERE id=? AND status='active'", (row["member_id"],)).fetchone():
|
|
690
|
+
connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
|
|
691
|
+
VALUES (?,?,?,?,'ask','user',?)""", (room_id, row["member_id"], row["message_id"], row["message_id"], int(time.time())))
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def route_wide(connection, room_id, message_id, action):
|
|
695
|
+
"""Smart scheduling answers an unnamed message with everyone (each member once, as if the user named them all) or no one.
|
|
696
|
+
|
|
697
|
+
Only a host request that has not started changes; a started host already handles the message its own way.
|
|
698
|
+
"""
|
|
699
|
+
route = host_route_request(connection, message_id)
|
|
700
|
+
if not route or route["room_id"] != room_id:
|
|
701
|
+
raise LookupError("只能处理交给主持人的未点名消息")
|
|
702
|
+
if route["status"] != "pending":
|
|
703
|
+
raise FileExistsError("主持人已开始处理这条消息,不再改派")
|
|
704
|
+
if action == "none":
|
|
705
|
+
connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (route["id"],))
|
|
706
|
+
else:
|
|
707
|
+
# The host keeps its row (one request per message and member) and now answers its own share, without dispatching.
|
|
708
|
+
connection.execute("UPDATE member_requests SET purpose='all' WHERE id=?", (route["id"],))
|
|
709
|
+
connection.execute(f"""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
|
|
710
|
+
SELECT room_id,id,?,?,'all','user',? FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER} ORDER BY id""",
|
|
711
|
+
(message_id, message_id, int(time.time()), room_id))
|
|
712
|
+
requests = connection.execute("SELECT * FROM member_requests WHERE message_id=? ORDER BY id", (message_id,)).fetchall()
|
|
713
|
+
return {"action": action, "requests": [dict(row) for row in requests]}
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def adopt_recommendation(connection, room_id, token, message_id, payload):
|
|
717
|
+
"""The user adopts a recommendation before it expires: interrupt the host now, or ask the member after the host.
|
|
718
|
+
|
|
719
|
+
Smart scheduling also sends action all or none here to have everyone or no one answer an unnamed message.
|
|
720
|
+
"""
|
|
721
|
+
require_owner(connection, room_id, token)
|
|
722
|
+
begin_write(connection)
|
|
723
|
+
if payload.get("action") in ("all", "none"):
|
|
724
|
+
return route_wide(connection, room_id, message_id, payload["action"])
|
|
725
|
+
if payload.get("action") != "adopt" or payload.get("mode") not in ("interrupt", "after"):
|
|
726
|
+
raise ValueError("采纳须为 action=adopt,mode 为 interrupt 或 after;全体或无需回应为 action=all 或 none")
|
|
727
|
+
row = connection.execute("SELECT * FROM member_recommendations WHERE room_id=? AND message_id=?", (room_id, message_id)).fetchone()
|
|
728
|
+
if not row:
|
|
729
|
+
raise LookupError("这条消息没有智能调度推荐")
|
|
730
|
+
if row["status"] != "open":
|
|
731
|
+
return {"recommendation": dict(row), "duplicate": True}
|
|
732
|
+
if int(time.time()) > row["expires_at"] + RECOMMEND_GRACE_SECONDS:
|
|
733
|
+
raise FileExistsError("推荐已过期,可直接 @ 该成员")
|
|
734
|
+
if not connection.execute("SELECT 1 FROM room_members WHERE id=? AND status='active'", (row["member_id"],)).fetchone():
|
|
735
|
+
raise FileExistsError("推荐的成员已离开")
|
|
736
|
+
route = host_route_request(connection, message_id)
|
|
737
|
+
mode = payload["mode"]
|
|
738
|
+
if mode == "interrupt" and route["status"] == "delivering":
|
|
739
|
+
# The host's execution may carry other messages too; stopping it voids that whole execution.
|
|
740
|
+
finish_execution(connection, route["execution_id"], "cancelled", "智能调度改派,改由其他成员回答", "cancelled")
|
|
741
|
+
elif mode == "interrupt" and route["status"] == "pending":
|
|
742
|
+
connection.execute("UPDATE member_requests SET status='cancelled' WHERE id=?", (route["id"],))
|
|
743
|
+
connection.execute("UPDATE member_recommendations SET status='waiting',mode=? WHERE message_id=?", (mode, message_id))
|
|
744
|
+
release_recommendations(connection, room_id)
|
|
745
|
+
settle_batches(connection, room_id)
|
|
746
|
+
return {"recommendation": dict(connection.execute("SELECT * FROM member_recommendations WHERE message_id=?", (message_id,)).fetchone()),
|
|
747
|
+
"duplicate": False}
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def recent_recommendations(connection, room_id, now):
|
|
751
|
+
"""Recommendations shown beside their messages, with seconds left to adopt an open one."""
|
|
752
|
+
return [{**dict(row), "expires_in": max(0, row["expires_at"] - now)} for row in connection.execute(
|
|
753
|
+
"""SELECT rec.*,m.name AS member FROM member_recommendations rec JOIN room_members m ON m.id=rec.member_id
|
|
754
|
+
WHERE rec.room_id=? ORDER BY rec.message_id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def reserve_execution(connection, room_id, token, payload):
|
|
758
|
+
"""Bundle a member's open requests into one execution; a retried client_id returns the same one."""
|
|
759
|
+
require_owner(connection, room_id, token)
|
|
760
|
+
begin_write(connection)
|
|
761
|
+
client_id = str(payload.get("client_id", ""))
|
|
762
|
+
if not 8 <= len(client_id) <= 100:
|
|
763
|
+
raise ValueError("client_id 须为 8–100 字符的本地投递编号")
|
|
764
|
+
previous = connection.execute("SELECT * FROM member_executions WHERE room_id=? AND client_id=?", (room_id, client_id)).fetchone()
|
|
765
|
+
if previous:
|
|
766
|
+
return execution_detail(connection, previous)
|
|
767
|
+
expire_executions(connection, room_id)
|
|
768
|
+
if connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0]:
|
|
769
|
+
raise FileExistsError("房间已存档,不再投递")
|
|
770
|
+
member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND id=? AND status='active' AND access='runner'",
|
|
771
|
+
(room_id, payload.get("member_id"))).fetchone()
|
|
772
|
+
if not member:
|
|
773
|
+
raise LookupError("成员不存在、已离开或为手动接入")
|
|
774
|
+
if connection.execute("SELECT 1 FROM member_executions WHERE member_id=? AND status IN ('dispatched','started')",
|
|
775
|
+
(member["id"],)).fetchone():
|
|
776
|
+
raise FileExistsError("该成员已有进行中的执行;同一会话不并发续接")
|
|
777
|
+
if connection.execute("SELECT 1 FROM discussion_events WHERE room_id=? AND participant=? AND status IN ('dispatched','started')",
|
|
778
|
+
(room_id, member["name"])).fetchone():
|
|
779
|
+
raise FileExistsError("该成员正在处理讨论安排;同一会话不并发续接")
|
|
780
|
+
if not connection.execute("SELECT 1 FROM member_requests WHERE member_id=? AND status='pending'", (member["id"],)).fetchone():
|
|
781
|
+
raise FileExistsError("该成员没有待投递的请求")
|
|
782
|
+
now = int(time.time())
|
|
783
|
+
cursor = connection.execute("""INSERT INTO member_executions (room_id,member_id,client_id,lease_until,created_at,cwd)
|
|
784
|
+
VALUES (?,?,?,?,?,?)""", (room_id, member["id"], client_id, now + LEASE_SECONDS, now, str(payload.get("cwd") or "")[:MAX_PATH_LENGTH]))
|
|
785
|
+
connection.execute("UPDATE member_requests SET status='delivering',execution_id=? WHERE member_id=? AND status='pending'",
|
|
786
|
+
(cursor.lastrowid, member["id"]))
|
|
787
|
+
return execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (cursor.lastrowid,)).fetchone())
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def execution_detail(connection, row):
|
|
791
|
+
"""Describe one execution with the requests it carries and the member's read cursor."""
|
|
792
|
+
member = connection.execute("SELECT * FROM room_members WHERE id=?", (row["member_id"],)).fetchone()
|
|
793
|
+
requests = [dict(item) for item in connection.execute(
|
|
794
|
+
"SELECT id,message_id,status,purpose FROM member_requests WHERE execution_id=? ORDER BY id", (row["id"],))]
|
|
795
|
+
return {**public_execution(row), "member": member["name"], "read_until": member["read_until"], "requests": requests}
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def view_execution(connection, room_id, execution_id, token):
|
|
799
|
+
"""Let the owner or the executing member read one execution."""
|
|
800
|
+
row = connection.execute("SELECT * FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
|
|
801
|
+
if not row:
|
|
802
|
+
raise LookupError("执行不存在")
|
|
803
|
+
try:
|
|
804
|
+
require_owner(connection, room_id, token)
|
|
805
|
+
except PermissionError:
|
|
806
|
+
member = require_member(connection, room_id, token)
|
|
807
|
+
if member["id"] != row["member_id"]:
|
|
808
|
+
raise
|
|
809
|
+
return execution_detail(connection, row)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def changed_files(payload):
|
|
813
|
+
"""Validate the runner's observed file list; paths are relative to the execution's cwd."""
|
|
814
|
+
files = payload.get("changes")
|
|
815
|
+
if (not isinstance(files, list) or len(files) > MAX_CHANGED_FILES
|
|
816
|
+
or any(not isinstance(item, str) or not item or len(item) > MAX_PATH_LENGTH for item in files)):
|
|
817
|
+
raise ValueError(f"changes 须为最多 {MAX_CHANGED_FILES} 个相对路径")
|
|
818
|
+
return sorted(set(files))
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def execution_action(connection, room_id, execution_id, token, payload):
|
|
822
|
+
"""Runner-side lifecycle: renew, fail, release undelivered work, or report observed file changes."""
|
|
823
|
+
require_owner(connection, room_id, token)
|
|
824
|
+
begin_write(connection)
|
|
825
|
+
row = connection.execute("SELECT * FROM member_executions WHERE room_id=? AND id=?", (room_id, execution_id)).fetchone()
|
|
826
|
+
if not row:
|
|
827
|
+
raise LookupError("执行不存在")
|
|
828
|
+
action = payload.get("action")
|
|
829
|
+
now = int(time.time())
|
|
830
|
+
if action == "report":
|
|
831
|
+
# Observed after the process ends, so the execution may already be complete; recorded once.
|
|
832
|
+
if row["changes"]:
|
|
833
|
+
raise FileExistsError("本次执行的改动已记录")
|
|
834
|
+
connection.execute("UPDATE member_executions SET changes=? WHERE id=?", (json.dumps(changed_files(payload), ensure_ascii=False), execution_id))
|
|
835
|
+
elif row["status"] not in ACTIVE:
|
|
836
|
+
raise FileExistsError("执行已结束")
|
|
837
|
+
elif action == "renew":
|
|
838
|
+
limit = row["created_at"] + execution_minutes(connection, room_id) * 60
|
|
839
|
+
if row["lease_until"] <= now or limit <= now:
|
|
840
|
+
raise FileExistsError("执行租约或单次时限已到,不能续租")
|
|
841
|
+
connection.execute("UPDATE member_executions SET lease_until=? WHERE id=?", (min(now + LEASE_SECONDS, limit), execution_id))
|
|
842
|
+
elif action == "fail":
|
|
843
|
+
finish_execution(connection, execution_id, "failed", str(payload.get("reason") or "执行失败"), "failed")
|
|
844
|
+
settle_batches(connection, room_id)
|
|
845
|
+
elif action == "release":
|
|
846
|
+
# Only for proven non-delivery: the requests return to the queue unchanged.
|
|
847
|
+
finish_execution(connection, execution_id, "cancelled", str(payload.get("reason") or "未送达"), "pending")
|
|
848
|
+
else:
|
|
849
|
+
raise ValueError("action 须为 renew、fail、release 或 report")
|
|
850
|
+
return execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (execution_id,)).fetchone())
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def owned_member(connection, room_id, member_id, token):
|
|
854
|
+
"""Read one member after proving the owner's credential."""
|
|
855
|
+
require_owner(connection, room_id, token)
|
|
856
|
+
begin_write(connection)
|
|
857
|
+
member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND id=?", (room_id, member_id)).fetchone()
|
|
858
|
+
if not member or member["status"] == "removed":
|
|
859
|
+
raise LookupError("成员不存在")
|
|
860
|
+
return member
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def interrupt_member(connection, room_id, member_id, token):
|
|
864
|
+
"""Owner stop: void the live execution and every open request."""
|
|
865
|
+
owned_member(connection, room_id, member_id, token)
|
|
866
|
+
stop_member_work(connection, member_id, "用户打断")
|
|
867
|
+
settle_batches(connection, room_id)
|
|
868
|
+
return free_view(connection, room_id)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def add_member(connection, room_id, token, payload, now):
|
|
872
|
+
"""Owner adds a member while the room keeps running; other members are not interrupted."""
|
|
873
|
+
require_owner(connection, room_id, token)
|
|
874
|
+
begin_write(connection)
|
|
875
|
+
member = member_value(payload)
|
|
876
|
+
existing = connection.execute("SELECT * FROM room_members WHERE room_id=? AND name=? AND status<>'removed'", (room_id, member["name"])).fetchone()
|
|
877
|
+
if existing and existing["status"] == "active" and existing["token_hash"] == token_hash(member["token"]) and all(
|
|
878
|
+
existing[key] == member[key] for key in ("adapter", "access", "session_mode")):
|
|
879
|
+
return free_view(connection, room_id)
|
|
880
|
+
if existing:
|
|
881
|
+
raise FileExistsError(f"{member['name']} 已是本房间成员;离开的成员请用请回")
|
|
882
|
+
require_fresh_member_token(connection, room_id, member["token"])
|
|
883
|
+
insert_member(connection, room_id, member, now)
|
|
884
|
+
sync_participants(connection, room_id)
|
|
885
|
+
return free_view(connection, room_id)
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def discussion_roster(connection, room_id):
|
|
889
|
+
"""Return a discussion's host, active experts and phase; away experts keep only their member row."""
|
|
890
|
+
row = connection.execute("SELECT host,experts,phase FROM topic_settings WHERE room_id=?", (room_id,)).fetchone()
|
|
891
|
+
return row["host"], json.loads(row["experts"]), row["phase"]
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def save_discussion_experts(connection, room_id, experts):
|
|
895
|
+
"""Write the active roster; additions preserve its plan, other changes ask the host to re-plan."""
|
|
896
|
+
connection.execute("UPDATE topic_settings SET experts=? WHERE room_id=?", (json.dumps(experts, ensure_ascii=False), room_id))
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def queue_invited_expert(connection, room_id, name, now):
|
|
900
|
+
"""Queue a new expert at the tail of an existing live plan, without changing the current speaker or hold.
|
|
901
|
+
|
|
902
|
+
A manual expert is queued only once it has registered (the invite alone does not), and only once per invitation.
|
|
903
|
+
"""
|
|
904
|
+
member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND name=? AND status='active'", (room_id, name)).fetchone()
|
|
905
|
+
if not member or (member["adapter"] == "manual" and not registered_at(connection, member)):
|
|
906
|
+
return
|
|
907
|
+
connection.execute("""INSERT OR IGNORE INTO discussion_turns (room_id,expert,question,status,submission_id,created_at)
|
|
908
|
+
SELECT settings.room_id,member.name,
|
|
909
|
+
CASE WHEN settings.phase='initial' THEN '欢迎加入:请先读取议题,独立给出你的开场观点。'
|
|
910
|
+
ELSE '欢迎加入:请先读取议题和已有回答,给出你的观点。' END,
|
|
911
|
+
'queued','invite:'||member.id,?
|
|
912
|
+
FROM topic_settings AS settings JOIN room_members AS member ON member.room_id=settings.room_id
|
|
913
|
+
WHERE settings.room_id=? AND member.name=? AND member.status='active'
|
|
914
|
+
AND settings.phase NOT IN ('paused','concluded')
|
|
915
|
+
AND EXISTS (SELECT 1 FROM discussion_turns WHERE room_id=settings.room_id AND status IN ('pending','notified','queued'))""",
|
|
916
|
+
(now, room_id, name))
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def add_discussion_member(connection, room_id, token, payload, now):
|
|
920
|
+
"""Owner invites one expert into a discussion using the room's delivery method."""
|
|
921
|
+
require_owner(connection, room_id, token)
|
|
922
|
+
begin_write(connection)
|
|
923
|
+
member = member_value(payload)
|
|
924
|
+
_, experts, phase = discussion_roster(connection, room_id)
|
|
925
|
+
run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
|
|
926
|
+
if not discussion_access_ok(member["access"], "cli", bool(run and run["mode"] == "automatic"), False):
|
|
927
|
+
raise ValueError("新专家的接入方式须与讨论的推进方式一致")
|
|
928
|
+
existing = connection.execute("SELECT * FROM room_members WHERE room_id=? AND name=? AND status<>'removed'", (room_id, member["name"])).fetchone()
|
|
929
|
+
if existing and existing["status"] == "active" and existing["token_hash"] == token_hash(member["token"]):
|
|
930
|
+
return
|
|
931
|
+
if existing:
|
|
932
|
+
raise FileExistsError(f"{member['name']} 已是本房间成员;离开的成员请用请回")
|
|
933
|
+
if phase == "concluded":
|
|
934
|
+
raise ValueError("讨论已收束,不能再邀请专家")
|
|
935
|
+
if len(experts) >= MAX_ROOM_AGENTS:
|
|
936
|
+
raise ValueError("房间成员已达上限")
|
|
937
|
+
require_fresh_member_token(connection, room_id, member["token"])
|
|
938
|
+
insert_member(connection, room_id, mark_manual(member, bool(run and run["mode"] == "automatic")), now)
|
|
939
|
+
save_discussion_experts(connection, room_id, {**experts, member["name"]: "cli"})
|
|
940
|
+
queue_invited_expert(connection, room_id, member["name"], now)
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def discussion_member_action(connection, room_id, member_id, token, payload):
|
|
944
|
+
"""Owner sends an expert away (keeping its seat), brings it back with a new credential, or removes it.
|
|
945
|
+
|
|
946
|
+
Leaving cancels the expert's open assignments; the host keeps its role and cannot leave, and an
|
|
947
|
+
expert with an open assignment can only leave while the discussion is paused.
|
|
948
|
+
"""
|
|
949
|
+
member = owned_member(connection, room_id, member_id, token)
|
|
950
|
+
host, experts, phase = discussion_roster(connection, room_id)
|
|
951
|
+
action = payload.get("action")
|
|
952
|
+
if action in ("away", "remove"):
|
|
953
|
+
if member["name"] == host:
|
|
954
|
+
raise ValueError("主持人不能离开或移除;请先更换主持人")
|
|
955
|
+
speaking = connection.execute("SELECT 1 FROM discussion_turns WHERE room_id=? AND expert=? AND status IN ('pending','notified')",
|
|
956
|
+
(room_id, member["name"])).fetchone()
|
|
957
|
+
if speaking and phase != "paused":
|
|
958
|
+
raise ValueError("该专家正在发言;请先暂停讨论再离开或移除")
|
|
959
|
+
connection.execute("UPDATE discussion_turns SET status='cancelled' WHERE room_id=? AND expert=? AND status IN ('pending','notified')",
|
|
960
|
+
(room_id, member["name"]))
|
|
961
|
+
connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND expert=? AND status='queued'", (room_id, member["name"]))
|
|
962
|
+
connection.execute("UPDATE room_members SET status=? WHERE id=?", ("away" if action == "away" else "removed", member_id))
|
|
963
|
+
experts.pop(member["name"], None)
|
|
964
|
+
elif action == "active":
|
|
965
|
+
hashed = token_hash(payload.get("token"))
|
|
966
|
+
if member["status"] == "active" and hashed and hmac.compare_digest(hashed, member["token_hash"]):
|
|
967
|
+
return
|
|
968
|
+
if member["status"] != "away" or not hashed or hmac.compare_digest(hashed, member["token_hash"]):
|
|
969
|
+
raise ValueError("只有离开的成员可以请回,且须提供新的成员凭证")
|
|
970
|
+
require_fresh_member_token(connection, room_id, payload.get("token"))
|
|
971
|
+
connection.execute("UPDATE discussion_turns SET status='skipped' WHERE room_id=? AND status='queued'", (room_id,))
|
|
972
|
+
connection.execute("UPDATE room_members SET status='active',token_hash=? WHERE id=?", (hashed, member_id))
|
|
973
|
+
experts[member["name"]] = "online" if member["access"] == "online" else "cli"
|
|
974
|
+
elif action == "adopt":
|
|
975
|
+
adopt_manual_member(connection, room_id, member, payload)
|
|
976
|
+
return
|
|
977
|
+
else:
|
|
978
|
+
raise ValueError("action 须为 away、active、remove 或 adopt")
|
|
979
|
+
save_discussion_experts(connection, room_id, experts)
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
ADOPTABLE_ADAPTERS = ("claude", "codex", "codex_cli", "antigravity_cli")
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def adopt_manual_member(connection, room_id, member, payload):
|
|
986
|
+
"""Hand a manual expert to the local service once its CLI reported the session it runs in; the owner's service resumes that session."""
|
|
987
|
+
reported = connection.execute("SELECT session_id FROM member_sessions WHERE member_id=?", (member["id"],)).fetchone()
|
|
988
|
+
run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
|
|
989
|
+
if not registered_at(connection, member):
|
|
990
|
+
raise ValueError("成员尚未注册,不能转为托管")
|
|
991
|
+
if not reported or reported["session_id"] != payload.get("session_id") or payload.get("adapter") not in ADOPTABLE_ADAPTERS:
|
|
992
|
+
raise ValueError("成员尚未上报与之相符的会话,不能转为托管")
|
|
993
|
+
if member["access"] != "manual" or member["status"] != "active" or not (room_mode(connection, room_id) == "free" or (run and run["mode"] == "automatic")):
|
|
994
|
+
raise ValueError("只有自由协作或全自动讨论中在场的手动专家可以转为托管")
|
|
995
|
+
connection.execute("UPDATE room_members SET access='runner',adapter=?,session_mode='borrowed' WHERE id=?", (payload["adapter"], member["id"]))
|
|
996
|
+
connection.execute("DELETE FROM member_sessions WHERE member_id=?", (member["id"],))
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def require_fresh_member_token(connection, room_id, token):
|
|
1000
|
+
"""Prevent owner or another member's credential from being reused for a new identity."""
|
|
1001
|
+
hashed = token_hash(token)
|
|
1002
|
+
if not hashed or connection.execute("SELECT 1 FROM room_members WHERE room_id=? AND token_hash=? UNION ALL SELECT 1 FROM room_modes WHERE room_id=? AND control_hash=?",
|
|
1003
|
+
(room_id, hashed, room_id, hashed)).fetchone():
|
|
1004
|
+
raise ValueError("须提供独立的新成员凭证")
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def member_action(connection, room_id, member_id, token, payload):
|
|
1008
|
+
"""Owner sends a member away, brings it back with a new credential, or removes it.
|
|
1009
|
+
|
|
1010
|
+
Leaving voids live work at once. Coming back requires a fresh credential, so copies of the
|
|
1011
|
+
old one never revive; the software session is kept by the local runner, not here. The
|
|
1012
|
+
`role` action makes one runner or manual member the AI host, replacing any previous host.
|
|
1013
|
+
"""
|
|
1014
|
+
member = owned_member(connection, room_id, member_id, token)
|
|
1015
|
+
action = payload.get("action")
|
|
1016
|
+
if action in ("away", "remove"):
|
|
1017
|
+
stop_member_work(connection, member_id, "成员已离开" if action == "away" else "成员已移除")
|
|
1018
|
+
connection.execute("UPDATE room_members SET status=? WHERE id=?", ("away" if action == "away" else "removed", member_id))
|
|
1019
|
+
elif action == "active":
|
|
1020
|
+
hashed = token_hash(payload.get("token"))
|
|
1021
|
+
if member["status"] == "active" and hashed and hmac.compare_digest(hashed, member["token_hash"]):
|
|
1022
|
+
return free_view(connection, room_id)
|
|
1023
|
+
if member["status"] != "away" or not hashed or hmac.compare_digest(hashed, member["token_hash"]):
|
|
1024
|
+
raise ValueError("只有离开的成员可以请回,且须提供新的成员凭证")
|
|
1025
|
+
require_fresh_member_token(connection, room_id, payload.get("token"))
|
|
1026
|
+
connection.execute("UPDATE room_members SET status='active',token_hash=? WHERE id=?", (hashed, member_id))
|
|
1027
|
+
elif action == "adopt":
|
|
1028
|
+
adopt_manual_member(connection, room_id, member, payload)
|
|
1029
|
+
elif action == "role":
|
|
1030
|
+
role = payload.get("role")
|
|
1031
|
+
if role not in ("host", "member") or (role == "host" and member["access"] == "online"):
|
|
1032
|
+
raise ValueError("role 须为 host 或 member;在线成员不能担任主持人")
|
|
1033
|
+
if role == "member" and member["role"] == "host":
|
|
1034
|
+
raise ValueError("自由房间须由一位 AI 主持;请指定另一位成员担任主持人")
|
|
1035
|
+
if role == "host":
|
|
1036
|
+
connection.execute("UPDATE room_members SET role='member' WHERE room_id=? AND role='host'", (room_id,))
|
|
1037
|
+
connection.execute("UPDATE room_members SET role=? WHERE id=?", (role, member_id))
|
|
1038
|
+
else:
|
|
1039
|
+
raise ValueError("action 须为 away、active、remove、role 或 adopt")
|
|
1040
|
+
settle_batches(connection, room_id)
|
|
1041
|
+
sync_participants(connection, room_id)
|
|
1042
|
+
return free_view(connection, room_id)
|