repomoot 0.2.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.
- repomoot/__init__.py +4 -0
- repomoot/adapters/__init__.py +1 -0
- repomoot/adapters/claude_code/__init__.py +1 -0
- repomoot/adapters/claude_code/install.py +211 -0
- repomoot/cli/__init__.py +1 -0
- repomoot/cli/fmt.py +202 -0
- repomoot/cli/main.py +1073 -0
- repomoot/client/__init__.py +1 -0
- repomoot/client/http.py +250 -0
- repomoot/config.py +73 -0
- repomoot/core/__init__.py +1 -0
- repomoot/core/cards.py +91 -0
- repomoot/core/db.py +116 -0
- repomoot/core/lifecycle.py +109 -0
- repomoot/core/service.py +1007 -0
- repomoot/server/__init__.py +1 -0
- repomoot/server/app.py +367 -0
- repomoot/server/run.py +24 -0
- repomoot/server/ui.html +182 -0
- repomoot-0.2.0.dist-info/METADATA +189 -0
- repomoot-0.2.0.dist-info/RECORD +26 -0
- repomoot-0.2.0.dist-info/WHEEL +5 -0
- repomoot-0.2.0.dist-info/entry_points.txt +2 -0
- repomoot-0.2.0.dist-info/licenses/LICENSE +202 -0
- repomoot-0.2.0.dist-info/licenses/NOTICE +5 -0
- repomoot-0.2.0.dist-info/top_level.txt +1 -0
repomoot/core/service.py
ADDED
|
@@ -0,0 +1,1007 @@
|
|
|
1
|
+
"""Business logic. One instance per daemon; every public method is short and transactional.
|
|
2
|
+
|
|
3
|
+
Principals:
|
|
4
|
+
- shared daemon token: any local client (agents included) — can act as an already registered
|
|
5
|
+
agent from that agent's own directory;
|
|
6
|
+
- human token: registers agents, changes policy, attests decisions, speaks "as" an agent.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import secrets
|
|
14
|
+
import sqlite3
|
|
15
|
+
import threading
|
|
16
|
+
import unicodedata
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from datetime import timedelta
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Callable
|
|
21
|
+
|
|
22
|
+
from . import lifecycle as lc
|
|
23
|
+
from .db import connect, j, next_id, now, parse_ts, row_to_dict
|
|
24
|
+
from .. import config
|
|
25
|
+
|
|
26
|
+
REF_RE = re.compile(r"@([\w.-]+)(?:@([0-9a-fA-F]{6,40}))?(?:[/!]([^\s:,)]+))?(?::(\d+)(?:-(\d+))?)?")
|
|
27
|
+
AGENT_ID_RE = re.compile(r"[a-z0-9][a-z0-9_.-]{0,40}")
|
|
28
|
+
LIMITS = {"title": 200, "body": 64_000, "content": 256_000, "reason": 2_000, "options": 20, "cc": 10}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ServiceError(Exception):
|
|
32
|
+
def __init__(self, msg: str, code: str = "bad_request", status: int = 400, hint: str | None = None):
|
|
33
|
+
super().__init__(msg)
|
|
34
|
+
if code in ("bad_transition", "conflict") and status == 400:
|
|
35
|
+
status = 409
|
|
36
|
+
self.code, self.status, self.hint = code, status, hint
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def clean(s: str | None, limit: int, field: str) -> str:
|
|
40
|
+
"""Strip control/invisible characters, enforce a size limit."""
|
|
41
|
+
s = s or ""
|
|
42
|
+
s = "".join(ch for ch in s if ch in "\n\t" or (unicodedata.category(ch)[0] != "C" and unicodedata.category(ch) != "Cf"))
|
|
43
|
+
if len(s) > limit:
|
|
44
|
+
raise ServiceError(f"{field} too long ({len(s)} > {limit})", "bad_request")
|
|
45
|
+
return s
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _norm(p: str) -> str:
|
|
49
|
+
return str(Path(p).resolve()).replace("\\", "/").lower()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _hash(tok: str) -> str:
|
|
53
|
+
return hashlib.sha256(tok.encode()).hexdigest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Service:
|
|
57
|
+
def __init__(self, db_path: Path):
|
|
58
|
+
self.conn = connect(db_path)
|
|
59
|
+
self.lock = threading.RLock()
|
|
60
|
+
self._depth = 0
|
|
61
|
+
self.on_event: Callable[[], None] | None = None # set by the server to wake long-pollers
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------------ plumbing
|
|
64
|
+
@contextmanager
|
|
65
|
+
def tx(self):
|
|
66
|
+
with self.lock:
|
|
67
|
+
self._depth += 1
|
|
68
|
+
try:
|
|
69
|
+
yield
|
|
70
|
+
if self._depth == 1:
|
|
71
|
+
self.conn.commit()
|
|
72
|
+
except BaseException:
|
|
73
|
+
if self._depth == 1:
|
|
74
|
+
self.conn.rollback()
|
|
75
|
+
raise
|
|
76
|
+
finally:
|
|
77
|
+
self._depth -= 1
|
|
78
|
+
if self._depth == 0 and self.on_event:
|
|
79
|
+
self.on_event()
|
|
80
|
+
|
|
81
|
+
def _one(self, sql: str, *args) -> dict | None:
|
|
82
|
+
with self.lock:
|
|
83
|
+
return row_to_dict(self.conn.execute(sql, args).fetchone())
|
|
84
|
+
|
|
85
|
+
def _all(self, sql: str, *args) -> list[dict]:
|
|
86
|
+
with self.lock:
|
|
87
|
+
return [row_to_dict(r) for r in self.conn.execute(sql, args).fetchall()]
|
|
88
|
+
|
|
89
|
+
def _emit(self, type_: str, entity_type: str, entity_id: str, audience: list[str], payload: dict | None = None,
|
|
90
|
+
request_id: str | None = None, agent_id: str | None = None, session_id: str | None = None) -> int:
|
|
91
|
+
cur = self.conn.execute(
|
|
92
|
+
"INSERT INTO events(type, entity_type, entity_id, request_id, agent_id, session_id, payload, created_at) VALUES(?,?,?,?,?,?,?,?)",
|
|
93
|
+
(type_, entity_type, entity_id, request_id, agent_id, session_id, j(payload or {}), now()))
|
|
94
|
+
eid = cur.lastrowid
|
|
95
|
+
for a in sorted({a for a in audience if a}):
|
|
96
|
+
self.conn.execute("INSERT OR IGNORE INTO event_audience(event_id, agent_id) VALUES(?,?)", (eid, a))
|
|
97
|
+
return eid
|
|
98
|
+
|
|
99
|
+
def _fts(self, entity_type: str, entity_id: str, title: str, body: str) -> None:
|
|
100
|
+
self.conn.execute("INSERT INTO fts(entity_type, entity_id, title, body) VALUES(?,?,?,?)", (entity_type, entity_id, title or "", body or ""))
|
|
101
|
+
|
|
102
|
+
def _notify_targets(self, agents: list[str], exclude_session: str | None = None) -> list[dict]:
|
|
103
|
+
out = []
|
|
104
|
+
for a in sorted(set(agents)):
|
|
105
|
+
for s in self.active_sessions(a):
|
|
106
|
+
if s["id"] != exclude_session:
|
|
107
|
+
out.append({"agent": a, "runtime": s["runtime"], "ref": s["runtime_ref"] or s["name"] or s["id"]})
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
def _req(self, rid: str) -> dict:
|
|
111
|
+
r = self._one("SELECT * FROM requests WHERE id=?", rid)
|
|
112
|
+
if not r:
|
|
113
|
+
raise ServiceError(f"{rid} not found", "not_found", 404)
|
|
114
|
+
return r
|
|
115
|
+
|
|
116
|
+
def _agent(self, aid: str) -> dict:
|
|
117
|
+
a = self._one("SELECT * FROM agents WHERE id=?", aid)
|
|
118
|
+
if not a:
|
|
119
|
+
raise ServiceError(f"agent '{aid}' not found", "not_found", 404, hint="repomoot agents")
|
|
120
|
+
return a
|
|
121
|
+
|
|
122
|
+
def _is_po(self, agent_id: str) -> bool:
|
|
123
|
+
a = self._one("SELECT kind FROM agents WHERE id=?", agent_id)
|
|
124
|
+
return bool(a and a["kind"] == "product_owner")
|
|
125
|
+
|
|
126
|
+
def _set_status(self, req: dict, status: str, session: dict | None, extra: dict | None = None, reason: str | None = None) -> dict:
|
|
127
|
+
old = req["status"]
|
|
128
|
+
fields = {"status": status, "updated_at": now()}
|
|
129
|
+
if status in lc.TERMINAL:
|
|
130
|
+
fields["closed_at"] = now()
|
|
131
|
+
if extra:
|
|
132
|
+
fields.update(extra)
|
|
133
|
+
sets = ", ".join(f"{k}=?" for k in fields)
|
|
134
|
+
self.conn.execute(f"UPDATE requests SET {sets} WHERE id=?", (*[j(v) if isinstance(v, (list, dict)) else v for v in fields.values()], req["id"]))
|
|
135
|
+
self._emit("status", "request", req["id"], self._audience(req), {"from": old, "to": status, "reason": reason},
|
|
136
|
+
request_id=req["id"], agent_id=session["agent_id"] if session else None, session_id=session["id"] if session else None)
|
|
137
|
+
return self._req(req["id"])
|
|
138
|
+
|
|
139
|
+
def _audience(self, req: dict) -> list[str]:
|
|
140
|
+
aud = [req["from_agent"], req["to_agent"], *(req.get("cc") or [])]
|
|
141
|
+
if req.get("routed_to"):
|
|
142
|
+
aud.append(req["routed_to"])
|
|
143
|
+
return aud
|
|
144
|
+
|
|
145
|
+
def _last_message_from(self, rid: str) -> str | None:
|
|
146
|
+
m = self._one("SELECT from_agent FROM messages WHERE request_id=? AND type!='system' ORDER BY id DESC LIMIT 1", rid)
|
|
147
|
+
return m["from_agent"] if m else None
|
|
148
|
+
|
|
149
|
+
def _po_id(self) -> str | None:
|
|
150
|
+
a = self._one("SELECT id FROM agents WHERE kind='product_owner' ORDER BY created_at LIMIT 1")
|
|
151
|
+
return a["id"] if a else None
|
|
152
|
+
|
|
153
|
+
def po_present(self) -> bool:
|
|
154
|
+
po = self._po_id()
|
|
155
|
+
if not po:
|
|
156
|
+
return False
|
|
157
|
+
cutoff = (parse_ts(now()) - timedelta(minutes=config.PO_PRESENT_MINUTES)).isoformat().replace("+00:00", "Z")
|
|
158
|
+
return bool(self._one("SELECT id FROM sessions WHERE agent_id=? AND status!='gone' AND last_seen_at>=? LIMIT 1", po, cutoff))
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------------ agents & sessions
|
|
161
|
+
def list_agents(self) -> list[dict]:
|
|
162
|
+
out = []
|
|
163
|
+
for a in self._all("SELECT * FROM agents ORDER BY kind DESC, id"):
|
|
164
|
+
a["sessions"] = [{"id": s["id"], "name": s["name"], "runtime": s["runtime"], "status": s["status"]} for s in self.active_sessions(a["id"])]
|
|
165
|
+
a["open_requests_to"] = self.conn.execute(
|
|
166
|
+
"SELECT COUNT(*) FROM requests WHERE to_agent=? AND status NOT IN ('closed','declined','withdrawn','acknowledged','wont_do')", (a["id"],)).fetchone()[0]
|
|
167
|
+
out.append(a)
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
def register_agent(self, agent_id: str, kind: str, repo: str, depends_on: list[str] | None, description: str | None) -> dict:
|
|
171
|
+
"""Human-only. Creates the agent with repo = the given directory."""
|
|
172
|
+
with self.tx():
|
|
173
|
+
if not AGENT_ID_RE.fullmatch(agent_id or ""):
|
|
174
|
+
raise ServiceError("agent id: lowercase letters, digits, '-', '_', '.'", "bad_request")
|
|
175
|
+
kind = {"po": "product_owner", "product_owner": "product_owner", "project": "project", "observer": "observer"}.get(kind)
|
|
176
|
+
if kind is None:
|
|
177
|
+
raise ServiceError("kind must be project|po|observer")
|
|
178
|
+
if self._one("SELECT id FROM agents WHERE id=?", agent_id):
|
|
179
|
+
raise ServiceError(f"agent '{agent_id}' already exists", "conflict", 409)
|
|
180
|
+
if kind == "product_owner" and self._po_id():
|
|
181
|
+
raise ServiceError(f"a product owner already exists: {self._po_id()}", "conflict", 409)
|
|
182
|
+
for d in depends_on or []:
|
|
183
|
+
self._agent(d)
|
|
184
|
+
repos = [_norm(repo)]
|
|
185
|
+
owner = self.agent_for_cwd(repo)
|
|
186
|
+
if owner and kind != "product_owner" and _norm(self._agent(owner)["repos"][0]) == repos[0]:
|
|
187
|
+
raise ServiceError(f"directory already belongs to agent '{owner}'", "conflict", 409)
|
|
188
|
+
self.conn.execute("INSERT INTO agents(id, kind, display_name, repos, depends_on, description, created_at) VALUES(?,?,?,?,?,?,?)",
|
|
189
|
+
(agent_id, kind, agent_id, j(repos), j(depends_on or []), description, now()))
|
|
190
|
+
if kind == "project":
|
|
191
|
+
for r in repos:
|
|
192
|
+
self.conn.execute("INSERT OR REPLACE INTO permissions(agent_id, repo, read, write, source) VALUES(?,?,1,1,'owner')", (agent_id, r))
|
|
193
|
+
if kind == "product_owner":
|
|
194
|
+
self.conn.execute("UPDATE requests SET to_agent=? WHERE type='decision' AND to_agent='po'", (agent_id,))
|
|
195
|
+
self.conn.execute("UPDATE requests SET routed_to=? WHERE type='decision' AND routed_to='po'", (agent_id,))
|
|
196
|
+
everyone = [a["id"] for a in self._all("SELECT id FROM agents")]
|
|
197
|
+
self._emit("agent.created", "agent", agent_id, everyone, {"kind": kind, "repos": repos, "agent": agent_id})
|
|
198
|
+
return self._agent(agent_id)
|
|
199
|
+
|
|
200
|
+
def update_agent(self, agent_id: str, depends_on: list[str] | None, description: str | None, add_repo: str | None) -> dict:
|
|
201
|
+
"""Human-only."""
|
|
202
|
+
with self.tx():
|
|
203
|
+
a = self._agent(agent_id)
|
|
204
|
+
deps = list(a["depends_on"])
|
|
205
|
+
for d in depends_on or []:
|
|
206
|
+
self._agent(d)
|
|
207
|
+
if d not in deps:
|
|
208
|
+
deps.append(d)
|
|
209
|
+
repos = list(a["repos"])
|
|
210
|
+
if add_repo and _norm(add_repo) not in repos:
|
|
211
|
+
repos.append(_norm(add_repo))
|
|
212
|
+
if a["kind"] == "project":
|
|
213
|
+
self.conn.execute("INSERT OR REPLACE INTO permissions(agent_id, repo, read, write, source) VALUES(?,?,1,1,'owner')", (agent_id, _norm(add_repo)))
|
|
214
|
+
self.conn.execute("UPDATE agents SET depends_on=?, repos=?, description=COALESCE(?, description) WHERE id=?", (j(deps), j(repos), description, agent_id))
|
|
215
|
+
return self._agent(agent_id)
|
|
216
|
+
|
|
217
|
+
def agent_for_cwd(self, cwd: str) -> str | None:
|
|
218
|
+
"""Longest repository prefix wins; on a tie a project agent beats the PO (the PO is chosen explicitly)."""
|
|
219
|
+
p = _norm(cwd)
|
|
220
|
+
best, best_len, best_kind = None, -1, ""
|
|
221
|
+
for a in self._all("SELECT id, kind, repos FROM agents"):
|
|
222
|
+
for r in a["repos"]:
|
|
223
|
+
rp = _norm(r)
|
|
224
|
+
if not (p == rp or p.startswith(rp.rstrip("/") + "/")):
|
|
225
|
+
continue
|
|
226
|
+
better = len(rp) > best_len or (len(rp) == best_len and best_kind == "product_owner" and a["kind"] != "product_owner")
|
|
227
|
+
if better:
|
|
228
|
+
best, best_len, best_kind = a["id"], len(rp), a["kind"]
|
|
229
|
+
return best
|
|
230
|
+
|
|
231
|
+
def connect(self, agent_id: str | None, kind: str, cwd: str, name: str | None, runtime: str | None, runtime_ref: str | None,
|
|
232
|
+
pid: int | None, depends_on: list[str] | None, description: str | None, human: bool) -> dict:
|
|
233
|
+
with self.tx():
|
|
234
|
+
suggested = self.agent_for_cwd(cwd)
|
|
235
|
+
if not agent_id:
|
|
236
|
+
agent_id = suggested
|
|
237
|
+
if not agent_id:
|
|
238
|
+
raise ServiceError("no agent registered for this directory", "not_connected", 409,
|
|
239
|
+
hint="the user registers it by typing `/repomoot connect [name]` or `/repomoot po` in the chat")
|
|
240
|
+
agent = self._one("SELECT * FROM agents WHERE id=?", agent_id)
|
|
241
|
+
if not agent:
|
|
242
|
+
if not human:
|
|
243
|
+
raise ServiceError(f"agent '{agent_id}' is not registered", "forbidden", 403,
|
|
244
|
+
hint="registration is the user's action: they type `/repomoot connect [name]` in the chat (or run it in their terminal)")
|
|
245
|
+
agent = self.register_agent(agent_id, kind, cwd, depends_on, description)
|
|
246
|
+
else:
|
|
247
|
+
if suggested != agent_id and not human:
|
|
248
|
+
raise ServiceError(f"this directory is not a repository of '{agent_id}'" + (f" (it belongs to '{suggested}')" if suggested else ""),
|
|
249
|
+
"forbidden", 403, hint="connect from the agent's own repository; the user can override with `/repomoot connect <name>`")
|
|
250
|
+
if human and (depends_on or description or (suggested != agent_id)):
|
|
251
|
+
agent = self.update_agent(agent_id, depends_on, description, cwd if suggested != agent_id else None)
|
|
252
|
+
# One live session per (agent, cwd, name): close the previous one.
|
|
253
|
+
for old in self._all("SELECT id FROM sessions WHERE agent_id=? AND cwd=? AND name IS ? AND status!='gone'", agent_id, cwd, name):
|
|
254
|
+
self.conn.execute("UPDATE sessions SET status='gone', ended_at=? WHERE id=?", (now(), old["id"]))
|
|
255
|
+
self.conn.execute("UPDATE requests SET claimed_by=NULL WHERE claimed_by=?", (old["id"],))
|
|
256
|
+
sid = next_id(self.conn, "ses")
|
|
257
|
+
tok = secrets.token_urlsafe(24)
|
|
258
|
+
cursor = self.last_event_id()
|
|
259
|
+
self.conn.execute(
|
|
260
|
+
"INSERT INTO sessions(id, token_hash, agent_id, name, runtime, runtime_ref, cwd, pid, status, last_seen_at, started_at, event_cursor)"
|
|
261
|
+
" VALUES(?,?,?,?,?,?,?,?, 'active', ?, ?, ?)", (sid, _hash(tok), agent_id, name, runtime, runtime_ref, cwd, pid, now(), now(), cursor))
|
|
262
|
+
self._emit("session.connected", "session", sid, [agent_id], {"name": name, "runtime": runtime, "human": human}, agent_id=agent_id, session_id=sid)
|
|
263
|
+
ses = self._one("SELECT * FROM sessions WHERE id=?", sid)
|
|
264
|
+
who = self.whoami(ses)
|
|
265
|
+
return {"session": ses, "token": tok, "agent": agent, "who": who, "inbox": self.inbox(ses), "po_present": self.po_present(), "cursor": cursor}
|
|
266
|
+
|
|
267
|
+
def set_session_ref(self, session: dict, runtime_ref: str) -> dict:
|
|
268
|
+
"""The session records its own cross-session address (e.g. Claude's 'name [ref]')."""
|
|
269
|
+
with self.tx():
|
|
270
|
+
ref = clean(runtime_ref, 200, "runtime_ref").strip()
|
|
271
|
+
if not ref:
|
|
272
|
+
raise ServiceError("address is empty")
|
|
273
|
+
self.conn.execute("UPDATE sessions SET runtime_ref=? WHERE id=?", (ref, session["id"]))
|
|
274
|
+
return self._one("SELECT * FROM sessions WHERE id=?", session["id"])
|
|
275
|
+
|
|
276
|
+
def disconnect(self, session: dict) -> None:
|
|
277
|
+
with self.tx():
|
|
278
|
+
self.conn.execute("UPDATE sessions SET status='gone', ended_at=? WHERE id=?", (now(), session["id"]))
|
|
279
|
+
self.conn.execute("UPDATE requests SET claimed_by=NULL WHERE claimed_by=?", (session["id"],))
|
|
280
|
+
self._emit("session.disconnected", "session", session["id"], [session["agent_id"]], agent_id=session["agent_id"], session_id=session["id"])
|
|
281
|
+
|
|
282
|
+
def auth(self, token: str | None) -> dict:
|
|
283
|
+
if not token:
|
|
284
|
+
raise ServiceError("no session", "not_connected", 401, hint="repomoot connect <agent>")
|
|
285
|
+
with self.tx():
|
|
286
|
+
s = self._one("SELECT * FROM sessions WHERE token_hash=?", _hash(token))
|
|
287
|
+
if not s or s["status"] == "gone":
|
|
288
|
+
raise ServiceError("session expired or unknown", "not_connected", 401, hint="repomoot connect <agent>")
|
|
289
|
+
if (parse_ts(now()) - parse_ts(s["last_seen_at"])).total_seconds() > 10:
|
|
290
|
+
self.conn.execute("UPDATE sessions SET last_seen_at=?, status='active' WHERE id=?", (now(), s["id"]))
|
|
291
|
+
return s
|
|
292
|
+
|
|
293
|
+
def active_sessions(self, agent_id: str) -> list[dict]:
|
|
294
|
+
cutoff = (parse_ts(now()) - timedelta(minutes=config.SESSION_IDLE_MINUTES)).isoformat().replace("+00:00", "Z")
|
|
295
|
+
return self._all("SELECT * FROM sessions WHERE agent_id=? AND status!='gone' AND last_seen_at>=? ORDER BY last_seen_at DESC", agent_id, cutoff)
|
|
296
|
+
|
|
297
|
+
def whoami(self, session: dict) -> dict:
|
|
298
|
+
a = self._agent(session["agent_id"])
|
|
299
|
+
return {"agent": a["id"], "kind": a["kind"], "session": session["id"], "name": session["name"], "runtime": session["runtime"],
|
|
300
|
+
"repos": a["repos"], "depends_on": a["depends_on"], "consumers": self.consumers_of(a["id"]), "po_present": self.po_present()}
|
|
301
|
+
|
|
302
|
+
def consumers_of(self, agent_id: str) -> list[str]:
|
|
303
|
+
return [a["id"] for a in self._all("SELECT id, depends_on FROM agents") if agent_id in a["depends_on"]]
|
|
304
|
+
|
|
305
|
+
# ------------------------------------------------------------------ requests
|
|
306
|
+
def create_request(self, session: dict, to: str, type_: str, title: str, body: str = "", goal: dict | None = None,
|
|
307
|
+
priority: str = "normal", blocking: bool = False, cc: list[str] | None = None, parent_id: str | None = None,
|
|
308
|
+
labels: list[str] | None = None, due_at: str | None = None, idem: str | None = None, kind: str | None = None,
|
|
309
|
+
options: list | None = None, as_agent: str | None = None) -> dict:
|
|
310
|
+
with self.tx():
|
|
311
|
+
me = as_agent or session["agent_id"]
|
|
312
|
+
if as_agent:
|
|
313
|
+
self._agent(as_agent)
|
|
314
|
+
title = clean(title, LIMITS["title"], "title").strip()
|
|
315
|
+
body = clean(body, LIMITS["body"], "body")
|
|
316
|
+
if type_ not in lc.TYPES:
|
|
317
|
+
raise ServiceError(f"type must be one of {', '.join(lc.TYPES)}")
|
|
318
|
+
if not title:
|
|
319
|
+
raise ServiceError("title is required")
|
|
320
|
+
if priority not in lc.PRIORITIES:
|
|
321
|
+
raise ServiceError(f"priority must be one of {', '.join(lc.PRIORITIES)}")
|
|
322
|
+
if len(cc or []) > LIMITS["cc"]:
|
|
323
|
+
raise ServiceError("too many cc")
|
|
324
|
+
if idem:
|
|
325
|
+
dup = self._one("SELECT * FROM requests WHERE from_agent=? AND idem=?", me, idem)
|
|
326
|
+
if dup:
|
|
327
|
+
return {"request": dup, "notify": [], "duplicate": True}
|
|
328
|
+
if type_ == "task" and not self._is_po(me) and to != me:
|
|
329
|
+
raise ServiceError("only the product owner assigns tasks to other agents", "forbidden", 403)
|
|
330
|
+
if type_ == "decision":
|
|
331
|
+
return self.ask_po(session, title, body, kind or "other", options or [], parent_id, blocking)
|
|
332
|
+
if type_ == "notice" and to not in self.consumers_of(me) and not self._is_po(me):
|
|
333
|
+
raise ServiceError(f"notices go to your consumers only ({', '.join(self.consumers_of(me)) or 'none registered'})", "forbidden", 403)
|
|
334
|
+
self._agent(to)
|
|
335
|
+
if to == me:
|
|
336
|
+
raise ServiceError("cannot address a request to yourself")
|
|
337
|
+
for c in cc or []:
|
|
338
|
+
self._agent(c)
|
|
339
|
+
if parent_id:
|
|
340
|
+
self._req(parent_id)
|
|
341
|
+
rid = next_id(self.conn, "req")
|
|
342
|
+
refs = self.extract_refs(body + " " + json.dumps(goal or {}))
|
|
343
|
+
self.conn.execute(
|
|
344
|
+
"INSERT INTO requests(id, type, from_agent, to_agent, cc, parent_id, title, body, goal, priority, blocking, status, labels, idem, due_at, kind, options, created_at, updated_at)"
|
|
345
|
+
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
346
|
+
(rid, type_, me, to, j(cc or []), parent_id, title, body, j(goal or {}), priority, int(blocking), "open",
|
|
347
|
+
j(labels or []), idem, due_at, kind, j(options or []), now(), now()))
|
|
348
|
+
self._fts("request", rid, title, body)
|
|
349
|
+
req = self._req(rid)
|
|
350
|
+
self._emit("request.created", "request", rid, self._audience(req), {"type": type_, "title": title, "from": me, "to": to, "blocking": blocking},
|
|
351
|
+
request_id=rid, agent_id=me, session_id=session["id"])
|
|
352
|
+
if type_ in ("question", "capability", "bug", "change", "review"):
|
|
353
|
+
# Asking another agent for something is the dependency; no need to declare it up front.
|
|
354
|
+
a_from = self._agent(me)
|
|
355
|
+
if a_from["kind"] == "project" and to not in a_from["depends_on"] and not self._is_po(to):
|
|
356
|
+
self.conn.execute("UPDATE agents SET depends_on=? WHERE id=?", (j(a_from["depends_on"] + [to]), me))
|
|
357
|
+
if type_ == "notice" and "breaking" in (labels or []) and self._po_id():
|
|
358
|
+
self._emit("notice.breaking", "request", rid, [self._po_id()], {"title": title, "from": me}, request_id=rid, agent_id=me)
|
|
359
|
+
return {"request": req, "notify": self._notify_targets([to, *(cc or [])], session["id"]), "refs": refs}
|
|
360
|
+
|
|
361
|
+
def list_requests(self, agent_id: str, box: str = "all", status: str | None = None, type_: str | None = None,
|
|
362
|
+
other: str | None = None, since: str | None = None, limit: int = 50, open_only: bool = True) -> list[dict]:
|
|
363
|
+
where, args = [], []
|
|
364
|
+
cc_has = "EXISTS(SELECT 1 FROM json_each(requests.cc) WHERE value=?)"
|
|
365
|
+
if box == "inbox":
|
|
366
|
+
where.append(f"(to_agent=? OR routed_to=? OR {cc_has})"); args += [agent_id] * 3
|
|
367
|
+
elif box == "outbox":
|
|
368
|
+
where.append("from_agent=?"); args.append(agent_id)
|
|
369
|
+
elif agent_id != "*":
|
|
370
|
+
where.append(f"(from_agent=? OR to_agent=? OR routed_to=? OR {cc_has})"); args += [agent_id] * 4
|
|
371
|
+
if status:
|
|
372
|
+
where.append("status=?"); args.append(status)
|
|
373
|
+
elif open_only:
|
|
374
|
+
where.append("status NOT IN ('closed','declined','withdrawn','acknowledged','wont_do')")
|
|
375
|
+
if type_:
|
|
376
|
+
where.append("type=?"); args.append(type_)
|
|
377
|
+
if other:
|
|
378
|
+
where.append("(from_agent=? OR to_agent=?)"); args += [other, other]
|
|
379
|
+
if since:
|
|
380
|
+
where.append("updated_at>=?"); args.append(since)
|
|
381
|
+
sql = "SELECT * FROM requests" + (" WHERE " + " AND ".join(where) if where else "") + " ORDER BY blocking DESC, updated_at DESC LIMIT ?"
|
|
382
|
+
rows = self._all(sql, *args, min(limit, 500))
|
|
383
|
+
for r in rows:
|
|
384
|
+
r["actor"] = lc.actor_for(r, self._last_message_from(r["id"]))
|
|
385
|
+
return rows
|
|
386
|
+
|
|
387
|
+
def get_request(self, rid: str, me: str | None, include: tuple[str, ...] = ("messages", "artifacts"), since: int | None = None) -> dict:
|
|
388
|
+
req = self._req(rid)
|
|
389
|
+
out = {"request": req, "actor": lc.actor_for(req, self._last_message_from(rid))}
|
|
390
|
+
if me:
|
|
391
|
+
out["next"] = lc.next_actions(req, me)
|
|
392
|
+
if "messages" in include:
|
|
393
|
+
out["messages"] = self._all("SELECT * FROM messages WHERE request_id=? AND (? IS NULL OR rowid>?) ORDER BY id", rid, since, since)
|
|
394
|
+
if "artifacts" in include:
|
|
395
|
+
out["artifacts"] = self._all("SELECT * FROM artifacts WHERE request_id=? ORDER BY id", rid)
|
|
396
|
+
if "events" in include:
|
|
397
|
+
out["events"] = self._all("SELECT * FROM events WHERE request_id=? ORDER BY id", rid)
|
|
398
|
+
out["children"] = self._all("SELECT id, type, status, to_agent, title FROM requests WHERE parent_id=? ORDER BY id", rid)
|
|
399
|
+
out["links"] = self._all("SELECT * FROM request_links WHERE request_id=? OR related_id=?", rid, rid)
|
|
400
|
+
if req["parent_id"]:
|
|
401
|
+
out["parent"] = self._one("SELECT id, type, status, from_agent, to_agent, title FROM requests WHERE id=?", req["parent_id"])
|
|
402
|
+
if req.get("claimed_by"):
|
|
403
|
+
out["claimed_by_session"] = self._one("SELECT id, name, status, last_seen_at FROM sessions WHERE id=?", req["claimed_by"])
|
|
404
|
+
return out
|
|
405
|
+
|
|
406
|
+
def _claim_free(self, req: dict, session: dict) -> bool:
|
|
407
|
+
if not req["claimed_by"] or req["claimed_by"] == session["id"]:
|
|
408
|
+
return True
|
|
409
|
+
return not any(s["id"] == req["claimed_by"] for s in self.active_sessions(req["to_agent"]))
|
|
410
|
+
|
|
411
|
+
def claim(self, session: dict, rid: str) -> dict:
|
|
412
|
+
with self.tx():
|
|
413
|
+
req = self._req(rid)
|
|
414
|
+
if req["to_agent"] != session["agent_id"]:
|
|
415
|
+
raise ServiceError("only the recipient can claim", "forbidden", 403)
|
|
416
|
+
if req["status"] in lc.TERMINAL or req["status"] == "blocked":
|
|
417
|
+
raise ServiceError(f"cannot claim in status {req['status']}", "bad_transition")
|
|
418
|
+
if not self._claim_free(req, session):
|
|
419
|
+
holder = self._one("SELECT name, id FROM sessions WHERE id=?", req["claimed_by"])
|
|
420
|
+
raise ServiceError(f"already claimed by live session {holder['name'] or holder['id']}", "conflict", 409)
|
|
421
|
+
if req["status"] == "open":
|
|
422
|
+
target = lc.CLAIM_STATUS.get(req["type"])
|
|
423
|
+
if target is None:
|
|
424
|
+
raise ServiceError(f"{req['type']} requests are not claimed; use `repomoot req ack`", "bad_transition")
|
|
425
|
+
return {"request": self._set_status(req, target, session, {"claimed_by": session["id"]})}
|
|
426
|
+
self.conn.execute("UPDATE requests SET claimed_by=? WHERE id=?", (session["id"], rid))
|
|
427
|
+
return {"request": self._req(rid)}
|
|
428
|
+
|
|
429
|
+
def reply(self, session: dict, rid: str, body: str, type_: str = "comment", as_agent: str | None = None) -> dict:
|
|
430
|
+
with self.tx():
|
|
431
|
+
req = self._req(rid)
|
|
432
|
+
me = as_agent or session["agent_id"]
|
|
433
|
+
if as_agent:
|
|
434
|
+
self._agent(as_agent)
|
|
435
|
+
if type_ not in {"comment", "clarification", "answer"}:
|
|
436
|
+
raise ServiceError("type must be comment|clarification|answer")
|
|
437
|
+
body = clean(body, LIMITS["body"], "body")
|
|
438
|
+
if not body.strip():
|
|
439
|
+
raise ServiceError("empty message")
|
|
440
|
+
if me not in self._audience(req) and not self._is_po(me):
|
|
441
|
+
raise ServiceError("you are not a party of this request", "forbidden", 403)
|
|
442
|
+
if req["status"] in lc.TERMINAL:
|
|
443
|
+
raise ServiceError(f"request is {req['status']}", "bad_transition")
|
|
444
|
+
if req["to_agent"] == me and not as_agent and not self._claim_free(req, session):
|
|
445
|
+
holder = self._one("SELECT name, id FROM sessions WHERE id=?", req["claimed_by"])
|
|
446
|
+
raise ServiceError(f"claimed by live session {holder['name'] or holder['id']}", "conflict", 409)
|
|
447
|
+
mid = next_id(self.conn, "msg")
|
|
448
|
+
refs = self.extract_refs(body)
|
|
449
|
+
self.conn.execute("INSERT INTO messages(id, request_id, from_agent, from_session, type, body, refs, created_at) VALUES(?,?,?,?,?,?,?,?)",
|
|
450
|
+
(mid, rid, me, None if as_agent else session["id"], type_, body, j(refs), now()))
|
|
451
|
+
self._fts("message", mid, req["title"], body)
|
|
452
|
+
status_note = None
|
|
453
|
+
if req["status"] == "open" and me == req["to_agent"] and req["type"] in lc.CLAIM_STATUS:
|
|
454
|
+
req = self._set_status(req, lc.CLAIM_STATUS[req["type"]], session, {"claimed_by": None if as_agent else session["id"]})
|
|
455
|
+
status_note = req["status"]
|
|
456
|
+
if type_ == "answer" and me == req["to_agent"] and req["type"] in {"question", "review"}:
|
|
457
|
+
req = self._set_status(req, "answered", session)
|
|
458
|
+
status_note = "answered"
|
|
459
|
+
self.conn.execute("UPDATE requests SET updated_at=? WHERE id=?", (now(), rid))
|
|
460
|
+
self._emit("message", "message", mid, self._audience(req), {"type": type_, "from": me, "preview": body.strip().splitlines()[0][:120], "as": bool(as_agent)},
|
|
461
|
+
request_id=rid, agent_id=me, session_id=session["id"])
|
|
462
|
+
return {"message": self._one("SELECT * FROM messages WHERE id=?", mid), "request": self._req(rid), "status": status_note,
|
|
463
|
+
"notify": self._notify_targets([a for a in self._audience(req) if a != me], session["id"])}
|
|
464
|
+
|
|
465
|
+
def _cascade_children(self, parent: dict, session: dict | None, reason: str) -> None:
|
|
466
|
+
for c in self._all("SELECT * FROM requests WHERE parent_id=? AND status NOT IN ('closed','declined','withdrawn','acknowledged','wont_do')", parent["id"]):
|
|
467
|
+
self._set_status(c, "wont_do", session, reason=reason)
|
|
468
|
+
|
|
469
|
+
def set_status(self, session: dict, rid: str, action: str, reason: str | None = None, to: str | None = None) -> dict:
|
|
470
|
+
with self.tx():
|
|
471
|
+
req = self._req(rid)
|
|
472
|
+
me = session["agent_id"]
|
|
473
|
+
reason = clean(reason, LIMITS["reason"], "reason") or None
|
|
474
|
+
is_init, is_rcpt = req["from_agent"] == me, req["to_agent"] == me
|
|
475
|
+
if action == "decline":
|
|
476
|
+
if not is_rcpt:
|
|
477
|
+
raise ServiceError("only the recipient declines", "forbidden", 403)
|
|
478
|
+
if req["status"] not in lc.DECLINE_FROM:
|
|
479
|
+
raise ServiceError(f"cannot decline in status {req['status']}", "bad_transition")
|
|
480
|
+
if not reason:
|
|
481
|
+
raise ServiceError("--reason is required for decline")
|
|
482
|
+
req = self._set_status(req, "declined", session, reason=reason)
|
|
483
|
+
elif action == "withdraw":
|
|
484
|
+
if not is_init:
|
|
485
|
+
raise ServiceError("only the initiator withdraws", "forbidden", 403)
|
|
486
|
+
if req["status"] in lc.TERMINAL:
|
|
487
|
+
raise ServiceError(f"cannot withdraw in status {req['status']}", "bad_transition")
|
|
488
|
+
req = self._set_status(req, "withdrawn", session, reason=reason)
|
|
489
|
+
self._cascade_children(req, session, f"{rid} withdrawn")
|
|
490
|
+
self._unblock_parent(req, session)
|
|
491
|
+
elif action == "close":
|
|
492
|
+
if not is_init and not self._is_po(me):
|
|
493
|
+
raise ServiceError("only the initiator or PO closes", "forbidden", 403)
|
|
494
|
+
if req["status"] in lc.TERMINAL:
|
|
495
|
+
raise ServiceError("already closed", "bad_transition")
|
|
496
|
+
req = self._set_status(req, "closed", session, reason=reason)
|
|
497
|
+
self._cascade_children(req, session, f"{rid} closed")
|
|
498
|
+
self._unblock_parent(req, session)
|
|
499
|
+
elif action == "ack":
|
|
500
|
+
if req["type"] != "notice":
|
|
501
|
+
raise ServiceError("ack is for notices; use `repomoot events ack` for events", "bad_transition")
|
|
502
|
+
if not is_rcpt:
|
|
503
|
+
raise ServiceError("only the recipient acknowledges", "forbidden", 403)
|
|
504
|
+
if req["status"] != "open":
|
|
505
|
+
raise ServiceError("already acknowledged", "bad_transition")
|
|
506
|
+
req = self._set_status(req, "acknowledged", session)
|
|
507
|
+
elif action == "reassign":
|
|
508
|
+
if not is_rcpt:
|
|
509
|
+
raise ServiceError("only the recipient reassigns", "forbidden", 403)
|
|
510
|
+
if req["status"] not in lc.REASSIGN_FROM:
|
|
511
|
+
raise ServiceError(f"cannot reassign in status {req['status']}", "bad_transition")
|
|
512
|
+
if not to:
|
|
513
|
+
raise ServiceError("--to is required")
|
|
514
|
+
self._agent(to)
|
|
515
|
+
if to in (me, req["from_agent"]):
|
|
516
|
+
raise ServiceError("reassign to a third agent")
|
|
517
|
+
old = req["to_agent"]
|
|
518
|
+
self.conn.execute("UPDATE requests SET to_agent=?, claimed_by=NULL, status='open', updated_at=? WHERE id=?", (to, now(), rid))
|
|
519
|
+
req = self._req(rid)
|
|
520
|
+
self._emit("request.reassigned", "request", rid, self._audience(req) + [old], {"from": old, "to": to, "reason": reason},
|
|
521
|
+
request_id=rid, agent_id=me, session_id=session["id"])
|
|
522
|
+
elif action == "wont_do":
|
|
523
|
+
if not self._is_po(me):
|
|
524
|
+
raise ServiceError("only the PO marks wont_do", "forbidden", 403)
|
|
525
|
+
req = self._set_status(req, "wont_do", session, reason=reason)
|
|
526
|
+
self._cascade_children(req, session, f"{rid} wont_do")
|
|
527
|
+
self._unblock_parent(req, session)
|
|
528
|
+
else:
|
|
529
|
+
raise ServiceError(f"unknown action {action}")
|
|
530
|
+
return {"request": req, "notify": self._notify_targets([a for a in self._audience(req) if a != me], session["id"])}
|
|
531
|
+
|
|
532
|
+
def accept_proposal(self, session: dict, rid: str, option: str | None, notes: str | None) -> dict:
|
|
533
|
+
with self.tx():
|
|
534
|
+
req = self._req(rid)
|
|
535
|
+
if req["from_agent"] != session["agent_id"]:
|
|
536
|
+
raise ServiceError("only the initiator accepts a proposal", "forbidden", 403)
|
|
537
|
+
if req["status"] != "proposal":
|
|
538
|
+
raise ServiceError(f"no proposal pending (status {req['status']})", "bad_transition")
|
|
539
|
+
prop = self._one("SELECT * FROM artifacts WHERE request_id=? AND type='proposal' ORDER BY id DESC LIMIT 1", rid)
|
|
540
|
+
opts = (prop or {}).get("data", {}).get("options") or []
|
|
541
|
+
ids = [o.get("id") for o in opts]
|
|
542
|
+
if opts and option and option not in ids:
|
|
543
|
+
raise ServiceError(f"unknown option {option}; available: {ids}")
|
|
544
|
+
if opts and not option:
|
|
545
|
+
if len(opts) > 1:
|
|
546
|
+
raise ServiceError("proposal has several options; pass --option")
|
|
547
|
+
option = ids[0]
|
|
548
|
+
req = self._set_status(req, "implementation", session, {"accepted_option": option}, reason=notes)
|
|
549
|
+
mid = next_id(self.conn, "msg")
|
|
550
|
+
self.conn.execute("INSERT INTO messages(id, request_id, from_agent, from_session, type, body, created_at) VALUES(?,?,?,?,?,?,?)",
|
|
551
|
+
(mid, rid, session["agent_id"], session["id"], "system", f"Proposal accepted: option {option or '-'}. {notes or ''}".strip(), now()))
|
|
552
|
+
return {"request": req, "notify": self._notify_targets([req["to_agent"]], session["id"])}
|
|
553
|
+
|
|
554
|
+
# ------------------------------------------------------------------ PO
|
|
555
|
+
def ask_po(self, session: dict, title: str, body: str, kind: str, options: list, parent_id: str | None, blocking: bool = False,
|
|
556
|
+
route_to: str | None = None) -> dict:
|
|
557
|
+
with self.tx():
|
|
558
|
+
me = session["agent_id"]
|
|
559
|
+
title = clean(title, LIMITS["title"], "title").strip()
|
|
560
|
+
body = clean(body, LIMITS["body"], "body")
|
|
561
|
+
if kind not in lc.KINDS:
|
|
562
|
+
raise ServiceError(f"kind must be one of {', '.join(lc.KINDS)}")
|
|
563
|
+
if len(options) > LIMITS["options"]:
|
|
564
|
+
raise ServiceError("too many options")
|
|
565
|
+
if self._is_po(me):
|
|
566
|
+
raise ServiceError("the PO does not ask itself; decide or delegate", "bad_request")
|
|
567
|
+
po = self._po_id()
|
|
568
|
+
present = self.po_present()
|
|
569
|
+
rid = next_id(self.conn, "req")
|
|
570
|
+
status = "open" if present else "local"
|
|
571
|
+
routed = po if present else (route_to or me)
|
|
572
|
+
self.conn.execute(
|
|
573
|
+
"INSERT INTO requests(id, type, from_agent, to_agent, parent_id, title, body, priority, blocking, status, routed_to, kind, options, created_at, updated_at)"
|
|
574
|
+
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
575
|
+
(rid, "decision", me, po or "po", parent_id, title, body, "high" if blocking else "normal", int(blocking), status, routed, kind, j(options), now(), now()))
|
|
576
|
+
self._fts("request", rid, title, body)
|
|
577
|
+
req = self._req(rid)
|
|
578
|
+
self._emit("request.created", "request", rid, self._audience(req), {"type": "decision", "title": title, "from": me, "kind": kind, "mode": status, "routed_to": routed},
|
|
579
|
+
request_id=rid, agent_id=me, session_id=session["id"])
|
|
580
|
+
if parent_id:
|
|
581
|
+
parent = self._req(parent_id)
|
|
582
|
+
if parent["status"] == "declined":
|
|
583
|
+
self._set_status(parent, "blocked", session, {"resume_status": "discussion"}, reason=f"escalated as {rid}")
|
|
584
|
+
elif parent["status"] not in lc.TERMINAL and parent["status"] != "blocked":
|
|
585
|
+
self._set_status(parent, "blocked", session, {"resume_status": parent["status"]}, reason=f"waiting for {rid}")
|
|
586
|
+
self.conn.execute("INSERT OR IGNORE INTO request_links(request_id, related_id, relation) VALUES(?,?,'depends_on')", (parent_id, rid))
|
|
587
|
+
targets = [po] if (present and po) else ([routed] if routed != me else [])
|
|
588
|
+
return {"request": req, "po_present": present, "mode": status, "routed_to": routed, "notify": self._notify_targets(targets, session["id"])}
|
|
589
|
+
|
|
590
|
+
def handoff(self, session: dict, rid: str) -> dict:
|
|
591
|
+
with self.tx():
|
|
592
|
+
req = self._req(rid)
|
|
593
|
+
if req["type"] != "decision" or req["status"] != "local":
|
|
594
|
+
raise ServiceError("only local decisions can be handed over", "bad_transition")
|
|
595
|
+
if session["agent_id"] not in (req["routed_to"], req["from_agent"]):
|
|
596
|
+
raise ServiceError("not routed to you", "forbidden", 403)
|
|
597
|
+
po = self._po_id() or "po"
|
|
598
|
+
req = self._set_status(req, "open", session, {"routed_to": po, "to_agent": po}, reason="handed over to PO")
|
|
599
|
+
self._emit("decision.handoff", "request", rid, [po, req["from_agent"]], {"title": req["title"]}, request_id=rid, agent_id=session["agent_id"])
|
|
600
|
+
return {"request": req, "po_present": self.po_present(), "notify": self._notify_targets([po], session["id"])}
|
|
601
|
+
|
|
602
|
+
def decide(self, session: dict, rid: str, decision: str, option: str | None, reason: str, affected: list[str],
|
|
603
|
+
author: str = "po", human: bool = False) -> dict:
|
|
604
|
+
with self.tx():
|
|
605
|
+
req = self._req(rid)
|
|
606
|
+
me = session["agent_id"]
|
|
607
|
+
reason = clean(reason, LIMITS["reason"], "reason").strip()
|
|
608
|
+
decision = clean(decision, LIMITS["body"], "decision")
|
|
609
|
+
if req["type"] != "decision":
|
|
610
|
+
raise ServiceError("not a decision request", "bad_transition")
|
|
611
|
+
if req["status"] in lc.TERMINAL:
|
|
612
|
+
raise ServiceError("already decided", "bad_transition")
|
|
613
|
+
if not reason:
|
|
614
|
+
raise ServiceError("--reason is required")
|
|
615
|
+
is_po = self._is_po(me)
|
|
616
|
+
if author == "human":
|
|
617
|
+
if req["status"] != "local" and not (is_po or human):
|
|
618
|
+
raise ServiceError("--author human is allowed only for local decisions, from the PO session, or with the human token", "forbidden", 403)
|
|
619
|
+
if req["status"] == "local" and req["routed_to"] != me and not (is_po or human):
|
|
620
|
+
raise ServiceError(f"this local decision is with {req['routed_to']}", "forbidden", 403)
|
|
621
|
+
parent = self._one("SELECT * FROM requests WHERE id=?", req["parent_id"]) if req["parent_id"] else None
|
|
622
|
+
if parent and parent["to_agent"] == me and not human:
|
|
623
|
+
raise ServiceError("the recipient of the parent request cannot record the human's decision on it; the initiator does", "forbidden", 403)
|
|
624
|
+
attested = human
|
|
625
|
+
elif author == "po":
|
|
626
|
+
if not is_po:
|
|
627
|
+
raise ServiceError("only the PO decides; use --author human for a local decision", "forbidden", 403)
|
|
628
|
+
attested = True
|
|
629
|
+
else:
|
|
630
|
+
raise ServiceError("author must be po|human")
|
|
631
|
+
opts = req.get("options") or []
|
|
632
|
+
if option and opts and option not in [o.get("id") for o in opts]:
|
|
633
|
+
raise ServiceError(f"unknown option {option}; available: {[o.get('id') for o in opts]}")
|
|
634
|
+
aid = next_id(self.conn, "art")
|
|
635
|
+
title = f"Decision on {rid}: {option or decision[:60]}"
|
|
636
|
+
data = {"decision": decision, "option": option, "reason": reason, "affected": affected, "author": author,
|
|
637
|
+
"attested": attested, "via_session": session["id"], "via_agent": me, "request": rid, "kind": req.get("kind")}
|
|
638
|
+
self.conn.execute("INSERT INTO artifacts(id, request_id, type, title, content, data, author_agent, author_session, created_at) VALUES(?,?,?,?,?,?,?,?,?)",
|
|
639
|
+
(aid, rid, "decision", title, f"{decision}\n\nReason: {reason}", j(data), "human" if author == "human" else me, session["id"], now()))
|
|
640
|
+
self._fts("artifact", aid, title, decision + " " + reason)
|
|
641
|
+
req = self._set_status(req, "closed", session, reason=f"decided by {author}{'' if attested else ' (unattested)'}: {option or decision[:40]}")
|
|
642
|
+
aud = self._audience(req) + list(affected)
|
|
643
|
+
self._emit("decision", "artifact", aid, aud, {"request": rid, "option": option, "author": author, "attested": attested, "reason": reason},
|
|
644
|
+
request_id=rid, agent_id=me, session_id=session["id"])
|
|
645
|
+
self._unblock_parent(req, session)
|
|
646
|
+
return {"artifact": self._one("SELECT * FROM artifacts WHERE id=?", aid), "request": req, "attested": attested,
|
|
647
|
+
"notify": self._notify_targets([a for a in aud if a != me], session["id"])}
|
|
648
|
+
|
|
649
|
+
def delegate(self, session: dict, rid: str, reason: str) -> dict:
|
|
650
|
+
with self.tx():
|
|
651
|
+
req = self._req(rid)
|
|
652
|
+
if not self._is_po(session["agent_id"]):
|
|
653
|
+
raise ServiceError("only the PO delegates back", "forbidden", 403)
|
|
654
|
+
if req["type"] != "decision" or req["status"] in lc.TERMINAL:
|
|
655
|
+
raise ServiceError("not an open decision", "bad_transition")
|
|
656
|
+
reason = clean(reason, LIMITS["reason"], "reason")
|
|
657
|
+
req = self._set_status(req, "closed", session, reason=f"delegated back: {reason}")
|
|
658
|
+
self._unblock_parent(req, session)
|
|
659
|
+
return {"request": req, "notify": self._notify_targets([req["from_agent"]], session["id"])}
|
|
660
|
+
|
|
661
|
+
def _unblock_parent(self, child: dict, session: dict | None) -> None:
|
|
662
|
+
if not child.get("parent_id"):
|
|
663
|
+
return
|
|
664
|
+
parent = self._one("SELECT * FROM requests WHERE id=?", child["parent_id"])
|
|
665
|
+
if not parent or parent["status"] != "blocked":
|
|
666
|
+
return
|
|
667
|
+
if self._all("SELECT id FROM requests WHERE parent_id=? AND status NOT IN ('closed','declined','withdrawn','acknowledged','wont_do')", parent["id"]):
|
|
668
|
+
return
|
|
669
|
+
self._set_status(parent, parent.get("resume_status") or "discussion", session, {"resume_status": None}, reason=f"{child['id']} resolved")
|
|
670
|
+
|
|
671
|
+
def escalate(self, session: dict, rid: str, question: str, kind: str, options: list) -> dict:
|
|
672
|
+
req = self._req(rid)
|
|
673
|
+
if session["agent_id"] not in (req["from_agent"], req["to_agent"]):
|
|
674
|
+
raise ServiceError("not a party of this request", "forbidden", 403)
|
|
675
|
+
if req["status"] in lc.TERMINAL - {"declined"}:
|
|
676
|
+
raise ServiceError(f"cannot escalate a {req['status']} request", "bad_transition")
|
|
677
|
+
return self.ask_po(session, f"Escalation of {rid}: {req['title']}", question, kind, options, rid, blocking=req["blocking"])
|
|
678
|
+
|
|
679
|
+
def create_task(self, session: dict, to: str, title: str, body: str, depends_on: list[str], due_at: str | None) -> dict:
|
|
680
|
+
with self.tx():
|
|
681
|
+
res = self.create_request(session, to, "task", title, body, due_at=due_at)
|
|
682
|
+
for d in depends_on:
|
|
683
|
+
self._req(d)
|
|
684
|
+
self.conn.execute("INSERT OR IGNORE INTO request_links(request_id, related_id, relation) VALUES(?,?,'depends_on')", (res["request"]["id"], d))
|
|
685
|
+
return res
|
|
686
|
+
|
|
687
|
+
def dashboard(self, po: str) -> dict:
|
|
688
|
+
open_ = self.list_requests("*", "all", limit=500)
|
|
689
|
+
decisions = [r for r in open_ if r["type"] == "decision" and r["status"] != "local"]
|
|
690
|
+
for r in decisions:
|
|
691
|
+
if self._one("SELECT id FROM events WHERE request_id=? AND type='decision.handoff'", r["id"]):
|
|
692
|
+
r["origin"] = "handed_over"
|
|
693
|
+
elif r["title"].startswith("Approve proposal"):
|
|
694
|
+
r["origin"] = "requires_po"
|
|
695
|
+
else:
|
|
696
|
+
r["origin"] = "escalation" if r["parent_id"] else "question"
|
|
697
|
+
local = [r for r in open_ if r["type"] == "decision" and r["status"] == "local"]
|
|
698
|
+
blocking = [r for r in open_ if r["blocking"] and r["type"] != "decision"]
|
|
699
|
+
stale_cut = (parse_ts(now()) - timedelta(hours=config.STALE_HOURS)).isoformat().replace("+00:00", "Z")
|
|
700
|
+
stale = [r for r in open_ if r["updated_at"] < stale_cut and r["status"] != "blocked"]
|
|
701
|
+
notices = [r for r in open_ if r["type"] == "notice"]
|
|
702
|
+
agreements = self._all("SELECT * FROM agreements WHERE status='active'")
|
|
703
|
+
pending_ack = [a for a in agreements if set(a["parties"]) - set(a["acknowledged"])]
|
|
704
|
+
unattested = self._all("SELECT id, request_id, data, created_at FROM artifacts WHERE type='decision' AND json_extract(data,'$.attested')=0 ORDER BY id DESC LIMIT 20")
|
|
705
|
+
return {"decisions": decisions, "local_decisions": local, "blocking": blocking, "stale": stale, "notices_unacked": notices,
|
|
706
|
+
"open": [r for r in open_ if r["type"] not in ("decision", "notice") and not r["blocking"]], "agreements_pending_ack": pending_ack,
|
|
707
|
+
"unattested_decisions": unattested, "agents": self.list_agents(), "policy": self.get_policy(po)}
|
|
708
|
+
|
|
709
|
+
def overview(self, limit: int = 200) -> dict:
|
|
710
|
+
"""Everything the UI needs in one call: agents, requests (incl. closed) with artifact and message summaries."""
|
|
711
|
+
reqs = self.list_requests("*", "all", limit=limit, open_only=False)
|
|
712
|
+
for r in reqs:
|
|
713
|
+
r["artifacts"] = self._all("SELECT id, type, title, author_agent, data, created_at FROM artifacts WHERE request_id=? ORDER BY id", r["id"])
|
|
714
|
+
row = self.conn.execute("SELECT COUNT(*), MAX(id) FROM messages WHERE request_id=? AND type!='system'", (r["id"],)).fetchone()
|
|
715
|
+
r["messages_count"] = row[0]
|
|
716
|
+
r["last_message_from"] = self._last_message_from(r["id"])
|
|
717
|
+
events = self._all("SELECT id, type, request_id, agent_id, created_at FROM events ORDER BY id DESC LIMIT 10")[::-1]
|
|
718
|
+
return {"agents": self.list_agents(), "requests": reqs, "recent_events": events, "po_present": self.po_present()}
|
|
719
|
+
|
|
720
|
+
def get_policy(self, po: str) -> dict:
|
|
721
|
+
p = self._one("SELECT policy FROM policies WHERE agent_id=?", po)
|
|
722
|
+
return p["policy"] if p else {"mode": "hybrid", "auto_decide": ["priority", "clarification"],
|
|
723
|
+
"always_ask_human": ["breaking_change", "scope_change", "conflict", "budget"], "default": "ask_human"}
|
|
724
|
+
|
|
725
|
+
def set_policy(self, session: dict, policy: dict, human: bool) -> dict:
|
|
726
|
+
with self.tx():
|
|
727
|
+
if not human:
|
|
728
|
+
raise ServiceError("policy changes need the human token (run from your terminal)", "forbidden", 403)
|
|
729
|
+
if not self._is_po(session["agent_id"]):
|
|
730
|
+
raise ServiceError("policy belongs to the PO agent", "forbidden", 403)
|
|
731
|
+
self.conn.execute("INSERT OR REPLACE INTO policies(agent_id, policy, updated_at) VALUES(?,?,?)", (session["agent_id"], j(policy), now()))
|
|
732
|
+
return policy
|
|
733
|
+
|
|
734
|
+
# ------------------------------------------------------------------ artifacts & agreements
|
|
735
|
+
def publish_artifact(self, session: dict, type_: str, title: str, content: str, data: dict | None, request_id: str | None,
|
|
736
|
+
supersedes: str | None, verdict: str | None, requires_po: bool, po_question: str | None) -> dict:
|
|
737
|
+
with self.tx():
|
|
738
|
+
me = session["agent_id"]
|
|
739
|
+
if type_ not in lc.ARTIFACT_TYPES:
|
|
740
|
+
raise ServiceError(f"artifact type must be one of {', '.join(lc.ARTIFACT_TYPES)}")
|
|
741
|
+
title = clean(title, LIMITS["title"], "title").strip()
|
|
742
|
+
content = clean(content, LIMITS["content"], "content")
|
|
743
|
+
if not title:
|
|
744
|
+
raise ServiceError("title is required")
|
|
745
|
+
data = dict(data or {})
|
|
746
|
+
req = self._req(request_id) if request_id else None
|
|
747
|
+
if type_ in {"proposal", "delivery", "evaluation"} and not req:
|
|
748
|
+
raise ServiceError(f"{type_} needs --request")
|
|
749
|
+
if req:
|
|
750
|
+
if type_ in {"proposal", "delivery"} and me != req["to_agent"]:
|
|
751
|
+
raise ServiceError(f"only the recipient ({req['to_agent']}) publishes a {type_}", "forbidden", 403)
|
|
752
|
+
if type_ == "evaluation" and me != req["from_agent"] and self._agent(me)["kind"] != "observer":
|
|
753
|
+
raise ServiceError(f"only the initiator ({req['from_agent']}) or an observer publishes an evaluation", "forbidden", 403)
|
|
754
|
+
if type_ == "proposal":
|
|
755
|
+
if req["status"] not in lc.PROPOSAL_FROM:
|
|
756
|
+
raise ServiceError(f"cannot propose in status {req['status']}", "bad_transition")
|
|
757
|
+
if len(data.get("options") or []) > LIMITS["options"]:
|
|
758
|
+
raise ServiceError("too many options")
|
|
759
|
+
if not self._claim_free(req, session):
|
|
760
|
+
raise ServiceError("request is claimed by another live session", "conflict", 409)
|
|
761
|
+
if type_ == "delivery":
|
|
762
|
+
allowed = lc.DELIVERY_FROM.get(req["type"], set())
|
|
763
|
+
if req["status"] not in allowed:
|
|
764
|
+
raise ServiceError(f"cannot deliver a {req['type']} in status {req['status']} (allowed: {', '.join(sorted(allowed)) or 'none'})", "bad_transition")
|
|
765
|
+
repo = data.get("repo")
|
|
766
|
+
if not repo:
|
|
767
|
+
raise ServiceError("delivery data needs repo")
|
|
768
|
+
if not self.check_permission(me, repo, "write")["allowed"]:
|
|
769
|
+
raise ServiceError(f"{me} has no write permission on repo '{repo}'", "forbidden", 403)
|
|
770
|
+
if not data.get("commit") and not data.get("branch"):
|
|
771
|
+
raise ServiceError("delivery data needs commit or branch")
|
|
772
|
+
if "tests" not in data:
|
|
773
|
+
raise ServiceError("delivery data needs tests (what you ran and the result)")
|
|
774
|
+
if type_ == "evaluation" and req["status"] != "evaluation":
|
|
775
|
+
raise ServiceError(f"nothing to evaluate in status {req['status']}", "bad_transition")
|
|
776
|
+
if type_ == "evaluation":
|
|
777
|
+
if verdict not in {"accepted", "rejected"}:
|
|
778
|
+
raise ServiceError("--verdict accepted|rejected is required")
|
|
779
|
+
data["verdict"] = verdict
|
|
780
|
+
aid = next_id(self.conn, "art")
|
|
781
|
+
version = 1
|
|
782
|
+
if supersedes:
|
|
783
|
+
prev = self._one("SELECT version FROM artifacts WHERE id=?", supersedes)
|
|
784
|
+
version = (prev["version"] + 1) if prev else 1
|
|
785
|
+
self.conn.execute("INSERT INTO artifacts(id, request_id, type, title, content, data, author_agent, author_session, supersedes, version, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
|
786
|
+
(aid, request_id, type_, title, content, j(data), me, session["id"], supersedes, version, now()))
|
|
787
|
+
self._fts("artifact", aid, title, content)
|
|
788
|
+
out: dict = {"artifact": self._one("SELECT * FROM artifacts WHERE id=?", aid), "notify": []}
|
|
789
|
+
if req:
|
|
790
|
+
new_status = lc.status_after_artifact(type_, verdict) if type_ in {"proposal", "delivery", "evaluation"} else None
|
|
791
|
+
extra = {}
|
|
792
|
+
if type_ == "evaluation" and verdict == "rejected":
|
|
793
|
+
extra["iteration"] = req["iteration"] + 1
|
|
794
|
+
if new_status:
|
|
795
|
+
req = self._set_status(req, new_status, session, extra, reason=f"{type_} {aid}")
|
|
796
|
+
self._emit("artifact", "artifact", aid, self._audience(req) + ([self._po_id()] if type_ == "evaluation" and self._po_id() else []),
|
|
797
|
+
{"type": type_, "title": title, "request": request_id, "verdict": verdict}, request_id=request_id, agent_id=me, session_id=session["id"])
|
|
798
|
+
if type_ == "evaluation" and verdict == "rejected" and req["iteration"] >= 3 and self._po_id():
|
|
799
|
+
self._emit("request.repeated_reject", "request", req["id"], [self._po_id()], {"iteration": req["iteration"]}, request_id=req["id"])
|
|
800
|
+
if type_ == "proposal" and requires_po:
|
|
801
|
+
opts = [{"id": o.get("id"), "summary": o.get("summary")} for o in data.get("options", [])]
|
|
802
|
+
kind = "breaking_change" if any("break" in str(o.get("compat", "")).lower() for o in data.get("options", [])) else "scope_change"
|
|
803
|
+
# Routed to the request's initiator (the consumer), never to the proposal's author.
|
|
804
|
+
dec = self.ask_po(session, f"Approve proposal for {req['id']}: {title}", po_question or content, kind, opts, req["id"],
|
|
805
|
+
blocking=req["blocking"], route_to=req["from_agent"])
|
|
806
|
+
out.update({"decision": dec["request"], "po_present": dec["po_present"], "mode": dec["mode"], "routed_to": dec["routed_to"]})
|
|
807
|
+
out["notify"] += dec["notify"]
|
|
808
|
+
out["request"] = self._req(req["id"])
|
|
809
|
+
out["notify"] += self._notify_targets([a for a in self._audience(req) if a != me], session["id"])
|
|
810
|
+
else:
|
|
811
|
+
self._emit("artifact", "artifact", aid, [me], {"type": type_, "title": title}, agent_id=me, session_id=session["id"])
|
|
812
|
+
return out
|
|
813
|
+
|
|
814
|
+
def get_artifact(self, aid: str) -> dict:
|
|
815
|
+
a = self._one("SELECT * FROM artifacts WHERE id=?", aid)
|
|
816
|
+
if not a:
|
|
817
|
+
raise ServiceError(f"{aid} not found", "not_found", 404)
|
|
818
|
+
return a
|
|
819
|
+
|
|
820
|
+
def list_artifacts(self, type_: str | None, request_id: str | None, agent: str | None, limit: int = 50) -> list[dict]:
|
|
821
|
+
where, args = [], []
|
|
822
|
+
if type_:
|
|
823
|
+
where.append("type=?"); args.append(type_)
|
|
824
|
+
if request_id:
|
|
825
|
+
where.append("request_id=?"); args.append(request_id)
|
|
826
|
+
if agent:
|
|
827
|
+
where.append("author_agent=?"); args.append(agent)
|
|
828
|
+
return self._all("SELECT id, request_id, type, title, author_agent, version, created_at FROM artifacts" + (" WHERE " + " AND ".join(where) if where else "") + " ORDER BY id DESC LIMIT ?", *args, min(limit, 500))
|
|
829
|
+
|
|
830
|
+
def publish_agreement(self, session: dict, title: str, content: str, parties: list[str], supersedes: str | None) -> dict:
|
|
831
|
+
with self.tx():
|
|
832
|
+
me = session["agent_id"]
|
|
833
|
+
title = clean(title, LIMITS["title"], "title").strip()
|
|
834
|
+
content = clean(content, LIMITS["content"], "content")
|
|
835
|
+
for p in parties:
|
|
836
|
+
self._agent(p)
|
|
837
|
+
if not parties:
|
|
838
|
+
raise ServiceError("--parties is required")
|
|
839
|
+
if not self._is_po(me) and me not in parties:
|
|
840
|
+
raise ServiceError("an agreement is published by the PO or by one of its parties", "forbidden", 403)
|
|
841
|
+
aid = next_id(self.conn, "art")
|
|
842
|
+
gid = next_id(self.conn, "agr")
|
|
843
|
+
self.conn.execute("INSERT INTO artifacts(id, type, title, content, data, author_agent, author_session, created_at) VALUES(?,?,?,?,?,?,?,?)",
|
|
844
|
+
(aid, "agreement", title, content, j({"parties": parties, "agreement": gid}), me, session["id"], now()))
|
|
845
|
+
ack = [me] if me in parties else []
|
|
846
|
+
self.conn.execute("INSERT INTO agreements(id, artifact_id, title, parties, status, acknowledged, created_at) VALUES(?,?,?,?,?,?,?)",
|
|
847
|
+
(gid, aid, title, j(parties), "active", j(ack), now()))
|
|
848
|
+
if supersedes:
|
|
849
|
+
self.conn.execute("UPDATE agreements SET status='superseded', superseded_by=? WHERE id=?", (gid, supersedes))
|
|
850
|
+
self._fts("agreement", gid, title, content)
|
|
851
|
+
self._emit("agreement", "agreement", gid, parties + [me], {"title": title, "parties": parties}, agent_id=me, session_id=session["id"])
|
|
852
|
+
return {"agreement": self._one("SELECT * FROM agreements WHERE id=?", gid), "artifact_id": aid,
|
|
853
|
+
"notify": self._notify_targets([p for p in parties if p != me], session["id"])}
|
|
854
|
+
|
|
855
|
+
def ack_agreement(self, session: dict, gid: str) -> dict:
|
|
856
|
+
with self.tx():
|
|
857
|
+
g = self._one("SELECT * FROM agreements WHERE id=?", gid)
|
|
858
|
+
if not g:
|
|
859
|
+
raise ServiceError(f"{gid} not found", "not_found", 404)
|
|
860
|
+
me = session["agent_id"]
|
|
861
|
+
if me not in g["parties"]:
|
|
862
|
+
raise ServiceError("not a party", "forbidden", 403)
|
|
863
|
+
ack = sorted(set(g["acknowledged"]) | {me})
|
|
864
|
+
self.conn.execute("UPDATE agreements SET acknowledged=? WHERE id=?", (j(ack), gid))
|
|
865
|
+
self._emit("agreement.ack", "agreement", gid, g["parties"], {"by": me}, agent_id=me)
|
|
866
|
+
return self._one("SELECT * FROM agreements WHERE id=?", gid)
|
|
867
|
+
|
|
868
|
+
def list_agreements(self, party: str | None = None, all_: bool = False) -> list[dict]:
|
|
869
|
+
rows = self._all("SELECT a.*, r.content FROM agreements a JOIN artifacts r ON r.id=a.artifact_id" + ("" if all_ else " WHERE a.status='active'") + " ORDER BY a.id")
|
|
870
|
+
return [r for r in rows if not party or party in r["parties"]]
|
|
871
|
+
|
|
872
|
+
# ------------------------------------------------------------------ inbox & events
|
|
873
|
+
def inbox(self, session: dict) -> dict:
|
|
874
|
+
agent_id = session["agent_id"]
|
|
875
|
+
rows = self.list_requests(agent_id, "all", limit=200)
|
|
876
|
+
groups: dict = {"blocking": [], "awaiting_me": [], "in_progress": [], "waiting_on_others": [], "fyi": []}
|
|
877
|
+
for r in rows:
|
|
878
|
+
mine_to_act = r["actor"] == agent_id
|
|
879
|
+
if r["type"] == "notice":
|
|
880
|
+
(groups["awaiting_me"] if r["to_agent"] == agent_id and r["status"] == "open" else groups["fyi"]).append(r)
|
|
881
|
+
elif r["status"] == "local" and r["routed_to"] == agent_id:
|
|
882
|
+
groups["awaiting_me"].append(r)
|
|
883
|
+
elif mine_to_act and r["blocking"]:
|
|
884
|
+
groups["blocking"].append(r)
|
|
885
|
+
elif mine_to_act:
|
|
886
|
+
groups["awaiting_me"].append(r)
|
|
887
|
+
elif r["to_agent"] == agent_id and r["status"] != "open":
|
|
888
|
+
groups["in_progress"].append(r)
|
|
889
|
+
elif r["from_agent"] == agent_id:
|
|
890
|
+
groups["waiting_on_others"].append(r)
|
|
891
|
+
else:
|
|
892
|
+
groups["fyi"].append(r)
|
|
893
|
+
groups["unread_events"] = self.conn.execute(
|
|
894
|
+
"SELECT COUNT(*) FROM events e JOIN event_audience a ON a.event_id=e.id WHERE a.agent_id=? AND e.id>? AND (e.session_id IS NULL OR e.session_id!=?)"
|
|
895
|
+
" AND NOT EXISTS(SELECT 1 FROM acks k WHERE k.session_id=? AND k.event_id=e.id)",
|
|
896
|
+
(agent_id, session["event_cursor"], session["id"], session["id"])).fetchone()[0]
|
|
897
|
+
groups["po_present"] = self.po_present()
|
|
898
|
+
return groups
|
|
899
|
+
|
|
900
|
+
def events(self, session: dict, since: int | None = None, request_id: str | None = None, limit: int = 100, include_own: bool = False,
|
|
901
|
+
advance: bool = True) -> dict:
|
|
902
|
+
"""Events for this session's agent after `since` (default: the session cursor). Advances the cursor unless filtered."""
|
|
903
|
+
cur = session["event_cursor"] if since is None else since
|
|
904
|
+
sql = "SELECT e.* FROM events e JOIN event_audience a ON a.event_id=e.id WHERE a.agent_id=? AND e.id>?"
|
|
905
|
+
args: list = [session["agent_id"], cur]
|
|
906
|
+
if request_id:
|
|
907
|
+
sql += " AND e.request_id=?"; args.append(request_id)
|
|
908
|
+
if not include_own:
|
|
909
|
+
sql += " AND (e.session_id IS NULL OR e.session_id!=?)"; args.append(session["id"])
|
|
910
|
+
sql += " AND NOT EXISTS(SELECT 1 FROM acks k WHERE k.session_id=? AND k.event_id=e.id)"; args.append(session["id"])
|
|
911
|
+
sql += " ORDER BY e.id LIMIT ?"; args.append(min(limit, 500))
|
|
912
|
+
rows = self._all(sql, *args)
|
|
913
|
+
new_cursor = rows[-1]["id"] if rows else cur
|
|
914
|
+
if rows and advance and since is None:
|
|
915
|
+
with self.tx():
|
|
916
|
+
if request_id:
|
|
917
|
+
# A request-filtered read must not skip other requests' events: mark just these as read.
|
|
918
|
+
for r in rows:
|
|
919
|
+
self.conn.execute("INSERT OR IGNORE INTO acks(session_id, event_id) VALUES(?,?)", (session["id"], r["id"]))
|
|
920
|
+
else:
|
|
921
|
+
self.conn.execute("UPDATE sessions SET event_cursor=MAX(event_cursor, ?) WHERE id=?", (new_cursor, session["id"]))
|
|
922
|
+
return {"events": rows, "cursor": new_cursor}
|
|
923
|
+
|
|
924
|
+
def last_event_id(self) -> int:
|
|
925
|
+
return int(self.conn.execute("SELECT COALESCE(MAX(id),0) FROM events").fetchone()[0])
|
|
926
|
+
|
|
927
|
+
def ack_events(self, session: dict, event_ids: list[int] | None, request_id: str | None, all_: bool) -> int:
|
|
928
|
+
with self.tx():
|
|
929
|
+
if all_:
|
|
930
|
+
self.conn.execute("UPDATE sessions SET event_cursor=? WHERE id=?", (self.last_event_id(), session["id"]))
|
|
931
|
+
event_ids = [r[0] for r in self.conn.execute("SELECT e.id FROM events e JOIN event_audience a ON a.event_id=e.id WHERE a.agent_id=?", (session["agent_id"],)).fetchall()]
|
|
932
|
+
elif request_id:
|
|
933
|
+
event_ids = [r[0] for r in self.conn.execute("SELECT e.id FROM events e JOIN event_audience a ON a.event_id=e.id WHERE a.agent_id=? AND e.request_id=?",
|
|
934
|
+
(session["agent_id"], request_id)).fetchall()]
|
|
935
|
+
n = 0
|
|
936
|
+
for e in event_ids or []:
|
|
937
|
+
self.conn.execute("INSERT OR IGNORE INTO acks(session_id, event_id) VALUES(?,?)", (session["id"], int(e)))
|
|
938
|
+
n += 1
|
|
939
|
+
return n
|
|
940
|
+
|
|
941
|
+
# ------------------------------------------------------------------ misc
|
|
942
|
+
def check_permission(self, agent_id: str, repo: str, op: str) -> dict:
|
|
943
|
+
a = self._agent(agent_id)
|
|
944
|
+
if a["kind"] == "product_owner":
|
|
945
|
+
return {"allowed": op == "read", "source": "po (read-only)"}
|
|
946
|
+
target = self._one("SELECT * FROM agents WHERE id=?", repo)
|
|
947
|
+
candidates = [repo] + (target["repos"] if target else [])
|
|
948
|
+
try:
|
|
949
|
+
candidates.append(_norm(repo))
|
|
950
|
+
except OSError:
|
|
951
|
+
pass
|
|
952
|
+
for c in candidates:
|
|
953
|
+
p = self._one("SELECT * FROM permissions WHERE agent_id=? AND repo=?", agent_id, c)
|
|
954
|
+
if p:
|
|
955
|
+
return {"allowed": bool(p["write"] if op == "write" else p["read"]), "source": p["source"]}
|
|
956
|
+
owner = target["id"] if target else self.agent_for_cwd(repo)
|
|
957
|
+
if owner == agent_id:
|
|
958
|
+
return {"allowed": True, "source": "owner"}
|
|
959
|
+
return {"allowed": op == "read", "source": "default (read-only on other repos; advisory — the runtime enforces)"}
|
|
960
|
+
|
|
961
|
+
def resolve(self, ref: str) -> dict:
|
|
962
|
+
m = REF_RE.fullmatch(ref.strip())
|
|
963
|
+
if not m:
|
|
964
|
+
return {"ref": ref, "known": False}
|
|
965
|
+
repo, commit, path, ls, le = m.groups()
|
|
966
|
+
a = self._one("SELECT * FROM agents WHERE id=?", repo)
|
|
967
|
+
out = {"ref": ref, "repo": repo, "commit": commit, "path": path, "line_start": int(ls) if ls else None, "line_end": int(le) if le else None, "known": bool(a)}
|
|
968
|
+
if a and a["repos"]:
|
|
969
|
+
root = Path(a["repos"][0])
|
|
970
|
+
out["root"] = str(root)
|
|
971
|
+
if path:
|
|
972
|
+
fp = (root / path).resolve()
|
|
973
|
+
if not str(fp).lower().startswith(str(root.resolve()).lower()):
|
|
974
|
+
return {**out, "error": "path escapes the repository"}
|
|
975
|
+
out["abs_path"] = str(fp); out["exists"] = fp.exists()
|
|
976
|
+
return out
|
|
977
|
+
|
|
978
|
+
def extract_refs(self, text: str) -> list[dict]:
|
|
979
|
+
known = {a["id"] for a in self._all("SELECT id FROM agents")}
|
|
980
|
+
return [{"raw": m.group(0), "repo": m.group(1), "commit": m.group(2), "path": m.group(3),
|
|
981
|
+
"line_start": int(m.group(4)) if m.group(4) else None, "line_end": int(m.group(5)) if m.group(5) else None}
|
|
982
|
+
for m in REF_RE.finditer(text or "") if m.group(1) in known]
|
|
983
|
+
|
|
984
|
+
def search(self, query: str, scope: str = "all", limit: int = 20) -> list[dict]:
|
|
985
|
+
sql = "SELECT entity_type, entity_id, title, snippet(fts, 3, '[', ']', '…', 12) AS snippet FROM fts WHERE fts MATCH ?"
|
|
986
|
+
args: list = [query]
|
|
987
|
+
if scope != "all":
|
|
988
|
+
sql += " AND entity_type=?"; args.append(scope.rstrip("s"))
|
|
989
|
+
sql += " ORDER BY rank LIMIT ?"; args.append(min(limit, 100))
|
|
990
|
+
try:
|
|
991
|
+
return self._all(sql, *args)
|
|
992
|
+
except sqlite3.OperationalError as e:
|
|
993
|
+
raise ServiceError(f"bad query: {e}", "bad_request")
|
|
994
|
+
|
|
995
|
+
def tick(self) -> dict:
|
|
996
|
+
"""Timers: expire sessions, promote local decisions to the PO."""
|
|
997
|
+
with self.tx():
|
|
998
|
+
cutoff = (parse_ts(now()) - timedelta(minutes=config.SESSION_IDLE_MINUTES)).isoformat().replace("+00:00", "Z")
|
|
999
|
+
gone = self.conn.execute("UPDATE sessions SET status='gone', ended_at=? WHERE status!='gone' AND last_seen_at<?", (now(), cutoff)).rowcount
|
|
1000
|
+
self.conn.execute("UPDATE requests SET claimed_by=NULL WHERE claimed_by IN (SELECT id FROM sessions WHERE status='gone')")
|
|
1001
|
+
promoted = 0
|
|
1002
|
+
lcut = (parse_ts(now()) - timedelta(minutes=config.local_timeout_minutes())).isoformat().replace("+00:00", "Z")
|
|
1003
|
+
po = self._po_id() or "po"
|
|
1004
|
+
for r in self._all("SELECT * FROM requests WHERE type='decision' AND status='local' AND updated_at<?", lcut):
|
|
1005
|
+
self._set_status(r, "open", None, {"routed_to": po, "to_agent": po}, reason="local timeout; routed to PO")
|
|
1006
|
+
promoted += 1
|
|
1007
|
+
return {"sessions_expired": gone, "decisions_promoted": promoted}
|