ai_co_discuss 0.3.7 → 0.3.9
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/backend/free_room.py +78 -20
- package/backend/schema.sql +8 -0
- package/backend/server.py +28 -5
- package/package.json +1 -1
- package/runner/discuss.py +4 -0
- package/runner/discussion_service.mjs +15 -5
- package/runner/free_runner.mjs +2 -1
- package/web/app.js +148 -173
- package/web/modern.css +1 -1
- package/web/modern.js +4389 -4587
package/backend/free_room.py
CHANGED
|
@@ -462,6 +462,18 @@ REGISTERED_MEMBER = """(room_members.access<>'manual' OR EXISTS(SELECT 1 FROM me
|
|
|
462
462
|
WHERE source.member_id=room_members.id AND source.source='web') OR EXISTS(SELECT 1 FROM member_registrations r
|
|
463
463
|
WHERE r.member_id=room_members.id AND r.token_hash=room_members.token_hash))"""
|
|
464
464
|
|
|
465
|
+
# One snapshot for group routing; the Worker uses the same SQL inside its atomic write assertion.
|
|
466
|
+
GROUP_STATE_SQL = f"""SELECT json_object(
|
|
467
|
+
'latest_id',(SELECT COALESCE(MAX(id),0) FROM messages WHERE room_id=rooms.id),
|
|
468
|
+
'title',rooms.title,'goal',rooms.goal,
|
|
469
|
+
'archived',(SELECT archived FROM room_directory WHERE room_id=rooms.id),
|
|
470
|
+
'order',COALESCE((SELECT member_ids FROM speaking_orders WHERE room_id=rooms.id),'[]'),
|
|
471
|
+
'members',(SELECT json_group_array(json_object('id',id,'name',name,'role',role,'status',status,'access',access,
|
|
472
|
+
'registered',{REGISTERED_MEMBER},'description',COALESCE((SELECT description FROM member_descriptions d
|
|
473
|
+
WHERE d.room_id=room_members.room_id AND d.name=room_members.name),'')))
|
|
474
|
+
FROM (SELECT * FROM room_members WHERE room_id=rooms.id ORDER BY id) AS room_members)) AS state
|
|
475
|
+
FROM rooms WHERE id=?"""
|
|
476
|
+
|
|
465
477
|
|
|
466
478
|
def free_view(connection, room_id):
|
|
467
479
|
"""Summarize members, their live execution, recent requests and possible file conflicts."""
|
|
@@ -1258,7 +1270,8 @@ def inbox(connection, room_id, token):
|
|
|
1258
1270
|
for row in connection.execute(f"SELECT * FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER} ORDER BY id", (room_id,)):
|
|
1259
1271
|
pending = [dict(item) for item in connection.execute("""SELECT r.id,r.message_id,r.purpose,
|
|
1260
1272
|
sr.id AS session_request_id,sr.session_id,sr.round,sr.position,sr.read_until,sr.question AS source_question,
|
|
1261
|
-
t.cause_kind,t.cause_message_id AS source_message_id,t.cause_request_id
|
|
1273
|
+
t.cause_kind,t.cause_message_id AS source_message_id,t.cause_request_id,
|
|
1274
|
+
(SELECT action FROM group_routes WHERE message_id=r.message_id) AS group_action
|
|
1262
1275
|
FROM member_requests r LEFT JOIN session_requests sr ON sr.member_request_id=r.id
|
|
1263
1276
|
LEFT JOIN task_message_sources t ON t.session_request_id=sr.id
|
|
1264
1277
|
WHERE r.member_id=? AND r.status='pending' ORDER BY r.id""", (row["id"],))]
|
|
@@ -1309,29 +1322,74 @@ def release_recommendations(connection, room_id):
|
|
|
1309
1322
|
VALUES (?,?,?,?,'ask','user',?)""", (room_id, row["member_id"], row["message_id"], row["message_id"], int(time.time())))
|
|
1310
1323
|
|
|
1311
1324
|
|
|
1312
|
-
def
|
|
1325
|
+
def group_route_plan(connection, room_id, token, message_id):
|
|
1326
|
+
"""Read the current group, eligible members, order and revision before arranging everyone's requests."""
|
|
1327
|
+
require_owner(connection, room_id, token)
|
|
1328
|
+
group = connection.execute("""SELECT id,message_id,status FROM member_requests
|
|
1329
|
+
WHERE room_id=? AND message_id=? AND purpose='group' ORDER BY id DESC LIMIT 1""", (room_id, message_id)).fetchone()
|
|
1330
|
+
previous = connection.execute("SELECT action,request_id,message_id FROM group_routes WHERE room_id=? AND message_id=?",
|
|
1331
|
+
(room_id, message_id)).fetchone()
|
|
1332
|
+
if not group and not previous:
|
|
1333
|
+
raise LookupError("这条消息没有全体待安排请求")
|
|
1334
|
+
state = connection.execute(GROUP_STATE_SQL, (room_id,)).fetchone()["state"]
|
|
1335
|
+
snapshot = json.loads(state)
|
|
1336
|
+
members = [member for member in snapshot["members"] if member["status"] == "active" and member["registered"]]
|
|
1337
|
+
rank = {member_id: index for index, member_id in enumerate(json.loads(snapshot["order"]))}
|
|
1338
|
+
members.sort(key=lambda member: (member["id"] not in rank, rank.get(member["id"], 0), member["role"] != "host", member["id"]))
|
|
1339
|
+
return {"message_id": message_id, "request": dict(group) if group else None, "members": members,
|
|
1340
|
+
"latest_id": snapshot["latest_id"], "revision": hashlib.sha256(state.encode("utf-8")).hexdigest(),
|
|
1341
|
+
"result": dict(previous) if previous else None}
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def route_group(connection, room_id, group, payload):
|
|
1345
|
+
"""Arrange a pending group atomically; only the single-object handback to its host needs no roster revision."""
|
|
1346
|
+
action = payload["action"]
|
|
1347
|
+
if action not in ("all", "sequential", "host"):
|
|
1348
|
+
raise ValueError("请全体成员回应的消息只能选 action=all、sequential 或 host")
|
|
1349
|
+
if group["status"] != "pending":
|
|
1350
|
+
raise FileExistsError("这条消息已不再等待安排")
|
|
1351
|
+
if action != "host":
|
|
1352
|
+
if not payload.get("revision"):
|
|
1353
|
+
raise ValueError("安排全体前须先读取 message group-plan,并带上其 revision")
|
|
1354
|
+
state = connection.execute(GROUP_STATE_SQL, (room_id,)).fetchone()["state"]
|
|
1355
|
+
if payload["revision"] != hashlib.sha256(state.encode("utf-8")).hexdigest():
|
|
1356
|
+
raise FileExistsError("成员、顺序或消息已变化,请重新读取全体安排状态")
|
|
1357
|
+
if action == "host":
|
|
1358
|
+
connection.execute("UPDATE member_requests SET purpose='route' WHERE id=?", (group["id"],))
|
|
1359
|
+
elif action == "sequential":
|
|
1360
|
+
connection.execute("DELETE FROM member_requests WHERE id=?", (group["id"],))
|
|
1361
|
+
members = connection.execute(f"SELECT id FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER}", (room_id,))
|
|
1362
|
+
queue_in_turn(connection, room_id, group["message_id"], [row["id"] for row in members])
|
|
1363
|
+
else:
|
|
1364
|
+
connection.execute("UPDATE member_requests SET purpose='all' WHERE id=?", (group["id"],))
|
|
1365
|
+
connection.execute(f"""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,purpose,created_by,created_at)
|
|
1366
|
+
SELECT room_id,id,?,?,'all','user',? FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER} ORDER BY id""",
|
|
1367
|
+
(group["message_id"], group["chain_id"], int(time.time()), room_id))
|
|
1368
|
+
connection.execute("INSERT INTO group_routes (message_id,room_id,request_id,action) VALUES (?,?,?,?)",
|
|
1369
|
+
(group["message_id"], room_id, group["id"], action))
|
|
1370
|
+
return {"action": action, "request_id": group["id"], "message_id": group["message_id"]}
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
def route_wide(connection, room_id, message_id, payload):
|
|
1313
1374
|
"""Smart scheduling answers an unnamed message with everyone (each member once, as if the user named them all) or no one.
|
|
1314
1375
|
|
|
1315
1376
|
A message naming everyone (a group request) is answered by all at once or, with action sequential, one after another.
|
|
1316
1377
|
Only a host request that has not started changes; a started host already handles the message its own way.
|
|
1317
1378
|
"""
|
|
1379
|
+
action = payload["action"]
|
|
1380
|
+
previous = connection.execute("SELECT action,request_id,message_id FROM group_routes WHERE room_id=? AND message_id=?",
|
|
1381
|
+
(room_id, message_id)).fetchone()
|
|
1382
|
+
if previous:
|
|
1383
|
+
if previous["action"] != action:
|
|
1384
|
+
raise FileExistsError("这条全体消息已经安排,不能改用其他动作")
|
|
1385
|
+
return dict(previous)
|
|
1386
|
+
group = connection.execute("""SELECT * FROM member_requests WHERE room_id=? AND message_id=? AND purpose='group'
|
|
1387
|
+
ORDER BY id DESC LIMIT 1""", (room_id, message_id)).fetchone()
|
|
1388
|
+
if group:
|
|
1389
|
+
return route_group(connection, room_id, group, payload)
|
|
1390
|
+
if action in ("sequential", "host"):
|
|
1391
|
+
raise ValueError("只有请全体成员回应的消息可以依次回答或交回主持人")
|
|
1318
1392
|
route = host_route_request(connection, message_id)
|
|
1319
|
-
group = connection.execute("SELECT * FROM member_requests WHERE message_id=? AND purpose='group'", (message_id,)).fetchone()
|
|
1320
|
-
if group and group["room_id"] == room_id:
|
|
1321
|
-
if action not in ("all", "sequential"):
|
|
1322
|
-
raise ValueError("请全体成员回应的消息只能选 action=all 或 sequential")
|
|
1323
|
-
if group["status"] != "pending":
|
|
1324
|
-
raise FileExistsError("这条消息已不再等待安排")
|
|
1325
|
-
if action == "sequential":
|
|
1326
|
-
# The row is removed rather than ended so the host takes its place in the queue like everyone else.
|
|
1327
|
-
connection.execute("DELETE FROM member_requests WHERE id=?", (group["id"],))
|
|
1328
|
-
members = connection.execute(f"SELECT id FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER}", (room_id,))
|
|
1329
|
-
queue_in_turn(connection, room_id, message_id, [row["id"] for row in members])
|
|
1330
|
-
requests = connection.execute("SELECT * FROM member_requests WHERE message_id=? ORDER BY id", (message_id,)).fetchall()
|
|
1331
|
-
return {"action": action, "requests": [dict(row) for row in requests]}
|
|
1332
|
-
route = group
|
|
1333
|
-
elif action == "sequential":
|
|
1334
|
-
raise ValueError("只有请全体成员回应的消息可以依次回答")
|
|
1335
1393
|
if not route or route["room_id"] != room_id:
|
|
1336
1394
|
raise LookupError("只能处理交给主持人的未点名消息")
|
|
1337
1395
|
if route["status"] != "pending":
|
|
@@ -1356,8 +1414,8 @@ def adopt_recommendation(connection, room_id, token, message_id, payload):
|
|
|
1356
1414
|
"""
|
|
1357
1415
|
require_owner(connection, room_id, token)
|
|
1358
1416
|
begin_write(connection)
|
|
1359
|
-
if payload.get("action") in ("all", "none", "sequential"):
|
|
1360
|
-
return route_wide(connection, room_id, message_id, payload
|
|
1417
|
+
if payload.get("action") in ("all", "none", "sequential", "host"):
|
|
1418
|
+
return route_wide(connection, room_id, message_id, payload)
|
|
1361
1419
|
if payload.get("action") != "adopt" or payload.get("mode") not in ("interrupt", "after"):
|
|
1362
1420
|
raise ValueError("采纳须为 action=adopt,mode 为 interrupt 或 after;全体、依次或无需回应为 action=all、sequential 或 none")
|
|
1363
1421
|
row = connection.execute("SELECT * FROM member_recommendations WHERE room_id=? AND message_id=?", (room_id, message_id)).fetchone()
|
package/backend/schema.sql
CHANGED
|
@@ -434,6 +434,14 @@ CREATE TABLE IF NOT EXISTS member_requests (
|
|
|
434
434
|
UNIQUE(message_id,member_id)
|
|
435
435
|
);
|
|
436
436
|
CREATE INDEX IF NOT EXISTS member_requests_open ON member_requests(member_id,status);
|
|
437
|
+
-- One durable disposition per @everyone message; also prevents a host fallback from being routed again after restart.
|
|
438
|
+
-- The original group request may be deleted for a sequential queue, so request_id is a receipt, not a foreign key.
|
|
439
|
+
CREATE TABLE IF NOT EXISTS group_routes (
|
|
440
|
+
message_id INTEGER PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
|
|
441
|
+
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
|
442
|
+
request_id INTEGER NOT NULL,
|
|
443
|
+
action TEXT NOT NULL CHECK(action IN ('all','sequential','host'))
|
|
444
|
+
);
|
|
437
445
|
-- Discussion options chosen at creation. A room without a row predates the option and keeps named follow-ups.
|
|
438
446
|
CREATE TABLE IF NOT EXISTS discussion_options (
|
|
439
447
|
room_id TEXT PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
|
package/backend/server.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""AI CoDiscuss 的零依赖 HTTP / SQLite 概念验证服务。"""
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
import collections
|
|
4
5
|
import copy
|
|
5
6
|
import hashlib
|
|
6
7
|
import hmac
|
|
@@ -30,7 +31,7 @@ from .free_room import (add_member, after_free_message, authorize_free_write, ch
|
|
|
30
31
|
from .free_room import add_discussion_member, convert_suggestion, discussion_member_action, message_suggestions, save_suggestions, sync_participants
|
|
31
32
|
from .free_room import active_members, adopt_recommendation, ask_members, asks_view, checked_asks, recommend_member, register_member, route_held_messages, require_member
|
|
32
33
|
from .free_room import describe_members, execution_notes, label_members, member_descriptions, member_labels, reorder_speakers, set_chain_rounds, speaking_order, stop_room_work
|
|
33
|
-
from .free_room import continue_chain, execution_timeline, retract_message, rewind_plan, rewind_room, withdrawals
|
|
34
|
+
from .free_room import continue_chain, execution_timeline, group_route_plan, retract_message, rewind_plan, rewind_room, withdrawals
|
|
34
35
|
from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
|
|
35
36
|
from .room_import import import_room, imported_attachment
|
|
36
37
|
from .runtime import (authorize_write, begin_write, event_view,
|
|
@@ -46,6 +47,8 @@ ROOT = Path(__file__).resolve().parent.parent
|
|
|
46
47
|
WEB_ROOT = ROOT / "web"
|
|
47
48
|
DEFAULT_PORT = 8765
|
|
48
49
|
UPDATE_HEARTBEAT_SECONDS = 15
|
|
50
|
+
# Changes remembered for streams that fall behind; one further back is reported as a change to the whole site.
|
|
51
|
+
UPDATE_LOG_SIZE = 256
|
|
49
52
|
MAX_BODY_BYTES = 512 * 1024
|
|
50
53
|
MAX_IMPORT_BYTES = 32 * 1024 * 1024
|
|
51
54
|
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
|
@@ -1985,10 +1988,17 @@ def protocol_text(room):
|
|
|
1985
1988
|
return "\n\n".join(paragraphs).replace("HOST", room["host"]).replace("BASE", base)
|
|
1986
1989
|
|
|
1987
1990
|
|
|
1991
|
+
def update_frame(rooms):
|
|
1992
|
+
"""Encode a change event; `rooms` lists the changed rooms, and without it every room may have changed."""
|
|
1993
|
+
return f"event: change\ndata: {json.dumps({'rooms': rooms} if rooms else {})}\n\n".encode("utf-8")
|
|
1994
|
+
|
|
1995
|
+
|
|
1988
1996
|
class DiscussionServer(ThreadingHTTPServer):
|
|
1989
1997
|
"""HTTP server carrying the configured SQLite path."""
|
|
1990
1998
|
|
|
1991
1999
|
daemon_threads = True
|
|
2000
|
+
# Every open page refreshes at once and the runner reads in bursts; Windows refuses connections beyond the backlog.
|
|
2001
|
+
request_queue_size = 128
|
|
1992
2002
|
allow_reuse_address = os.name != "nt"
|
|
1993
2003
|
allow_reuse_port = False
|
|
1994
2004
|
|
|
@@ -1996,6 +2006,8 @@ class DiscussionServer(ThreadingHTTPServer):
|
|
|
1996
2006
|
self.database = Path(database)
|
|
1997
2007
|
self.updates = threading.Condition()
|
|
1998
2008
|
self.update_version = 0
|
|
2009
|
+
# Recent (version, room id or None) pairs, so a stream names the rooms that changed; None means the whole site.
|
|
2010
|
+
self.update_log = collections.deque(maxlen=UPDATE_LOG_SIZE)
|
|
1999
2011
|
self.updates_closed = False
|
|
2000
2012
|
if local_launch and address[0] != "127.0.0.1":
|
|
2001
2013
|
raise ValueError("本机启动入口须绑定 127.0.0.1")
|
|
@@ -2012,12 +2024,20 @@ class DiscussionServer(ThreadingHTTPServer):
|
|
|
2012
2024
|
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
|
2013
2025
|
super().server_bind()
|
|
2014
2026
|
|
|
2015
|
-
def notify_update(self):
|
|
2016
|
-
"""Wake
|
|
2027
|
+
def notify_update(self, room_id=None):
|
|
2028
|
+
"""Wake subscribers after a committed business write to one room, or to the site when no room is given."""
|
|
2017
2029
|
with self.updates:
|
|
2018
2030
|
self.update_version += 1
|
|
2031
|
+
self.update_log.append((self.update_version, room_id))
|
|
2019
2032
|
self.updates.notify_all()
|
|
2020
2033
|
|
|
2034
|
+
def changed_rooms(self, version):
|
|
2035
|
+
"""Return the rooms changed after a version, or None when the site changed or the log no longer reaches it."""
|
|
2036
|
+
entries = [room for number, room in self.update_log if number > version]
|
|
2037
|
+
if len(entries) < self.update_version - version or None in entries:
|
|
2038
|
+
return None
|
|
2039
|
+
return sorted(set(entries))
|
|
2040
|
+
|
|
2021
2041
|
def server_close(self):
|
|
2022
2042
|
"""Release streaming clients when the HTTP server stops."""
|
|
2023
2043
|
with self.updates:
|
|
@@ -2041,7 +2061,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
2041
2061
|
quiet_renewal = (parts[3:4] == ["events"] and action == "renew") or (parts[3:4] in (["events"], ["executions"]) and action == "segments")
|
|
2042
2062
|
if (self.command == "POST" and 200 <= status < 300 and parts[:2] in (["api", "rooms"], ["api", "folders"])
|
|
2043
2063
|
and not quiet_renewal):
|
|
2044
|
-
self.server.notify_update()
|
|
2064
|
+
self.server.notify_update(parts[2] if parts[:2] == ["api", "rooms"] and len(parts) > 3 else None)
|
|
2045
2065
|
if content_type == "application/json":
|
|
2046
2066
|
body = json.dumps(body, ensure_ascii=False)
|
|
2047
2067
|
data = body.encode("utf-8") if isinstance(body, str) else body
|
|
@@ -2143,8 +2163,9 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
2143
2163
|
if self.server.updates_closed:
|
|
2144
2164
|
return
|
|
2145
2165
|
changed = self.server.update_version != version
|
|
2166
|
+
rooms = self.server.changed_rooms(version) if changed else None
|
|
2146
2167
|
version = self.server.update_version
|
|
2147
|
-
self.wfile.write(
|
|
2168
|
+
self.wfile.write(update_frame(rooms) if changed else b": keepalive\n\n")
|
|
2148
2169
|
self.wfile.flush()
|
|
2149
2170
|
except OSError:
|
|
2150
2171
|
pass
|
|
@@ -2689,6 +2710,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
2689
2710
|
result = reserve_execution(connection, room_id, token, self.read_payload())
|
|
2690
2711
|
elif parts[3:] == ["recommendations"] and method == "POST":
|
|
2691
2712
|
result = recommend_member(connection, room_id, token, self.read_payload())
|
|
2713
|
+
elif len(parts) == 5 and parts[3] == "recommendations" and method == "GET":
|
|
2714
|
+
result = group_route_plan(connection, room_id, token, int(parts[4]))
|
|
2692
2715
|
elif len(parts) == 5 and parts[3] == "recommendations" and method == "POST":
|
|
2693
2716
|
result = adopt_recommendation(connection, room_id, token, int(parts[4]), self.read_payload())
|
|
2694
2717
|
elif len(parts) == 6 and parts[3] == "executions" and parts[5] == "notes" and method == "GET":
|
package/package.json
CHANGED
package/runner/discuss.py
CHANGED
|
@@ -227,6 +227,8 @@ def message_commands(subcommands, registry):
|
|
|
227
227
|
view = add_leaf(subcommands, registry, "message view", "读取一条精确原文与来源", True)
|
|
228
228
|
view.add_argument("id", type=positive_int, help="消息编号")
|
|
229
229
|
view.add_argument("--request", type=positive_int, help="手动成员:按自己的有效请求限制可读原文")
|
|
230
|
+
group = add_leaf(subcommands, registry, "message group-plan", "房主读取全体请求、成员、顺序与 revision;安排全体前先读,提交时携带该 revision", True)
|
|
231
|
+
group.add_argument("id", type=positive_int, help="全体请求的原消息编号")
|
|
230
232
|
post = add_leaf(subcommands, registry, "message post", "提交观点、主持说明、协作回答或代发", True)
|
|
231
233
|
add_text(post, "body")
|
|
232
234
|
post.add_argument("--attachment", action="append", type=attachment_id, default=[],
|
|
@@ -488,6 +490,8 @@ def plan_room(args):
|
|
|
488
490
|
def plan_message(args):
|
|
489
491
|
"""Preserve operator, original author, message role, and replay identifier."""
|
|
490
492
|
base = f"/api/rooms/{args.room}/messages"
|
|
493
|
+
if args.action == "group-plan":
|
|
494
|
+
return RequestPlan(f"/api/rooms/{args.room}/recommendations/{args.id}")
|
|
491
495
|
if args.action == "view":
|
|
492
496
|
return RequestPlan(f"{base}/{args.id}", params=read_context_params(args))
|
|
493
497
|
if args.action == "list":
|
|
@@ -388,12 +388,14 @@ export class DiscussionService {
|
|
|
388
388
|
}
|
|
389
389
|
await Promise.all(checks);
|
|
390
390
|
}
|
|
391
|
-
/** Check
|
|
392
|
-
wake(site) {
|
|
393
|
-
for (const [path, worker] of this.workers)
|
|
391
|
+
/** Check a site's rooms now, as its website reported a change; `rooms` limits it to the rooms that changed. */
|
|
392
|
+
wake(site, rooms = null) {
|
|
393
|
+
for (const [path, worker] of this.workers) {
|
|
394
|
+
if (worker.state.site === site && (!rooms || rooms.includes(worker.state.room))) this.start(path, worker);
|
|
395
|
+
}
|
|
394
396
|
}
|
|
395
397
|
/**
|
|
396
|
-
* Follow a site's `/api/updates` stream and wake
|
|
398
|
+
* Follow a site's `/api/updates` stream and wake the rooms each change names, so a message reaches the AI within
|
|
397
399
|
* milliseconds instead of the next poll. A site without push (204) is left to polling; a dropped stream reconnects.
|
|
398
400
|
*/
|
|
399
401
|
watch(site) {
|
|
@@ -411,7 +413,8 @@ export class DiscussionService {
|
|
|
411
413
|
buffer += decoder.decode(chunk, { stream: true }).replaceAll('\r\n', '\n');
|
|
412
414
|
for (let end = buffer.indexOf('\n\n'); end >= 0; end = buffer.indexOf('\n\n')) {
|
|
413
415
|
// `ready` comes on every (re)connection, so changes missed while disconnected are picked up too.
|
|
414
|
-
|
|
416
|
+
const frame = buffer.slice(0, end);
|
|
417
|
+
if (/^event: (change|ready)$/m.test(frame)) this.wake(site, changed_rooms(frame));
|
|
415
418
|
buffer = buffer.slice(end + 2);
|
|
416
419
|
}
|
|
417
420
|
}
|
|
@@ -451,6 +454,13 @@ export class DiscussionService {
|
|
|
451
454
|
}
|
|
452
455
|
}
|
|
453
456
|
|
|
457
|
+
/** The rooms an update event names, or null when it names none and any room may have changed. */
|
|
458
|
+
function changed_rooms(frame) {
|
|
459
|
+
const data = frame.match(/^data: (.*)$/m)?.[1];
|
|
460
|
+
try { const rooms = JSON.parse(data).rooms; return Array.isArray(rooms) && rooms.length ? rooms : null; }
|
|
461
|
+
catch { return null; }
|
|
462
|
+
}
|
|
463
|
+
|
|
454
464
|
/** Delete one room's local files: its answer drafts, and its launch folder when the service created it under launches/. */
|
|
455
465
|
export function remove_room_files(home, config_path, data_directory) {
|
|
456
466
|
const folder = dirname(resolve(config_path));
|
package/runner/free_runner.mjs
CHANGED
|
@@ -383,7 +383,8 @@ export class FreeRoomRunner {
|
|
|
383
383
|
if (!adapter) return this.fail(member.name, execution.id, `未配置 ${member.name} 的本机接入`);
|
|
384
384
|
if (this.context_errors.has(member.name)) return this.fail(member.name, execution.id, this.context_errors.get(member.name));
|
|
385
385
|
const directory = adapter.adapter === 'api' ? undefined : adapter.cwd || this.config.cwd;
|
|
386
|
-
|
|
386
|
+
// A read-only member's software cannot write the project, so files changing meanwhile were written by someone else.
|
|
387
|
+
const before = adapter.project_access === 'read' ? null : workspace_snapshot(directory);
|
|
387
388
|
if (before) this.snapshots.set(execution.id, { member: member.name, directory, before });
|
|
388
389
|
const binding = this.store.binding(member.name);
|
|
389
390
|
// A member whose session a rewind restarted is bound to '' and must not fall back to the session it was invited with.
|