ai_co_discuss 0.2.99 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/discuss-host/SKILL.md +3 -3
- package/.agents/skills/discuss-host/references/discussion.md +16 -16
- package/.agents/skills/discuss-host/references/launch.md +1 -3
- package/.agents/skills/discuss-host/references/manual.md +14 -77
- package/.agents/skills/discuss-participant/SKILL.md +3 -3
- package/.agents/skills/discuss-participant/references/discussion.md +29 -27
- package/.agents/skills/discuss-participant/references/free.md +2 -2
- package/.agents/skills/discuss-participant/references/manual.md +8 -10
- package/.agents/skills/discuss-participant/references/writing.md +9 -9
- package/.github/SECURITY.md +1 -1
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/backend/free_room.py +360 -68
- package/backend/room_import.py +75 -15
- package/backend/runtime.py +81 -169
- package/backend/schema.sql +136 -244
- package/backend/server.py +602 -166
- package/backend/session_room.py +919 -0
- package/docs/architecture.md +3 -2
- package/docs/en/architecture.md +4 -3
- package/docs/en/runner.md +3 -1
- package/docs/en/workflow.md +9 -6
- package/docs/runner.md +3 -1
- package/docs/workflow.md +11 -8
- package/package.json +2 -2
- package/runner/api_participant.mjs +47 -44
- package/runner/discuss.py +77 -58
- package/runner/discussion_adapters.mjs +6 -3
- package/runner/discussion_launch.mjs +98 -70
- package/runner/discussion_runner.mjs +38 -496
- package/runner/discussion_service.mjs +21 -74
- package/runner/discussion_sidecar.mjs +4 -13
- package/runner/expert_prompts.mjs +29 -21
- package/runner/free_runner.mjs +84 -19
- package/runner/session_driver.mjs +175 -0
- package/web/app.js +193 -128
- package/web/backup.js +1 -1
- package/web/modern.css +1 -1
- package/web/modern.js +5482 -6646
package/backend/room_import.py
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
"""Import one room backup as a new, owner-controlled room on this site.
|
|
2
2
|
|
|
3
|
-
The room keeps its ID, so importing the same backup twice is skipped. Message, turn, member
|
|
4
|
-
request IDs are renumbered after this database's current maximum
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
and its members away, so the owner invites them back with new credentials.
|
|
3
|
+
The room keeps its ID, so importing the same backup twice is skipped. Message, turn, member,
|
|
4
|
+
request, session and session-request IDs are renumbered after this database's current maximum.
|
|
5
|
+
Imported discussions are historical, concluded sessions; active work and credentials are never
|
|
6
|
+
restored.
|
|
8
7
|
"""
|
|
9
8
|
|
|
10
9
|
import hashlib
|
|
@@ -23,18 +22,24 @@ ACTIVE_PHASES = ("initial", "discussing", "reviewing")
|
|
|
23
22
|
OPEN_REQUESTS = ("pending", "delivering")
|
|
24
23
|
LIMIT_FIELDS = ("max_rounds", "max_minutes", "execution_minutes", "on_limit", "summary_minutes",
|
|
25
24
|
"started_at", "closing_at", "stop_reason", "revision")
|
|
25
|
+
VERSION_2_TABLES = ("discussion_sessions", "discussion_conclusions", "session_requests", "task_message_sources",
|
|
26
|
+
"session_state", "session_limits", "session_round_budgets", "session_options", "session_pauses")
|
|
26
27
|
# Tables whose integer IDs are renumbered, with the backup table that holds their rows.
|
|
27
28
|
RENUMBERED = {"messages": "messages", "discussion_turns": "discussion_turns",
|
|
28
|
-
"room_members": "room_members", "member_requests": "member_requests"
|
|
29
|
+
"room_members": "room_members", "member_requests": "member_requests",
|
|
30
|
+
"discussion_sessions": "discussion_sessions", "session_requests": "session_requests"}
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
def backup_tables(backup):
|
|
32
34
|
"""Validate the backup envelope and return its tables and the single room row."""
|
|
33
|
-
if not isinstance(backup, dict) or backup.get("format") != BACKUP_FORMAT
|
|
34
|
-
|
|
35
|
+
if (not isinstance(backup, dict) or backup.get("format") != BACKUP_FORMAT
|
|
36
|
+
or backup.get("version") not in (1, 2)):
|
|
37
|
+
raise ValueError("不是可导入的房间备份(需要 ai-codiscuss-room-backup 第 1 或第 2 版)")
|
|
35
38
|
tables = backup.get("tables")
|
|
36
39
|
if not isinstance(tables, dict) or not isinstance(tables.get("rooms"), list) or len(tables["rooms"]) != 1:
|
|
37
40
|
raise ValueError("备份须恰好包含一个房间")
|
|
41
|
+
if backup["version"] == 2 and any(name not in tables for name in VERSION_2_TABLES):
|
|
42
|
+
raise ValueError("第 2 版备份缺少讨论会话历史表,拒绝静默丢弃新字段")
|
|
38
43
|
for name, rows in tables.items():
|
|
39
44
|
if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
|
|
40
45
|
raise ValueError(f"备份表 {name} 格式无效")
|
|
@@ -102,8 +107,7 @@ def history_statements(tables, room_id, maps):
|
|
|
102
107
|
for old, row in details.items():
|
|
103
108
|
statements += [("INSERT INTO message_details (message_id,kind,submitted_by) VALUES (?,'import',?)", (messages[old], row["submitted_by"])),
|
|
104
109
|
("UPDATE message_details SET kind=?,turn_id=? WHERE message_id=?", (row["kind"], turns.get(row.get("turn_id")), messages[old]))]
|
|
105
|
-
#
|
|
106
|
-
statements.append(("INSERT INTO turn_relay (room_id,held_since) VALUES (?,1) ON CONFLICT(room_id) DO UPDATE SET held_since=1", (room_id,)))
|
|
110
|
+
# Keep active rows queued until the other active row is restored, preserving the legacy unique index.
|
|
107
111
|
order = {"notified": 1, "pending": 2}
|
|
108
112
|
for row in sorted(tables.get("discussion_turns", []), key=lambda turn: order.get(turn["status"], 0)):
|
|
109
113
|
if row["status"] != "queued":
|
|
@@ -161,8 +165,9 @@ def record_statements(tables, room_id, maps):
|
|
|
161
165
|
statements += [("""INSERT INTO member_requests (id,room_id,member_id,message_id,status,answer_id,created_at,chain_id,batch_id,purpose,created_by)
|
|
162
166
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)""", (requests[row["id"]], room_id, members[row["member_id"]], messages[row["message_id"]],
|
|
163
167
|
"cancelled" if row["status"] in OPEN_REQUESTS else row["status"],
|
|
164
|
-
messages.get(row.get("answer_id")), row["created_at"],
|
|
165
|
-
row.get("
|
|
168
|
+
messages.get(row.get("answer_id")), row["created_at"],
|
|
169
|
+
messages.get(row.get("chain_id"), 0), messages.get(row.get("batch_id"), 0),
|
|
170
|
+
row.get("purpose", "ask"), row.get("created_by", "user")))
|
|
166
171
|
for row in tables.get("member_requests", [])]
|
|
167
172
|
statements += [("INSERT INTO member_suggestions (message_id,member_id) VALUES (?,?)", (messages[row["message_id"]], members[row["member_id"]]))
|
|
168
173
|
for row in tables.get("member_suggestions", [])]
|
|
@@ -176,14 +181,69 @@ def record_statements(tables, room_id, maps):
|
|
|
176
181
|
return statements
|
|
177
182
|
|
|
178
183
|
|
|
184
|
+
def session_statements(tables, room_id, maps):
|
|
185
|
+
"""Restore session history and its provenance without restoring active work."""
|
|
186
|
+
messages = maps["messages"]
|
|
187
|
+
sessions = maps["discussion_sessions"]
|
|
188
|
+
requests = maps["session_requests"]
|
|
189
|
+
members = maps["room_members"]
|
|
190
|
+
member_requests = maps["member_requests"]
|
|
191
|
+
task_messages = {row["message_id"] for row in tables.get("task_message_sources", [])}
|
|
192
|
+
latest_visible_message = max((new for old, new in messages.items() if old not in task_messages), default=0)
|
|
193
|
+
statements = []
|
|
194
|
+
for row in tables.get("discussion_sessions", []):
|
|
195
|
+
active = row["status"] in ("active", "paused")
|
|
196
|
+
statements.append(("""INSERT INTO discussion_sessions
|
|
197
|
+
(id,room_id,goal,status,started_message_id,ended_message_id,created_at)
|
|
198
|
+
VALUES (?,?,?,?,?,?,?)""", (sessions[row["id"]], room_id, remap_text(row["goal"], messages),
|
|
199
|
+
"concluded" if active else row["status"],
|
|
200
|
+
remap_until(row.get("started_message_id"), messages),
|
|
201
|
+
latest_visible_message if active else remap_until(row.get("ended_message_id"), messages),
|
|
202
|
+
row["created_at"])))
|
|
203
|
+
statements += [("""INSERT INTO discussion_conclusions
|
|
204
|
+
(session_id,room_id,stop_reason,max_rounds,max_minutes,brief,state,state_until,concluded_at)
|
|
205
|
+
VALUES (?,?,?,?,?,?,?,?,?)""", (sessions[row["session_id"]], room_id, row["stop_reason"],
|
|
206
|
+
row["max_rounds"], row["max_minutes"], remap_text(row["brief"], messages),
|
|
207
|
+
remap_text(row["state"], messages), remap_until(row["state_until"], messages),
|
|
208
|
+
row["concluded_at"])) for row in tables.get("discussion_conclusions", [])]
|
|
209
|
+
for name, fields in (("session_state", ("phase", "revision")),
|
|
210
|
+
("session_limits", ("max_rounds", "max_minutes", "execution_minutes", "started_at", "stop_reason", "revision")),
|
|
211
|
+
("session_round_budgets", ("base", "cap", "extra")),
|
|
212
|
+
("session_pauses", ("paused_at", "paused_seconds")),
|
|
213
|
+
("session_options", ("named_followup",))):
|
|
214
|
+
columns = ("session_id", *fields)
|
|
215
|
+
statements += [(f"INSERT INTO {name} ({','.join(columns)}) VALUES ({','.join('?' for _ in columns)})",
|
|
216
|
+
(sessions[row["session_id"]], *(row[field] for field in fields)))
|
|
217
|
+
for row in tables.get(name, [])]
|
|
218
|
+
for row in tables.get("session_state", []):
|
|
219
|
+
statements.append(("UPDATE session_state SET read_until=?,held_since=? WHERE session_id=?",
|
|
220
|
+
(remap_until(row["read_until"], messages), remap_until(row["held_since"], messages),
|
|
221
|
+
sessions[row["session_id"]])))
|
|
222
|
+
statements += [("""INSERT INTO session_requests
|
|
223
|
+
(id,session_id,round,position,member_id,purpose,question,status,member_request_id,submission_id,read_until,created_at)
|
|
224
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", (requests[row["id"]], sessions[row["session_id"]], row["round"], row["position"],
|
|
225
|
+
members[row["member_id"]], row["purpose"], remap_text(row["question"], messages),
|
|
226
|
+
"cancelled" if row["status"] in ("queued", "open") else row["status"],
|
|
227
|
+
member_requests.get(row.get("member_request_id")), row["submission_id"],
|
|
228
|
+
None if row.get("read_until") is None else remap_until(row["read_until"], messages),
|
|
229
|
+
row["created_at"])) for row in tables.get("session_requests", [])]
|
|
230
|
+
statements += [("""INSERT INTO task_message_sources
|
|
231
|
+
(message_id,session_id,session_request_id,cause_kind,cause_message_id,cause_request_id)
|
|
232
|
+
VALUES (?,?,?,?,?,?)""", (messages[row["message_id"]], sessions[row["session_id"]],
|
|
233
|
+
requests[row["session_request_id"]], row["cause_kind"],
|
|
234
|
+
messages.get(row.get("cause_message_id")),
|
|
235
|
+
member_requests.get(row.get("cause_request_id"))))
|
|
236
|
+
for row in tables.get("task_message_sources", [])]
|
|
237
|
+
return statements
|
|
238
|
+
|
|
239
|
+
|
|
179
240
|
def import_statements(tables, room, maps, control_hash, now):
|
|
180
241
|
"""Build the ordered writes that recreate one backup; the D1 adapter builds the same sequence."""
|
|
181
|
-
mode = next((row["mode"] for row in tables.get("room_modes", [])), "brainstorm")
|
|
182
242
|
return [*room_statements(tables, room, maps, control_hash), *member_statements(tables, room["id"], maps),
|
|
183
243
|
*history_statements(tables, room["id"], maps), *settings_statements(tables, room, maps, now),
|
|
184
244
|
*record_statements(tables, room["id"], maps),
|
|
185
|
-
(
|
|
186
|
-
|
|
245
|
+
*session_statements(tables, room["id"], maps),
|
|
246
|
+
]
|
|
187
247
|
|
|
188
248
|
|
|
189
249
|
def import_room(connection, backup, control_token, now):
|
package/backend/runtime.py
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
"""Opt-in discussion execution; no app launching or model calls live here."""
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
|
-
import json
|
|
5
4
|
import secrets
|
|
6
5
|
import time
|
|
7
|
-
import
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from fractions import Fraction
|
|
8
8
|
|
|
9
9
|
LEASE_SECONDS = 120
|
|
10
10
|
# Never leaves this process; a client cannot guess it to pose as a stored hash.
|
|
11
11
|
OWNER_PROXY_PREFIX = secrets.token_hex(16) + ":"
|
|
12
|
-
MAX_RUN_SECONDS = 86400
|
|
13
12
|
LIMIT_DEFAULTS = {"max_rounds": 0, "max_minutes": 0, "execution_minutes": 0}
|
|
14
13
|
# Older clients may still send these; reaching a limit always pauses, so only the pause value is accepted and the reserve is ignored.
|
|
15
14
|
LEGACY_LIMIT_FIELDS = ("on_limit", "summary_minutes")
|
|
@@ -132,10 +131,68 @@ def require_within_limits(room):
|
|
|
132
131
|
raise FileExistsError("已达到讨论上限;请检查上限,讨论随之结束并回到自由协作")
|
|
133
132
|
|
|
134
133
|
|
|
135
|
-
def
|
|
136
|
-
"""Read
|
|
137
|
-
|
|
138
|
-
|
|
134
|
+
def session_limits_view(connection, room_id):
|
|
135
|
+
"""Read request-model session progress, excluding paused time and ordinary collaboration replies."""
|
|
136
|
+
from .session_room import current_session
|
|
137
|
+
|
|
138
|
+
session = current_session(connection, room_id)
|
|
139
|
+
if not session:
|
|
140
|
+
return None
|
|
141
|
+
result = dict(connection.execute("SELECT * FROM session_limits WHERE session_id=?", (session["id"],)).fetchone())
|
|
142
|
+
pause = connection.execute("SELECT * FROM session_pauses WHERE session_id=?", (session["id"],)).fetchone()
|
|
143
|
+
budget = dict(connection.execute("SELECT base,cap,extra FROM session_round_budgets WHERE session_id=?", (session["id"],)).fetchone())
|
|
144
|
+
answered = connection.execute("""SELECT COUNT(*) FROM session_requests
|
|
145
|
+
WHERE session_id=? AND purpose='discuss' AND status='answered'""", (session["id"],)).fetchone()[0]
|
|
146
|
+
now = int(time.time())
|
|
147
|
+
if session["status"] == "concluded":
|
|
148
|
+
finished = connection.execute("SELECT CAST(strftime('%s',concluded_at) AS INTEGER) FROM discussion_conclusions WHERE session_id=?",
|
|
149
|
+
(session["id"],)).fetchone()
|
|
150
|
+
now = finished[0] if finished and finished[0] else now
|
|
151
|
+
idle = pause["paused_seconds"] + (max(0, now - pause["paused_at"]) if pause["paused_at"] else 0)
|
|
152
|
+
deadline = result["started_at"] + result["max_minutes"] * 60 + idle if result["started_at"] and result["max_minutes"] else 0
|
|
153
|
+
completed = _session_answer_rounds(connection, session["id"])
|
|
154
|
+
segment_rounds = _session_answer_rounds(connection, session["id"], budget["base"])
|
|
155
|
+
round_cap = budget["cap"] or result["max_rounds"]
|
|
156
|
+
reason = result["stop_reason"] if result["stop_reason"] in ("time_limit", "round_limit") else ""
|
|
157
|
+
if not reason and deadline and now >= deadline:
|
|
158
|
+
reason = "time_limit"
|
|
159
|
+
elif not reason and round_cap and segment_rounds >= round_cap:
|
|
160
|
+
reason = "round_limit"
|
|
161
|
+
result.update(deadline=deadline, reached=bool(reason), reason=reason, completed_rounds=completed,
|
|
162
|
+
segment_rounds=segment_rounds, round_cap=round_cap, extra_rounds=budget["extra"],
|
|
163
|
+
remaining_seconds=max(0, deadline - now) if deadline else None,
|
|
164
|
+
elapsed_seconds=max(0, now - result["started_at"] - idle), answered=answered)
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _session_answer_rounds(connection, session_id, base=0):
|
|
169
|
+
"""Count answer volume across rounds, adding the host only to rounds where the host is scheduled."""
|
|
170
|
+
from .session_room import _expert_denominator
|
|
171
|
+
|
|
172
|
+
rounds = Counter(row[0] for row in connection.execute("""SELECT round FROM session_requests
|
|
173
|
+
WHERE session_id=? AND purpose='discuss' AND status='answered' ORDER BY id LIMIT -1 OFFSET ?""",
|
|
174
|
+
(session_id, base)))
|
|
175
|
+
progress = Fraction(0)
|
|
176
|
+
for round_number, answered in rounds.items():
|
|
177
|
+
denominator = _expert_denominator(connection, session_id, round_number)
|
|
178
|
+
if denominator:
|
|
179
|
+
progress += Fraction(answered, denominator)
|
|
180
|
+
return int(progress)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def enforce_session_limits(connection, room_id):
|
|
184
|
+
"""End an active request-model session at its configured limit under the caller's write transaction."""
|
|
185
|
+
from .session_room import _conclude_session, current_session
|
|
186
|
+
|
|
187
|
+
begin_write(connection)
|
|
188
|
+
session = current_session(connection, room_id, active_only=True)
|
|
189
|
+
if not session or session["status"] != "active":
|
|
190
|
+
return False
|
|
191
|
+
limits = session_limits_view(connection, room_id)
|
|
192
|
+
if not limits["reached"]:
|
|
193
|
+
return False
|
|
194
|
+
_conclude_session(connection, session, limits["reason"])
|
|
195
|
+
return True
|
|
139
196
|
|
|
140
197
|
|
|
141
198
|
def runtime_view(connection, room_id):
|
|
@@ -154,6 +211,12 @@ def runtime_view(connection, room_id):
|
|
|
154
211
|
return result
|
|
155
212
|
|
|
156
213
|
|
|
214
|
+
def has_session_request_model(connection, room_id):
|
|
215
|
+
"""Return whether this room has request-model session history, including concluded sessions."""
|
|
216
|
+
return bool(connection.execute("""SELECT 1 FROM discussion_sessions AS s
|
|
217
|
+
JOIN session_state AS state ON state.session_id=s.id WHERE s.room_id=? LIMIT 1""", (room_id,)).fetchone())
|
|
218
|
+
|
|
219
|
+
|
|
157
220
|
def failed_executions(connection, room_id):
|
|
158
221
|
"""Keep failed attempts readable after a retry, without exposing execution credentials."""
|
|
159
222
|
return [dict(row) for row in connection.execute("""SELECT e.id,e.participant,e.role,e.turn_id,
|
|
@@ -236,7 +299,7 @@ def authorize_participant(connection, room_id, identity, operation, payload):
|
|
|
236
299
|
def authorize_write(connection, room_id, token, operation, payload):
|
|
237
300
|
"""Check a capability under the same writer lock as the following mutation."""
|
|
238
301
|
begin_write(connection)
|
|
239
|
-
run = connection.execute("SELECT
|
|
302
|
+
run = connection.execute("SELECT mode FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
|
|
240
303
|
hashed = token_hash(token)
|
|
241
304
|
owner = connection.execute("SELECT 1 FROM room_modes WHERE room_id=? AND control_hash=?", (room_id, hashed)).fetchone() if hashed else None
|
|
242
305
|
if owner:
|
|
@@ -247,100 +310,7 @@ def authorize_write(connection, room_id, token, operation, payload):
|
|
|
247
310
|
if member and (not run or run["mode"] == "manual" or member["access"] == "manual"):
|
|
248
311
|
authorize_participant(connection, room_id, member, operation, payload)
|
|
249
312
|
return
|
|
250
|
-
|
|
251
|
-
raise PermissionError("需要房主或本成员的有效凭据;没有凭据只能阅读")
|
|
252
|
-
actor = write_actor(operation, payload)
|
|
253
|
-
work_seconds = execution_seconds(connection, room_id)
|
|
254
|
-
event = connection.execute("""SELECT * FROM discussion_events WHERE room_id=? AND token_hash=?
|
|
255
|
-
AND run_id=? AND generation=? AND status='started' AND lease_until>?
|
|
256
|
-
AND (?=0 OR started_at+?>?)""", (room_id, hashed, run["run_id"], run["generation"],
|
|
257
|
-
int(time.time()), work_seconds, work_seconds, int(time.time()))).fetchone()
|
|
258
|
-
if not hashed or not event or run["status"] != "running" or 0 < run["deadline"] <= int(time.time()):
|
|
259
|
-
raise PermissionError("执行资格无效、已结束或已到期;请重新读取运行状态")
|
|
260
|
-
if actor != event["participant"]:
|
|
261
|
-
raise PermissionError("执行凭据与实际参与身份不符")
|
|
262
|
-
authorize_participant(connection, room_id, {"name": event["participant"], "role": event["role"]}, operation, payload)
|
|
263
|
-
if operation == "messages":
|
|
264
|
-
if payload.get("author") != actor:
|
|
265
|
-
raise PermissionError("自动执行不能代发其他参与者")
|
|
266
|
-
if event["role"] == "expert" and payload.get("turn_id") not in (None, event["turn_id"]):
|
|
267
|
-
raise PermissionError("回答必须关联本次安排")
|
|
268
|
-
if event["role"] == "expert" and payload.get("turn_id") is None:
|
|
269
|
-
# Without a named arrangement the answer belongs to the expert's open one, which must be this execution's.
|
|
270
|
-
open_turn = connection.execute("SELECT id FROM discussion_turns WHERE room_id=? AND expert=? AND status IN ('pending','notified')",
|
|
271
|
-
(room_id, actor)).fetchone()
|
|
272
|
-
if not open_turn or open_turn["id"] != event["turn_id"]:
|
|
273
|
-
raise PermissionError("回答必须关联本次安排")
|
|
274
|
-
elif operation == "attachments":
|
|
275
|
-
pass
|
|
276
|
-
elif event["role"] != "host" or operation not in ("topic", "state", "turns", "limits"):
|
|
277
|
-
raise PermissionError("本次执行不能进行此操作")
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
def configure_runtime(connection, room_id, token, payload):
|
|
281
|
-
"""Apply one versioned, explicit mode or lifecycle action."""
|
|
282
|
-
begin_write(connection)
|
|
283
|
-
require_owner(connection, room_id, token)
|
|
284
|
-
current = runtime_view(connection, room_id)
|
|
285
|
-
if payload.get("revision") != current["revision"]:
|
|
286
|
-
raise FileExistsError("运行状态已变化,请重新读取 revision")
|
|
287
|
-
hashed = token_hash(token)
|
|
288
|
-
if not current["protected"] and not hashed:
|
|
289
|
-
raise ValueError("运行凭据须为 32–256 字符的随机值")
|
|
290
|
-
action = payload.get("action", "configure")
|
|
291
|
-
mode = payload.get("mode", "automatic" if action == "start" else current["mode"])
|
|
292
|
-
if mode not in ("manual", "automatic") or action not in ("configure", "start", "pause", "stop"):
|
|
293
|
-
raise ValueError("请选择 manual/automatic 和 configure/start/pause/stop")
|
|
294
|
-
if action == "start" and mode != "automatic":
|
|
295
|
-
raise ValueError("start 只用于全自动运行;手动方式无需运行启动")
|
|
296
|
-
if action in ("configure", "start") and current["status"] == "running":
|
|
297
|
-
raise FileExistsError("讨论仍在运行;请先暂停,再切换模式或继续")
|
|
298
|
-
if action in ("configure", "start") and connection.execute(
|
|
299
|
-
"SELECT 1 FROM discussion_events WHERE room_id=? AND status IN ('dispatched','started')", (room_id,)).fetchone():
|
|
300
|
-
raise FileExistsError("存在在途执行;先暂停并核对原会话已停止,再切换或继续")
|
|
301
|
-
status = {"configure": "ready", "start": "running", "pause": "paused", "stop": "stopped"}[action]
|
|
302
|
-
start_payload = {"phase": current.get("resume_phase", "initial"), **payload}
|
|
303
|
-
deadline = validate_start(connection, room_id, start_payload) if action == "start" else current.get("deadline", 0)
|
|
304
|
-
connection.execute("""INSERT INTO discussion_runtime
|
|
305
|
-
(room_id,mode,run_id,status,control_hash,deadline,updated_at) VALUES (?,?,?,?,?,?,datetime('now'))
|
|
306
|
-
ON CONFLICT(room_id) DO UPDATE SET mode=excluded.mode,status=excluded.status,
|
|
307
|
-
generation=generation+1,revision=revision+1,deadline=excluded.deadline,updated_at=excluded.updated_at""",
|
|
308
|
-
(room_id, mode, str(uuid.uuid4()), "ready", hashed, deadline))
|
|
309
|
-
if action in ("pause", "stop"):
|
|
310
|
-
connection.execute("UPDATE topic_settings SET phase='paused' WHERE room_id=? AND phase<>'concluded'", (room_id,))
|
|
311
|
-
connection.execute("UPDATE discussion_runtime SET status=? WHERE room_id=?", (status, room_id))
|
|
312
|
-
# Only a real mode change converts members; a manual expert of a woken discussion (adapter 'manual') stays manual.
|
|
313
|
-
if mode != current["mode"]:
|
|
314
|
-
connection.execute("UPDATE room_members SET access='manual' WHERE room_id=? AND access='runner'" if mode == "manual" else
|
|
315
|
-
"UPDATE room_members SET access='runner' WHERE room_id=? AND access='manual' AND adapter<>'manual'", (room_id,))
|
|
316
|
-
return runtime_view(connection, room_id)
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
def validate_start(connection, room_id, payload):
|
|
320
|
-
"""Validate an AI-hosted run; zero deadline leaves only the configured limits."""
|
|
321
|
-
row = connection.execute("SELECT * FROM topic_settings JOIN room_directory USING(room_id) WHERE room_id=?",
|
|
322
|
-
(room_id,)).fetchone()
|
|
323
|
-
limits = connection.execute(LIMITS_SQL, (room_id,)).fetchone()
|
|
324
|
-
configured = ((limits["started_at"] or int(time.time())) + limits["max_minutes"] * 60 + limits["idle_seconds"]
|
|
325
|
-
if limits["max_minutes"] else 0)
|
|
326
|
-
deadline = payload.get("deadline", configured)
|
|
327
|
-
if type(deadline) is int and configured:
|
|
328
|
-
deadline = min(deadline or configured, configured)
|
|
329
|
-
if type(deadline) is not int or (deadline != 0 and not int(time.time()) < deadline <= int(time.time()) + MAX_RUN_SECONDS):
|
|
330
|
-
raise ValueError("deadline 须为 0(不限时长)或未来 24 小时内的 Unix 秒数")
|
|
331
|
-
experts = json.loads(row["experts"])
|
|
332
|
-
if row["archived"] or row["phase"] == "concluded" or row["host"] == "用户":
|
|
333
|
-
raise ValueError("自动运行需要未归档、未收束的话题及 AI 主持人")
|
|
334
|
-
if not experts or any(mode != "cli" for mode in experts.values()):
|
|
335
|
-
raise ValueError("自动运行的参与者须均具有本地 CLI 读写能力")
|
|
336
|
-
phase = payload.get("phase", "initial" if row["phase"] == "preparing" else "discussing")
|
|
337
|
-
if phase not in ("initial", "discussing"):
|
|
338
|
-
raise ValueError("恢复阶段必须为 initial/discussing")
|
|
339
|
-
if row["phase"] in ("preparing", "paused"):
|
|
340
|
-
connection.execute("UPDATE topic_settings SET phase=? WHERE room_id=?", (phase, room_id))
|
|
341
|
-
connection.execute("UPDATE discussion_limits SET started_at=? WHERE room_id=? AND started_at=0",
|
|
342
|
-
(int(time.time()), room_id))
|
|
343
|
-
return deadline
|
|
313
|
+
raise PermissionError("需要房主或本成员的有效凭据;旧讨论执行凭据已停用")
|
|
344
314
|
|
|
345
315
|
|
|
346
316
|
def list_events(connection, room_id, token, after=0):
|
|
@@ -352,75 +322,17 @@ def list_events(connection, room_id, token, after=0):
|
|
|
352
322
|
return {"events": events, "next_after": events[-1]["id"] if events else int(after)}
|
|
353
323
|
|
|
354
324
|
|
|
355
|
-
def
|
|
356
|
-
"""
|
|
357
|
-
|
|
325
|
+
def event_view(connection, room_id, event_id, token, payload):
|
|
326
|
+
"""Read one legacy event without allowing it to resume the retired runtime."""
|
|
327
|
+
if payload.get("action") != "view":
|
|
328
|
+
raise ValueError("旧讨论执行仅保留历史读取")
|
|
358
329
|
event = connection.execute("SELECT * FROM discussion_events WHERE room_id=? AND id=?",
|
|
359
330
|
(room_id, event_id)).fetchone()
|
|
360
331
|
if not event:
|
|
361
332
|
raise LookupError("执行不存在")
|
|
362
|
-
run = connection.execute("SELECT
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
work_deadline = (now if action == "enter" else event["started_at"]) + work_seconds if work_seconds else 0
|
|
367
|
-
lease_until = min(now + LEASE_SECONDS, work_deadline or now + LEASE_SECONDS, run["deadline"] or now + LEASE_SECONDS)
|
|
368
|
-
controller = token_hash(token) == run["control_hash"]
|
|
369
|
-
if not controller and (not token_hash(token) or token_hash(token) != event["token_hash"]):
|
|
333
|
+
run = connection.execute("SELECT control_hash FROM discussion_runtime WHERE room_id=?", (room_id,)).fetchone()
|
|
334
|
+
hashed = token_hash(token)
|
|
335
|
+
controller = bool(run and hashed and hashed == run["control_hash"])
|
|
336
|
+
if not controller and (not hashed or hashed != event["token_hash"]):
|
|
370
337
|
raise PermissionError("执行凭据无效")
|
|
371
|
-
|
|
372
|
-
return {key: event[key] for key in PUBLIC_EVENT_FIELDS}
|
|
373
|
-
if action == "segments":
|
|
374
|
-
# A timeline report comes from the runner, also just after the execution ended or its run stopped.
|
|
375
|
-
if not controller:
|
|
376
|
-
raise PermissionError("需要本房间的运行控制凭据")
|
|
377
|
-
timing = connection.execute("SELECT dispatched_at,finished_at FROM event_timings WHERE event_id=?", (event_id,)).fetchone()
|
|
378
|
-
if not timing or not timing["dispatched_at"]:
|
|
379
|
-
raise FileExistsError("执行尚未交付")
|
|
380
|
-
finished = 0 if event["status"] in ("dispatched", "started") else (timing["finished_at"] or 1)
|
|
381
|
-
save_segments(connection, "discussion", room_id, event_id, timing["dispatched_at"], finished, payload)
|
|
382
|
-
return {key: event[key] for key in PUBLIC_EVENT_FIELDS}
|
|
383
|
-
if run["status"] != "running" or 0 < run["deadline"] <= now or event["generation"] != run["generation"]:
|
|
384
|
-
raise FileExistsError("本次运行已停止或执行已过期")
|
|
385
|
-
if action == "reserve" and controller:
|
|
386
|
-
reserve_event(connection, event, payload)
|
|
387
|
-
elif action == "enter" and not controller:
|
|
388
|
-
if event["status"] != "dispatched" or not payload.get("session_id"):
|
|
389
|
-
raise FileExistsError("本次任务已进入或不可进入;不要重复执行")
|
|
390
|
-
connection.execute("UPDATE discussion_events SET status='started',session_id=?,started_at=?,lease_until=? WHERE id=?",
|
|
391
|
-
(str(payload["session_id"])[:200], now, lease_until, event_id))
|
|
392
|
-
elif action == "renew" and controller and event["status"] == "started":
|
|
393
|
-
if event["lease_until"] <= now or 0 < work_deadline <= now:
|
|
394
|
-
raise FileExistsError("执行租约已到期,不能自动重新激活")
|
|
395
|
-
connection.execute("UPDATE discussion_events SET lease_until=? WHERE id=?",
|
|
396
|
-
(lease_until, event_id))
|
|
397
|
-
elif action == "fail" and event["status"] in ("dispatched", "started"):
|
|
398
|
-
connection.execute("UPDATE discussion_events SET status='failed',result=? WHERE id=?",
|
|
399
|
-
(str(payload.get("reason", "需要用户处理"))[:500], event_id))
|
|
400
|
-
connection.execute("UPDATE topic_settings SET phase='paused' WHERE room_id=?", (room_id,))
|
|
401
|
-
else:
|
|
402
|
-
raise FileExistsError("执行状态不允许此操作")
|
|
403
|
-
result = connection.execute("SELECT * FROM discussion_events WHERE id=?", (event_id,)).fetchone()
|
|
404
|
-
return {key: result[key] for key in PUBLIC_EVENT_FIELDS}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
def reserve_event(connection, event, payload):
|
|
408
|
-
"""Reserve only current work and merge pending host wakeups before dispatch."""
|
|
409
|
-
hashed = token_hash(payload.get("execution_token"))
|
|
410
|
-
if event["status"] != "pending" or not hashed:
|
|
411
|
-
raise FileExistsError("执行已被领取或缺少有效的执行凭据")
|
|
412
|
-
turn = connection.execute("SELECT * FROM discussion_turns WHERE room_id=? AND status IN ('pending','notified')",
|
|
413
|
-
(event["room_id"],)).fetchone()
|
|
414
|
-
if (event["role"] == "host" and turn) or (event["role"] == "expert" and (not turn or turn["id"] != event["turn_id"])):
|
|
415
|
-
raise FileExistsError("当前安排与执行不符,请重新读取")
|
|
416
|
-
if connection.execute("SELECT 1 FROM discussion_events WHERE room_id=? AND status IN ('dispatched','started')",
|
|
417
|
-
(event["room_id"],)).fetchone():
|
|
418
|
-
raise FileExistsError("已有在途执行")
|
|
419
|
-
if connection.execute("""SELECT 1 FROM member_executions JOIN room_members ON room_members.id=member_executions.member_id
|
|
420
|
-
WHERE member_executions.room_id=? AND room_members.name=? AND member_executions.status IN ('dispatched','started')""",
|
|
421
|
-
(event["room_id"], event["participant"])).fetchone():
|
|
422
|
-
raise FileExistsError("该参与者正在回答用户的直接提问;同一会话不并发续接")
|
|
423
|
-
connection.execute("UPDATE discussion_events SET status='dispatched',token_hash=? WHERE id=?", (hashed, event["id"]))
|
|
424
|
-
if event["role"] == "host":
|
|
425
|
-
connection.execute("UPDATE discussion_events SET status='completed',result=? WHERE room_id=? AND role='host' AND status='pending' AND id<?",
|
|
426
|
-
(f"merged:{event['id']}", event["room_id"], event["id"]))
|
|
338
|
+
return {key: event[key] for key in PUBLIC_EVENT_FIELDS}
|