opencode-agora 0.1.0__py3-none-any.whl

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.
Files changed (41) hide show
  1. agora_mcp.py +240 -0
  2. agorad/__init__.py +1 -0
  3. agorad/access/__init__.py +1 -0
  4. agorad/access/anchors.py +22 -0
  5. agorad/access/gitx.py +206 -0
  6. agorad/access/oc.py +124 -0
  7. agorad/access/pages.py +25 -0
  8. agorad/access/store.py +181 -0
  9. agorad/core/__init__.py +1 -0
  10. agorad/core/app.py +137 -0
  11. agorad/core/auth.py +134 -0
  12. agorad/core/gate.py +281 -0
  13. agorad/core/health.py +182 -0
  14. agorad/core/msg.py +92 -0
  15. agorad/core/peer.py +90 -0
  16. agorad/core/recover.py +83 -0
  17. agorad/core/report.py +189 -0
  18. agorad/core/state.py +183 -0
  19. agorad/core/task.py +308 -0
  20. agorad/installer.py +44 -0
  21. agorad/main.py +181 -0
  22. agorad/transport/__init__.py +1 -0
  23. agorad/transport/httpd.py +316 -0
  24. board_tui.py +649 -0
  25. opencode_agora-0.1.0.data/data/share/opencode-agora/agora +244 -0
  26. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/cred.ts +112 -0
  27. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/envPath.ts +33 -0
  28. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/hook.ts +132 -0
  29. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/index.ts +160 -0
  30. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/inject.ts +49 -0
  31. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/lift.ts +161 -0
  32. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/scope.ts +36 -0
  33. opencode_agora-0.1.0.data/data/share/opencode-agora/install.sh +222 -0
  34. opencode_agora-0.1.0.data/data/share/opencode-agora/skills/agora-dispatch/SKILL.md +95 -0
  35. opencode_agora-0.1.0.data/data/share/opencode-agora/skills/agora-ops/SKILL.md +60 -0
  36. opencode_agora-0.1.0.dist-info/METADATA +195 -0
  37. opencode_agora-0.1.0.dist-info/RECORD +41 -0
  38. opencode_agora-0.1.0.dist-info/WHEEL +5 -0
  39. opencode_agora-0.1.0.dist-info/entry_points.txt +5 -0
  40. opencode_agora-0.1.0.dist-info/licenses/LICENSE +21 -0
  41. opencode_agora-0.1.0.dist-info/top_level.txt +3 -0
agorad/access/store.py ADDED
@@ -0,0 +1,181 @@
1
+ """账本唯一磁盘接触点:append-only 账本四原语+flock 单实例。
2
+
3
+ 布局 $AGORA_HOME:state/tasks.jsonl(全量快照行)、state/messages/<id>.md
4
+ (不可变,头部 key: value 行+空行+正文)、events.jsonl(审计)、
5
+ 易失件(service.json 等,可覆写非账本)。
6
+ """
7
+ import fcntl
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+
12
+ TAIL_BYTES = 1 << 20 # 启动尾读窗口:load 耗时与账本大小解耦;窗内无完整行回退全扫
13
+ COMPACT_LINES = 500 # 瘦身阈值:行数超限即原子重写仅留末完整行(flock 单写者、迁移路径时机避开启动),账本有上界
14
+
15
+
16
+ class Store:
17
+ def __init__(self, home: Path):
18
+ self.home = Path(home)
19
+ self._tasks = self.home / "state" / "tasks.jsonl"
20
+ self._events = self.home / "events.jsonl"
21
+ self._messages = self.home / "state" / "messages"
22
+ self._lock_path = self.home / "agorad.lock"
23
+ self._lock_fd = None
24
+ # 单写者内存计数器;重启扫目录文件名 max+1 恢复
25
+ self._next_mid = self.scan_max_msg_id() + 1
26
+ self._task_lines = None # 懒播种的账本行数(flock 单写者安全):免每次追加全文件重扫;崩溃漂移只挪瘦身时机
27
+
28
+ def append_state(self, snap: dict) -> None:
29
+ if self._task_lines is None: # 播种在追加前:计磁盘实有行,此后内存自增不重扫
30
+ self._task_lines = self._count_lines() if self._tasks.exists() else 0
31
+ line = json.dumps(snap, ensure_ascii=False)
32
+ self._append_line(self._tasks, line) # 先追加后瘦身:崩溃至多留下超限账本,状态不丢
33
+ self._task_lines += 1
34
+ if self._task_lines > COMPACT_LINES:
35
+ try:
36
+ self._compact(line)
37
+ self._task_lines = 1
38
+ except OSError:
39
+ pass # 追加已 fsync 持久化,瘦身属维护动作:失败不回滚成功语义,计数器留超阈待下次追加重试
40
+
41
+ def load_state(self) -> dict | None:
42
+ if not self._tasks.exists():
43
+ return None
44
+ if self._tasks.stat().st_size > TAIL_BYTES:
45
+ snap = self._load_tail()
46
+ if snap is not None:
47
+ return snap
48
+ # 尾窗无完整行(巨行/断尾贯穿全窗)→ 回退全文件扫,末行胜出语义不变
49
+ snap = None
50
+ with self._tasks.open(encoding="utf-8") as f:
51
+ for line in f:
52
+ try:
53
+ snap = json.loads(line) # 断尾行丢弃,取最后完整行
54
+ except ValueError:
55
+ pass
56
+ return snap
57
+
58
+ def _load_tail(self) -> dict | None:
59
+ with self._tasks.open("rb") as f:
60
+ f.seek(-TAIL_BYTES, os.SEEK_END)
61
+ block = f.read()
62
+ i = block.find(b"\n") # 跨窗切割的首段残行丢弃,只解析完整行
63
+ if i == -1:
64
+ return None
65
+ snap = None
66
+ for raw in block[i + 1:].split(b"\n"):
67
+ try:
68
+ snap = json.loads(raw) # 断尾行丢弃,取最后完整行(同全扫口径)
69
+ except ValueError:
70
+ pass
71
+ return snap
72
+
73
+ def _count_lines(self) -> int:
74
+ n = 0
75
+ with self._tasks.open("rb") as f:
76
+ for chunk in iter(lambda: f.read(TAIL_BYTES), b""):
77
+ n += chunk.count(b"\n")
78
+ return n
79
+
80
+ def _compact(self, line: str) -> None:
81
+ # 刚追加行即末完整行(flock 单写者);原子重写同 write_ephemeral 口径
82
+ tmp = self._tasks.with_name(self._tasks.name + ".tmp")
83
+ with tmp.open("w", encoding="utf-8") as f:
84
+ f.write(line + "\n")
85
+ f.flush()
86
+ os.fsync(f.fileno())
87
+ os.replace(tmp, self._tasks)
88
+
89
+ def append_event(self, ev: dict) -> None:
90
+ self._append_line(self._events, json.dumps(ev, ensure_ascii=False))
91
+
92
+ def write_message(self, fm: dict, body: str) -> int:
93
+ mid = self._next_mid
94
+ self._next_mid += 1
95
+ header = f"id: {mid}\n" + "".join(
96
+ f"{k}: {fm[k]}\n" for k in sorted(fm)
97
+ )
98
+ path = self._messages / f"{mid:08d}.md" # 补零保证文件名字典序=msg-id 数值序
99
+ path.parent.mkdir(parents=True, exist_ok=True)
100
+ with path.open("x", encoding="utf-8") as f: # "x"=已存在即拒,id 不可变
101
+ f.write(f"{header}\n{body}")
102
+ f.flush()
103
+ os.fsync(f.fileno())
104
+ return mid
105
+
106
+ def scan_max_msg_id(self) -> int:
107
+ if not self._messages.exists():
108
+ return 0
109
+ return max((int(p.stem) for p in self._messages.glob("*.md")), default=0)
110
+
111
+ def read_messages(self) -> list[dict]:
112
+ """全库消息按 msg-id 升序(补零文件名序=id 序);frontmatter 键+id(int)+body。
113
+
114
+ 门中崩溃撕裂件(坏头/断尾/空文件)跳过——同 load_state 断尾容错口径。
115
+ """
116
+ out = []
117
+ for p in sorted(self._messages.glob("*.md")):
118
+ try:
119
+ head, _, body = p.read_text(encoding="utf-8").partition("\n\n")
120
+ fm = dict(line.split(": ", 1) for line in head.splitlines())
121
+ fm["id"] = int(fm["id"])
122
+ fm["body"] = body
123
+ except (ValueError, KeyError, OSError):
124
+ continue
125
+ out.append(fm)
126
+ return out
127
+
128
+ def read_events(self, tid=None) -> list[dict]:
129
+ """events.jsonl 全量(tid 给定则按 task 过滤)按行序;断尾行丢弃同 load_state。"""
130
+ out = []
131
+ if not self._events.exists():
132
+ return out
133
+ with self._events.open(encoding="utf-8") as f:
134
+ for line in f:
135
+ try:
136
+ ev = json.loads(line)
137
+ except ValueError:
138
+ continue
139
+ if tid is None or ev.get("task") == tid:
140
+ out.append(ev)
141
+ return out
142
+
143
+ def write_ephemeral(self, name: str, data: dict) -> None:
144
+ path = self.home / name
145
+ path.parent.mkdir(parents=True, exist_ok=True)
146
+ tmp = path.with_name(path.name + ".tmp")
147
+ with tmp.open("w", encoding="utf-8") as f:
148
+ f.write(json.dumps(data, ensure_ascii=False) + "\n")
149
+ f.flush()
150
+ os.fsync(f.fileno())
151
+ os.replace(tmp, path) # 原子覆写:读者(lift 解析 service.json)不见半写
152
+
153
+ def read_ephemeral(self, name: str) -> dict | None:
154
+ path = self.home / name
155
+ if not path.exists():
156
+ return None
157
+ try:
158
+ return json.loads(path.read_text(encoding="utf-8"))
159
+ except ValueError:
160
+ return None # 断尾/损坏件视为缺席,同 load_state 容错口径
161
+
162
+ def flock_singleton(self) -> bool:
163
+ if self._lock_fd is not None:
164
+ return True
165
+ self.home.mkdir(parents=True, exist_ok=True)
166
+ fd = os.open(self._lock_path, os.O_RDWR | os.O_CREAT, 0o644)
167
+ try:
168
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
169
+ except OSError:
170
+ os.close(fd)
171
+ return False
172
+ self._lock_fd = fd # 持有至进程退出,关闭即释放
173
+ return True
174
+
175
+ @staticmethod
176
+ def _append_line(path: Path, line: str) -> None:
177
+ path.parent.mkdir(parents=True, exist_ok=True)
178
+ with path.open("a", encoding="utf-8") as f:
179
+ f.write(line + "\n")
180
+ f.flush()
181
+ os.fsync(f.fileno())
@@ -0,0 +1 @@
1
+ """agorad 域核心:纯逻辑,只 import stdlib;访问层对象一律构造注入。"""
agorad/core/app.py ADDED
@@ -0,0 +1,137 @@
1
+ """装配容器(R2):字段即依赖图——main 独占构造,transport 只 import 本模块。"""
2
+ import time
3
+ from dataclasses import dataclass
4
+
5
+ EV_MAIN_BOUND = "main_sid_bound"
6
+ EV_WORKER_REBOUND = "worker_rebound"
7
+ EV_DISPATCHER_REBOUND = "dispatcher_rebind"
8
+
9
+ # worker 换号自愈口径活动态(词汇对齐 task._ACTIVE;core 禁兄弟 import 故字面量本地定义)
10
+ _ACTIVE = {"dispatched", "running", "done", "verify_failed", "reopened", "blocked"}
11
+ _NESTED_OPEN = _ACTIVE | {"todo"} # 已建未派子任务同样保留父 worker 所有权
12
+
13
+
14
+ @dataclass
15
+ class App:
16
+ """聚合根:core 服务+注入的 access 件+装配元数据+换号自愈绑定面(R25-A/gt-d55b9e)。"""
17
+ store: object
18
+ state: object
19
+ auth: object
20
+ router: object
21
+ task: object
22
+ peer: object
23
+ gate: object
24
+ health: object
25
+ recover: object
26
+ report: object
27
+ oc: object
28
+ gitx: object
29
+ main_sid: str
30
+
31
+ def bind_main(self, sid, caller):
32
+ """R25-A:root 牌调用登记主会话真实 sid(多主会话最近活跃覆盖)。
33
+
34
+ sid=牌当前 sid(占位 "main" 或重启前旧绑定),caller=X-Agora-Sid 真实
35
+ 会话——改绑牌使后续 task 行 dispatcher 落真实 sid,拍人链边直达真会话;
36
+ 重启后 main_sid 复位与牌不同步亦经此自愈。任务 worker 边会话拒绝绑定:
37
+ worker 凭据缺失回落 root 牌(B1)+携 sid 属误附,不得抢主会话绑定。
38
+ 返回对齐后 sid(拒绑=原 sid)——本笔请求后续归属判定即以新 sid 走。
39
+ """
40
+ if caller != sid:
41
+ if any(t.get("worker_sid") == caller
42
+ for t in self.state.snapshot()["tasks"].values()):
43
+ return sid
44
+ self.auth.rebind(sid, caller)
45
+ if self.main_sid != caller:
46
+ self.main_sid = caller
47
+ self.store.append_event({"ts": time.time(), "actor": caller,
48
+ "event": EV_MAIN_BOUND})
49
+ return caller
50
+
51
+ def bind_worker(self, sid, caller):
52
+ """gt-d55b9e:worker 牌换号自愈——持牌即身份,漂移即改绑(镜像 bind_main)。
53
+
54
+ sid=牌注册 sid(dispatch 时落行的 worker_sid),caller=X-Agora-Sid
55
+ 真实续接会话。注册 sid 是某活动任务 worker_sid 且两者漂移 → rebind
56
+ (旧 sid 入血统,token 本体不变)+任务行 worker_sid 同步覆写(与
57
+ recover.reinject 同口径)。改绑波及 authorship(gt-83185d R2):worker
58
+ 作为派发方的活动嵌套子任务行 dispatcher_sid 一并覆写为 caller——
59
+ 嵌套任务所有权在换号后保持,不被归属终审 dispatcher_sid==caller 卡死。
60
+ 守卫:终态/无主 sid 一律不动;worker_sid 行与 dispatcher_sid 行分别
61
+ 覆写对应字段不串;已占 caller sid 拒绑(下)原样——worker 改绑只动
62
+ worker 牌,主会话/他牌不受累。返回改绑后 sid(调用方刷新身份)。
63
+ """
64
+ if caller == sid:
65
+ return sid
66
+ tasks = self.state.snapshot()["tasks"]
67
+ # 已占 sid 不抢绑(镜像 bind_main 守卫):X-Agora-Sid 是自证头,worker 可从
68
+ # 任务页读到他任务 sid——caller 已是主会话/任一任务 dispatcher/worker 即拒
69
+ # (防跨任务冒充;真续接 sid 全新必不撞)。
70
+ if caller == self.main_sid or any(
71
+ caller in (t.get("worker_sid"), t.get("dispatcher_sid"))
72
+ for t in tasks.values()):
73
+ return sid
74
+ hit = [tid for tid, t in tasks.items()
75
+ if t.get("worker_sid") == sid and t["state"] in _ACTIVE]
76
+ if not hit:
77
+ return sid
78
+ # 快照后门线程可能已结案:零活动行不改绑(TOCTOU 复检,镜像 bind_dispatcher)
79
+ if not any(self.state.get(tid)["state"] in _ACTIVE for tid in hit):
80
+ return sid
81
+ self.auth.rebind(sid, caller)
82
+ for tid in hit:
83
+ if self.state.get(tid)["state"] not in _ACTIVE:
84
+ continue # 改绑窗内结案:终态行冻结不覆写(TOCTOU 复检)
85
+ self.state.set_fields(tid, worker_sid=caller)
86
+ self.store.append_event({"ts": time.time(), "actor": sid,
87
+ "event": EV_WORKER_REBOUND, "task": tid,
88
+ "from": sid, "to": caller})
89
+ # R2 波及:嵌套子任务行只覆写 dispatcher_sid 字段(worker_sid 行不串)
90
+ nested = [tid for tid, t in tasks.items()
91
+ if t.get("dispatcher_sid") == sid and t["state"] in _NESTED_OPEN]
92
+ for tid in nested:
93
+ if self.state.get(tid)["state"] not in _NESTED_OPEN:
94
+ continue # 同上:终态行冻结不覆写(TOCTOU 复检)
95
+ self.state.set_fields(tid, dispatcher_sid=caller)
96
+ self.store.append_event({"ts": time.time(), "actor": sid,
97
+ "event": EV_WORKER_REBOUND, "task": tid,
98
+ "from": sid, "to": caller})
99
+ return caller
100
+
101
+ def bind_dispatcher(self, role, sid, t):
102
+ """F21:dispatcher 侧换号改绑(镜像 bind_worker,补壳重启死号缺口)——
103
+ root 牌 post 命中「非链边」前判愈合:登记 dispatcher_sid ≠ 调用者且该
104
+ 会话已不存在(oc 存在性检查)→ auth.rebind(旧号入血统)+活动任务行
105
+ dispatcher_sid 同步覆写+dispatcher_rebind 审计(含新旧 sid 与任务),
106
+ 本笔 post 按链边放行。守卫(不满足保持既有拒绝):非 root 牌、活号
107
+ (防活号互抢)、任务终态(冻结不动)、oc 不可达≠死(recover 同口径免判)。"""
108
+ d = t.get("dispatcher_sid")
109
+ if role != "root" or not d or d == sid:
110
+ return
111
+ tasks = self.state.snapshot()["tasks"]
112
+ hit = [tid for tid, x in tasks.items()
113
+ if x.get("dispatcher_sid") == d and x["state"] in _ACTIVE]
114
+ if not hit:
115
+ return # 终态不触发改绑
116
+ # 已占 sid 不抢绑(镜像 bind_worker 防跨任务冒充;真续接号全新必不撞;
117
+ # main_sid 不在守卫内:root 牌对齐后 sid==main_sid 是常态非冒充)。
118
+ if any(sid in (x.get("worker_sid"), x.get("dispatcher_sid"))
119
+ for x in tasks.values()):
120
+ return
121
+ try:
122
+ if self.oc.alive(d):
123
+ return # 登记会话仍活不抢绑
124
+ except Exception: # oc 不可达≠会话死:本轮免判(recover.startup 同口径)
125
+ return
126
+ # 探活窗内可能已全数结案:零活动行不改牌(TOCTOU 复检——rebind 是
127
+ # 持久身份换绑,无活动行即无消费方,凭陈旧快照换牌=无谓身份漂移)
128
+ if not any(self.state.get(tid)["state"] in _ACTIVE for tid in hit):
129
+ return
130
+ self.auth.rebind(d, sid)
131
+ for tid in hit:
132
+ if self.state.get(tid)["state"] not in _ACTIVE:
133
+ continue # 探活窗内门体或已结案:终态行冻结不覆写(TOCTOU 复检)
134
+ self.state.set_fields(tid, dispatcher_sid=sid)
135
+ self.store.append_event({"ts": time.time(), "actor": sid,
136
+ "event": EV_DISPATCHER_REBOUND,
137
+ "task": tid, "from": d, "to": sid})
agorad/core/auth.py ADDED
@@ -0,0 +1,134 @@
1
+ """会话牌(token→sid/role)与 enforce 旗标:独立持久化+角色判定矩阵(设计 §4.9)。
2
+
3
+ 牌不入 tasks 快照行与消息库,经 write_ephemeral("tokens") 独立落盘,kill -9 后
4
+ 新实例从盘恢复;enforce 旗标只内存持有、重启默认 on,开/关与拒绝均写审计
5
+ (events.jsonl 经注入 store.append_event)。Store 构造注入,core 只 import stdlib。
6
+ """
7
+ import secrets
8
+ import time
9
+
10
+ # worker 可创建并管理自己的嵌套子任务;update/cancel 的他任务隔离由 TaskService 归属终审
11
+ WORKER_TOOLS = {"post", "check_mail", "repo_board", "wiki_read", "wiki_write",
12
+ "task_create", "task_dispatch", "task_update", "task_cancel"}
13
+ # dispatcher 通用面=worker 面+建任务/观测;task_update/task_cancel 为 task 域单列
14
+ DISPATCHER_TOOLS = WORKER_TOOLS | {"task_create", "board", "doctor"}
15
+
16
+ # 血统封顶(gt-83185d R1):牌血统最多留最近 16 条曾用名(FIFO 最旧出局),
17
+ # 防持牌调用每次携随机新 X-Agora-Sid 把血统表与落盘无限撑大
18
+ LINEAGE_CAP = 16
19
+
20
+ # 审计事件名常量
21
+ EV_ENFORCE_ON = "enforce_on"
22
+ EV_ENFORCE_OFF = "enforce_off"
23
+ EV_AUTH_DENIED = "auth_denied"
24
+ EV_ROOT_ROTATED = "root_rotated"
25
+
26
+
27
+ class Auth:
28
+ """每会话一牌:root/dispatcher/worker;多主会话各持 root 牌(角色非单例)。"""
29
+
30
+ def __init__(self, store):
31
+ self._store = store
32
+ data = store.read_ephemeral("tokens") or {}
33
+ self._tokens = {t: list(v) for t, v in data.items()} # token -> [sid, role]
34
+ self._enforce = True # 重启默认 on(旗标不落盘)
35
+
36
+ def issue(self, sid, role):
37
+ token = secrets.token_hex()
38
+ self._tokens[token] = [sid, role, []]
39
+ self._persist()
40
+ return token
41
+
42
+ @staticmethod
43
+ def _lineage(v):
44
+ """牌血统集(F16 曾用名);旧格式两元组缺字段=空集。"""
45
+ return v[2] if len(v) > 2 else []
46
+
47
+ def same_lineage(self, current_sid, historical_sid):
48
+ """F16:current_sid 现持牌的血统集含 historical_sid(换号链边连续)。"""
49
+ return any(historical_sid in self._lineage(v)
50
+ for v in self._tokens.values() if v[0] == current_sid)
51
+
52
+ def identify(self, token):
53
+ v = self._tokens.get(token)
54
+ return (v[0], v[1]) if v else None
55
+
56
+ def rebind(self, old_sid, new_sid):
57
+ """按 sid 改绑牌到新会话(值 [new_sid, role],token 本体不变)——
58
+ 重注入后旧牌即续用;old_sid 入血统集随牌落盘(F16 换号链边连续)。
59
+ 返回牌文本(供 inject 送达),无牌 None。"""
60
+ for tok, v in self._tokens.items():
61
+ if v[0] == old_sid:
62
+ v[0] = new_sid
63
+ if len(v) < 3: # 旧格式起档
64
+ v.append([])
65
+ if old_sid not in v[2]:
66
+ v[2].append(old_sid)
67
+ del v[2][:-LINEAGE_CAP] # 封顶:最旧出局
68
+ self._persist()
69
+ return tok
70
+ return None
71
+
72
+ def token_for(self, sid):
73
+ """按 sid 查牌文本(F25 周期重注入只读面,不改绑不轮换);无牌 None。"""
74
+ return next((tok for tok, v in self._tokens.items() if v[0] == sid), None)
75
+
76
+ def revoke(self, token):
77
+ if token not in self._tokens:
78
+ return False
79
+ del self._tokens[token]
80
+ self._persist()
81
+ return True
82
+
83
+ def revoke_session(self, sid):
84
+ """按 sid 作废该会话全部牌(worker 单牌语义;cancel/重派/门尾取消共用)。"""
85
+ hit = [t for t, v in self._tokens.items() if v[0] == sid]
86
+ for t in hit:
87
+ del self._tokens[t]
88
+ if hit:
89
+ self._persist()
90
+ return bool(hit)
91
+
92
+ def rotate_root(self):
93
+ # doctor 一键轮换:全部旧 root 牌即废,新牌沿用最近 root 的 sid;其余角色不受累
94
+ roots = [t for t, v in self._tokens.items() if v[1] == "root"]
95
+ sid = self._tokens[roots[-1]][0] if roots else "root"
96
+ for t in roots:
97
+ del self._tokens[t]
98
+ token = self.issue(sid, "root")
99
+ self._store.append_event({"ts": time.time(), "actor": "root",
100
+ "event": EV_ROOT_ROTATED, "note": f"revoked={len(roots)}"})
101
+ return token
102
+
103
+ def may(self, role, tool, tid, state):
104
+ """矩阵:root→任意;worker→白名单;dispatcher→通用面+task 域(须有任务行)。
105
+
106
+ state=目标任务快照行(State.snapshot()["tasks"][tid]),无行即非己任;
107
+ 归属终审(dispatcher_sid==caller sid)由持 actor_token 的 TaskService 负责。
108
+ """
109
+ if role == "root":
110
+ return True
111
+ if role == "worker":
112
+ return tool in WORKER_TOOLS
113
+ if role == "dispatcher":
114
+ if tool in ("task_update", "task_cancel"):
115
+ return state is not None
116
+ return tool in DISPATCHER_TOOLS
117
+ return False
118
+
119
+ def set_enforce(self, on, role):
120
+ """仅 root 生效;开/关与非 root 拒绝均写 events 审计行。"""
121
+ if role != "root":
122
+ self._store.append_event({"ts": time.time(), "actor": role,
123
+ "event": EV_AUTH_DENIED, "note": "enforce"})
124
+ return False
125
+ self._enforce = bool(on)
126
+ self._store.append_event({"ts": time.time(), "actor": role,
127
+ "event": EV_ENFORCE_ON if self._enforce else EV_ENFORCE_OFF})
128
+ return True
129
+
130
+ def enforce_on(self):
131
+ return self._enforce
132
+
133
+ def _persist(self):
134
+ self._store.write_ephemeral("tokens", self._tokens)