flowstatemcp 2.5.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.
- flowstate/__init__.py +34 -0
- flowstate/__main__.py +7 -0
- flowstate/activity.py +224 -0
- flowstate/agent_guide.py +251 -0
- flowstate/app_keys.py +141 -0
- flowstate/approvals.py +974 -0
- flowstate/approve_page.py +565 -0
- flowstate/ask.py +107 -0
- flowstate/autosession.py +480 -0
- flowstate/backup.py +201 -0
- flowstate/brief.py +420 -0
- flowstate/card_offer.py +208 -0
- flowstate/card_taps.py +165 -0
- flowstate/card_trust.py +127 -0
- flowstate/claims.py +461 -0
- flowstate/cleanup.py +619 -0
- flowstate/cli.py +728 -0
- flowstate/cli_data.py +434 -0
- flowstate/cli_more.py +302 -0
- flowstate/cli_review.py +459 -0
- flowstate/cloudflared_check.py +49 -0
- flowstate/conflict_card_ui.py +286 -0
- flowstate/conflict_cards.py +1193 -0
- flowstate/conflict_reopen.py +320 -0
- flowstate/conflicts.py +420 -0
- flowstate/connections.py +33 -0
- flowstate/database.py +2191 -0
- flowstate/dbconn.py +308 -0
- flowstate/deletes.py +860 -0
- flowstate/embeddings.py +354 -0
- flowstate/erase.py +1047 -0
- flowstate/extras.py +71 -0
- flowstate/git_capture.py +157 -0
- flowstate/guard.py +224 -0
- flowstate/handoffs.py +168 -0
- flowstate/history.py +755 -0
- flowstate/history_page.py +276 -0
- flowstate/http_app.py +204 -0
- flowstate/identity.py +387 -0
- flowstate/install_name.py +140 -0
- flowstate/ledger.py +272 -0
- flowstate/legacy_args.py +65 -0
- flowstate/links.py +609 -0
- flowstate/markdown_io.py +242 -0
- flowstate/meaning.py +398 -0
- flowstate/memory_import.py +92 -0
- flowstate/migrations/001_auto_sessions.sql +16 -0
- flowstate/migrations/002_review_queue.sql +33 -0
- flowstate/migrations/003_links.sql +47 -0
- flowstate/migrations/004_review_mode.sql +24 -0
- flowstate/migrations/005_history.sql +21 -0
- flowstate/migrations/006_activity.sql +26 -0
- flowstate/migrations/007_meaning_index.sql +16 -0
- flowstate/migrations/008_git_capture.sql +12 -0
- flowstate/migrations/009_hard_rules.sql +13 -0
- flowstate/migrations/010_handoffs.sql +14 -0
- flowstate/migrations/011_record_meta.sql +13 -0
- flowstate/migrations/012_accountability.sql +71 -0
- flowstate/migrations/013_todo_claims.sql +36 -0
- flowstate/migrations/014_decision_conflicts.sql +41 -0
- flowstate/migrations/015_session_runs.sql +17 -0
- flowstate/migrations/016_write_receipts.sql +35 -0
- flowstate/migrations/017_run_views.sql +26 -0
- flowstate/migrations/018_run_views_upto.sql +4 -0
- flowstate/migrations/019_approvals.sql +47 -0
- flowstate/migrations/020_source_stamps.sql +10 -0
- flowstate/migrations/021_review_links.sql +11 -0
- flowstate/migrations/022_review_start_tickets.sql +10 -0
- flowstate/migrations/023_conflict_cards.sql +68 -0
- flowstate/migrations/024_rule_confirmations.sql +7 -0
- flowstate/migrations/025_confirmed_hash.sql +7 -0
- flowstate/migrations/__init__.py +149 -0
- flowstate/models.py +307 -0
- flowstate/names.py +157 -0
- flowstate/offsite.py +386 -0
- flowstate/offsite_crypto.py +272 -0
- flowstate/paths.py +265 -0
- flowstate/popup.py +238 -0
- flowstate/priority.py +66 -0
- flowstate/privatefs.py +476 -0
- flowstate/project_history.py +710 -0
- flowstate/provenance.py +696 -0
- flowstate/receipts.py +377 -0
- flowstate/relay/__init__.py +72 -0
- flowstate/relay/acme.py +271 -0
- flowstate/relay/api.py +164 -0
- flowstate/relay/cli.py +344 -0
- flowstate/relay/crypto.py +137 -0
- flowstate/relay/ct.py +118 -0
- flowstate/relay/runner.py +177 -0
- flowstate/relay/store.py +191 -0
- flowstate/relay/tunnel.py +413 -0
- flowstate/release_signers +14 -0
- flowstate/remote_auth.py +1219 -0
- flowstate/review.py +320 -0
- flowstate/review_apply.py +140 -0
- flowstate/review_card_ui.py +241 -0
- flowstate/review_cards.py +564 -0
- flowstate/review_mode.py +274 -0
- flowstate/review_page.py +1361 -0
- flowstate/review_phone.py +465 -0
- flowstate/review_tunnel.py +338 -0
- flowstate/routing.py +392 -0
- flowstate/rules.py +418 -0
- flowstate/safety.py +98 -0
- flowstate/saves.py +83 -0
- flowstate/scheduler.py +160 -0
- flowstate/schema.sql +713 -0
- flowstate/search.py +355 -0
- flowstate/secret_store.py +271 -0
- flowstate/secrets_scan.py +135 -0
- flowstate/selfupdate.py +1020 -0
- flowstate/server.py +1299 -0
- flowstate/session_contents.py +382 -0
- flowstate/session_records.py +629 -0
- flowstate/site_style.py +46 -0
- flowstate/stored.py +158 -0
- flowstate/story.py +442 -0
- flowstate/surface.py +173 -0
- flowstate/tasks.py +231 -0
- flowstate/term.py +40 -0
- flowstate/tool_labels.py +104 -0
- flowstate/tooldefs.py +383 -0
- flowstate/tools/__init__.py +118 -0
- flowstate/tools/core.py +545 -0
- flowstate/tools/files.py +416 -0
- flowstate/tools/git_ops.py +1081 -0
- flowstate/tools/intelligence.py +376 -0
- flowstate/tools/learning.py +282 -0
- flowstate/tools/problems.py +159 -0
- flowstate/tools/sessions.py +266 -0
- flowstate/tools/story.py +48 -0
- flowstate/tools/todos.py +79 -0
- flowstate/tools/utils.py +71 -0
- flowstate/tools/variables.py +345 -0
- flowstate/updates.py +482 -0
- flowstate/urls.py +7 -0
- flowstate/waiting.py +828 -0
- flowstate/who.py +302 -0
- flowstatemcp-2.5.0.dist-info/METADATA +88 -0
- flowstatemcp-2.5.0.dist-info/RECORD +143 -0
- flowstatemcp-2.5.0.dist-info/WHEEL +4 -0
- flowstatemcp-2.5.0.dist-info/entry_points.txt +3 -0
flowstate/claims.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""To-do claims with an expiry (2.3.0, migration 013).
|
|
2
|
+
|
|
3
|
+
One database serves every agent, so two agents starting at once can both pick the same
|
|
4
|
+
"next" todo. A claim says "I'm on this until <time>": the brief, `next` and `list` show it,
|
|
5
|
+
so the second agent picks something else instead of doing the same work silently.
|
|
6
|
+
|
|
7
|
+
- claim: one conditional UPSERT decides, inside one write transaction. It succeeds when the
|
|
8
|
+
todo is unclaimed, its claim has expired, or the same claimer holds it (then it renews).
|
|
9
|
+
- The same claimer = same app, machine and session (identity.py; the session is this server
|
|
10
|
+
process for stdio, the MCP session id over HTTP), so two sessions of one app are told apart.
|
|
11
|
+
- A claim ends with its session (2.4.0): when the session closes (the app quits, the agent
|
|
12
|
+
ends it, or it goes idle) its claims are released, and the same app on the same machine
|
|
13
|
+
may take over a claim whose session is no longer open (it reconnected as a new session).
|
|
14
|
+
Other apps stay blocked until the claim expires or is released.
|
|
15
|
+
- No hard lock: anyone may still update a claimed todo; they get a warning and an `override`
|
|
16
|
+
event. done / cancelled clears the claim.
|
|
17
|
+
- Every event goes into todo_claim_events, which manage_history who lists.
|
|
18
|
+
|
|
19
|
+
All times come from utcnow() (tests freeze it); stored UTC, shown relative ("expires in 9m").
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import sqlite3
|
|
23
|
+
from datetime import datetime, timedelta, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
from . import dbconn
|
|
28
|
+
from . import handoffs, identity, links
|
|
29
|
+
|
|
30
|
+
DEFAULT_MINUTES, MIN_MINUTES, MAX_MINUTES = 15, 1, 120
|
|
31
|
+
TS_FMT = "%Y-%m-%d %H:%M:%S"
|
|
32
|
+
OPEN = (None, "pending", "in_progress", "blocked")
|
|
33
|
+
CLOSED = ("done", "cancelled")
|
|
34
|
+
_ID = ("app", "model", "machine", "identity_source", "session")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def utcnow() -> datetime:
|
|
38
|
+
"""The one clock for claims (naive UTC)."""
|
|
39
|
+
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _ts(dt: datetime) -> str:
|
|
43
|
+
return dt.strftime(TS_FMT)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse(ts: str) -> datetime:
|
|
47
|
+
return datetime.strptime(ts[:19].replace("T", " "), TS_FMT)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ClaimError(links.LinkError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def available(conn) -> bool:
|
|
55
|
+
return conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' "
|
|
56
|
+
"AND name='todo_claims'").fetchone() is not None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def connect(db_path: Path) -> sqlite3.Connection:
|
|
60
|
+
conn = dbconn.connect(str(db_path), timeout=10.0, isolation_level=None)
|
|
61
|
+
conn.row_factory = sqlite3.Row
|
|
62
|
+
conn.execute("PRAGMA busy_timeout = 10000")
|
|
63
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
64
|
+
return conn
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------- who
|
|
68
|
+
def me(client: Optional[str] = None) -> dict:
|
|
69
|
+
"""{app, model, machine, identity_source, session, holder} of the caller."""
|
|
70
|
+
ident = identity.current() or identity.local(client or "unknown")
|
|
71
|
+
who = {**ident.as_dict(), "session": getattr(ident, "session", None)}
|
|
72
|
+
who["holder"] = "\x1f".join(str(who.get(k) or "") for k in ("app", "machine", "session"))
|
|
73
|
+
return who
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def holder_name(row) -> str:
|
|
77
|
+
"""'cursor on mac-mini' (the app, and the machine when known); 'Grok Bot (via Cursor)'
|
|
78
|
+
for an app that came in on a relay / own-tunnel sign-in (identity.shown)."""
|
|
79
|
+
return identity.shown_row(row, on_machine=True, fallback="another agent")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def relative(expires_at: str, now: Optional[datetime] = None) -> str:
|
|
83
|
+
"""'9m', '1h 5m', or 'now'."""
|
|
84
|
+
secs = (_parse(expires_at) - (now or utcnow())).total_seconds()
|
|
85
|
+
mins = int(-(-secs // 60)) if secs > 0 else 0 # round up: 8m30s left -> 9m
|
|
86
|
+
if mins <= 0:
|
|
87
|
+
return "now"
|
|
88
|
+
return f"{mins}m" if mins < 60 else f"{mins // 60}h {mins % 60}m" if mins % 60 else f"{mins // 60}h"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _who(row, mine: str) -> str:
|
|
92
|
+
"""'you', 'cursor on mac-mini', or 'cursor on mac-mini (another session)'."""
|
|
93
|
+
if row["holder"] == mine:
|
|
94
|
+
return "you"
|
|
95
|
+
me_app, me_machine = (mine.split("\x1f") + ["", ""])[:2]
|
|
96
|
+
same = (row["app"] or "") == me_app and (row["machine"] or "") == me_machine
|
|
97
|
+
return holder_name(row) + (" (another session)" if same else "")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def describe(row, mine: str, now: Optional[datetime] = None) -> str:
|
|
101
|
+
"""'claimed by cursor on mac-mini, expires in 9m' (or 'claimed by you, ...')."""
|
|
102
|
+
return f"claimed by {_who(row, mine)}, expires in {relative(row['expires_at'], now)}"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def active(conn, todo_ids=None, pid: Optional[int] = None) -> dict:
|
|
106
|
+
"""{todo_id: row} of unexpired claims (by ids, or for a whole project)."""
|
|
107
|
+
if not available(conn):
|
|
108
|
+
return {}
|
|
109
|
+
now = _ts(utcnow())
|
|
110
|
+
if todo_ids is not None:
|
|
111
|
+
ids = [int(i) for i in todo_ids]
|
|
112
|
+
if not ids:
|
|
113
|
+
return {}
|
|
114
|
+
rows = conn.execute(f"SELECT * FROM todo_claims WHERE expires_at > ? AND todo_id IN "
|
|
115
|
+
f"({', '.join('?' * len(ids))})", (now, *ids))
|
|
116
|
+
else:
|
|
117
|
+
rows = conn.execute("SELECT * FROM todo_claims WHERE expires_at > ? AND project_id = ?",
|
|
118
|
+
(now, pid))
|
|
119
|
+
return {r["todo_id"]: r for r in rows}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def notes(db_path: Path, todo_ids, client: Optional[str] = None) -> dict:
|
|
123
|
+
"""{todo_id: 'claimed by ..., expires in ...'} for the active claims among todo_ids."""
|
|
124
|
+
conn = connect(db_path)
|
|
125
|
+
try:
|
|
126
|
+
mine, now = me(client)["holder"], utcnow()
|
|
127
|
+
return {tid: describe(r, mine, now) for tid, r in active(conn, todo_ids).items()}
|
|
128
|
+
finally:
|
|
129
|
+
conn.close()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def annotate(db_path: Path, rows, client: Optional[str] = None):
|
|
133
|
+
"""`todo list`: add "claim" to each row that has an active claim."""
|
|
134
|
+
ids = [r["id"] for r in rows if isinstance(r, dict) and isinstance(r.get("id"), int)]
|
|
135
|
+
found = notes(db_path, ids, client) if ids else {}
|
|
136
|
+
for r in rows:
|
|
137
|
+
if isinstance(r, dict) and r.get("id") in found:
|
|
138
|
+
r["claim"] = found[r["id"]]
|
|
139
|
+
return rows
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def split_next(conn, ready: list, blocked: list, client: Optional[str] = None) -> tuple:
|
|
143
|
+
"""`todo next`: (ready with your claims first and others' claims removed, the removed
|
|
144
|
+
ones as short lines). Unchanged when nothing is claimed."""
|
|
145
|
+
claimed = active(conn, [i["id"] for i in ready + blocked])
|
|
146
|
+
if not claimed:
|
|
147
|
+
return ready, []
|
|
148
|
+
mine, now = me(client)["holder"], utcnow()
|
|
149
|
+
own, free, others = [], [], []
|
|
150
|
+
for i in ready:
|
|
151
|
+
c = claimed.get(i["id"])
|
|
152
|
+
if c is None:
|
|
153
|
+
free.append(i)
|
|
154
|
+
elif c["holder"] == mine:
|
|
155
|
+
own.append({**i, "claim": f"yours, expires in {relative(c['expires_at'], now)}"})
|
|
156
|
+
else:
|
|
157
|
+
others.append(f"#{i['id']} {links._one_line(i['title'], 60)} "
|
|
158
|
+
f"({describe(c, mine, now)})")
|
|
159
|
+
for i in blocked:
|
|
160
|
+
if i["id"] in claimed:
|
|
161
|
+
i["claim"] = describe(claimed[i["id"]], mine, now)
|
|
162
|
+
return own + free, others
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------- events
|
|
166
|
+
def _event(conn, todo_id: int, pid, event: str, summary: str, who: dict) -> None:
|
|
167
|
+
row = {"todo_id": todo_id, "project_id": pid, "event": event, "summary": summary[:200],
|
|
168
|
+
"at": _ts(utcnow()), **{k: who.get(k) for k in _ID}}
|
|
169
|
+
conn.execute(f"INSERT INTO todo_claim_events ({', '.join(row)}) "
|
|
170
|
+
f"VALUES ({', '.join('?' * len(row))})", list(row.values()))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------- actions
|
|
174
|
+
def _need(conn) -> None:
|
|
175
|
+
if not available(conn):
|
|
176
|
+
raise ClaimError("claims_unavailable", "Todo claims aren't set up in this database yet "
|
|
177
|
+
"(migration 013 has not run). Restart FlowState.")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _minutes(value) -> int:
|
|
181
|
+
if value in (None, ""):
|
|
182
|
+
return DEFAULT_MINUTES
|
|
183
|
+
m = None
|
|
184
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
185
|
+
m = value
|
|
186
|
+
elif isinstance(value, float) and value.is_integer():
|
|
187
|
+
m = int(value)
|
|
188
|
+
elif isinstance(value, str) and value.strip().isdigit():
|
|
189
|
+
m = int(value.strip())
|
|
190
|
+
if m is None or not MIN_MINUTES <= m <= MAX_MINUTES:
|
|
191
|
+
raise ClaimError("bad_minutes", f"minutes must be a whole number from {MIN_MINUTES} to "
|
|
192
|
+
f"{MAX_MINUTES} (default {DEFAULT_MINUTES}).")
|
|
193
|
+
return m
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _todo(conn, pid: int, todo_id) -> sqlite3.Row:
|
|
197
|
+
if todo_id is None:
|
|
198
|
+
raise ClaimError("todo_id_required", "Give todo_id (#id in the brief).")
|
|
199
|
+
t = conn.execute("SELECT id, project_id, title, status FROM todos WHERE id = ?",
|
|
200
|
+
(int(todo_id),)).fetchone()
|
|
201
|
+
if t is None:
|
|
202
|
+
raise ClaimError("record_not_found", f"No todo with id {todo_id}.")
|
|
203
|
+
if t["project_id"] != pid:
|
|
204
|
+
raise ClaimError("project_mismatch", f"Todo #{todo_id} is in project "
|
|
205
|
+
f"'{links.project_name(conn, t['project_id'])}'.")
|
|
206
|
+
return t
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _ended_session_sql(conn) -> Optional[str]:
|
|
210
|
+
"""SQL (for the claim upsert) that is true when the existing claim's session is known
|
|
211
|
+
and no longer open for its app. None before migration 015."""
|
|
212
|
+
if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='session_runs'"
|
|
213
|
+
).fetchone() is None:
|
|
214
|
+
return None
|
|
215
|
+
return ("EXISTS (SELECT 1 FROM session_runs r WHERE r.conn_key = todo_claims.session) "
|
|
216
|
+
"AND NOT EXISTS (SELECT 1 FROM session_runs r JOIN auto_sessions a USING (session_id) "
|
|
217
|
+
"WHERE r.conn_key = todo_claims.session AND r.app IS todo_claims.app "
|
|
218
|
+
"AND a.closed_at IS NULL)")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def release_for_session(conn, session_key: Optional[str], app: Optional[str], project_id: int,
|
|
222
|
+
reason: str) -> int:
|
|
223
|
+
"""Inside the caller's transaction: drop the claims a closed session (this connection
|
|
224
|
+
and app) held in this project, with a `release` event each. Returns how many."""
|
|
225
|
+
if not session_key or not available(conn):
|
|
226
|
+
return 0
|
|
227
|
+
rows = conn.execute("SELECT * FROM todo_claims WHERE session = ? AND app IS ? AND project_id = ?",
|
|
228
|
+
(session_key, app, project_id)).fetchall()
|
|
229
|
+
for r in rows:
|
|
230
|
+
conn.execute("DELETE FROM todo_claims WHERE todo_id = ?", (r["todo_id"],))
|
|
231
|
+
_event(conn, r["todo_id"], project_id, "release",
|
|
232
|
+
f"claim on todo #{r['todo_id']} released: its session ended ({reason})",
|
|
233
|
+
{k: r[k] for k in _ID})
|
|
234
|
+
return len(rows)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _held_elsewhere(t, row, now: datetime, mine: str) -> ClaimError:
|
|
238
|
+
return ClaimError(
|
|
239
|
+
"claimed_by_other",
|
|
240
|
+
f"Todo #{t['id']} is claimed by {_who(row, mine)} until {row['expires_at']} UTC "
|
|
241
|
+
f"(expires in {relative(row['expires_at'], now)}). Pick another one: "
|
|
242
|
+
f"manage_todos(action=\"next\") lists what is free.",
|
|
243
|
+
holder=holder_name(row), expires_at=row["expires_at"])
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def claim(db_path: Path, pid: int, todo_id, minutes=None, client: Optional[str] = None) -> dict:
|
|
247
|
+
mins = _minutes(minutes)
|
|
248
|
+
who = me(client)
|
|
249
|
+
now = utcnow()
|
|
250
|
+
expires = _ts(now + timedelta(minutes=mins))
|
|
251
|
+
conn = connect(db_path)
|
|
252
|
+
try:
|
|
253
|
+
_need(conn)
|
|
254
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
255
|
+
try:
|
|
256
|
+
t = _todo(conn, pid, todo_id)
|
|
257
|
+
if t["status"] not in OPEN:
|
|
258
|
+
raise ClaimError("todo_closed", f"Todo #{t['id']} is {t['status']}; claim an open one.")
|
|
259
|
+
before = conn.execute("SELECT * FROM todo_claims WHERE todo_id = ?",
|
|
260
|
+
(t["id"],)).fetchone()
|
|
261
|
+
vals = {"todo_id": t["id"], "project_id": pid, "holder": who["holder"],
|
|
262
|
+
**{k: who.get(k) for k in _ID}, "claimed_at": _ts(now), "expires_at": expires}
|
|
263
|
+
# The decision is this one statement: insert, or take over only when the current
|
|
264
|
+
# claim is ours, has expired, or is this app's on this machine from a session
|
|
265
|
+
# that is no longer open (the app reconnected).
|
|
266
|
+
ended = _ended_session_sql(conn)
|
|
267
|
+
same_app = (f" OR (todo_claims.app IS excluded.app AND todo_claims.machine IS "
|
|
268
|
+
f"excluded.machine AND {ended})" if ended else "")
|
|
269
|
+
cur = conn.execute(
|
|
270
|
+
f"""INSERT INTO todo_claims ({', '.join(vals)}) VALUES ({', '.join('?' * len(vals))})
|
|
271
|
+
ON CONFLICT(todo_id) DO UPDATE SET
|
|
272
|
+
claimed_at = CASE WHEN todo_claims.holder = excluded.holder
|
|
273
|
+
AND todo_claims.expires_at > ?
|
|
274
|
+
THEN todo_claims.claimed_at ELSE excluded.claimed_at END,
|
|
275
|
+
project_id = excluded.project_id, holder = excluded.holder,
|
|
276
|
+
app = excluded.app, model = excluded.model, machine = excluded.machine,
|
|
277
|
+
identity_source = excluded.identity_source, session = excluded.session,
|
|
278
|
+
expires_at = excluded.expires_at
|
|
279
|
+
WHERE todo_claims.holder = excluded.holder OR todo_claims.expires_at <= ?"""
|
|
280
|
+
+ same_app,
|
|
281
|
+
(*vals.values(), _ts(now), _ts(now)))
|
|
282
|
+
if cur.rowcount != 1:
|
|
283
|
+
row = conn.execute("SELECT * FROM todo_claims WHERE todo_id = ?",
|
|
284
|
+
(t["id"],)).fetchone()
|
|
285
|
+
raise _held_elsewhere(t, row, now, who["holder"])
|
|
286
|
+
live = before is not None and before["expires_at"] > _ts(now)
|
|
287
|
+
renewed = live and before["holder"] == who["holder"]
|
|
288
|
+
title = links._one_line(t["title"], 60)
|
|
289
|
+
if renewed:
|
|
290
|
+
_event(conn, t["id"], pid, "renew", f"renewed claim on todo #{t['id']} ({mins}m)", who)
|
|
291
|
+
else:
|
|
292
|
+
took = ""
|
|
293
|
+
if before is not None and before["holder"] != who["holder"]:
|
|
294
|
+
took = (f" (took over the claim of an ended {holder_name(before)} session)"
|
|
295
|
+
if live else f" (took over an expired claim by {holder_name(before)})")
|
|
296
|
+
_event(conn, t["id"], pid, "claim",
|
|
297
|
+
f"claimed todo #{t['id']} {title} for {mins}m{took}", who)
|
|
298
|
+
conn.execute("COMMIT")
|
|
299
|
+
except Exception:
|
|
300
|
+
conn.execute("ROLLBACK")
|
|
301
|
+
raise
|
|
302
|
+
return {"todo_id": t["id"], "claimed": True, "renewed": renewed, "expires_at": expires,
|
|
303
|
+
"expires_in": relative(expires, now),
|
|
304
|
+
"note": "Other agents see it as claimed. Renew before it expires if you need "
|
|
305
|
+
"longer; release or handoff when you stop."}
|
|
306
|
+
finally:
|
|
307
|
+
conn.close()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def renew(db_path: Path, pid: int, todo_id, minutes=None, client: Optional[str] = None) -> dict:
|
|
311
|
+
"""Extend your own live claim. Fails if you don't hold it, and once it has expired (2.4.4,
|
|
312
|
+
task 72): an expired claim is gone, for its holder too; claim the todo again."""
|
|
313
|
+
mins = _minutes(minutes)
|
|
314
|
+
who = me(client)
|
|
315
|
+
now = utcnow()
|
|
316
|
+
expires = _ts(now + timedelta(minutes=mins))
|
|
317
|
+
conn = connect(db_path)
|
|
318
|
+
try:
|
|
319
|
+
_need(conn)
|
|
320
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
321
|
+
try:
|
|
322
|
+
t = _todo(conn, pid, todo_id)
|
|
323
|
+
cur = conn.execute("UPDATE todo_claims SET expires_at = ?, model = ? "
|
|
324
|
+
"WHERE todo_id = ? AND holder = ? AND expires_at > ?",
|
|
325
|
+
(expires, who.get("model"), t["id"], who["holder"], _ts(now)))
|
|
326
|
+
if cur.rowcount != 1:
|
|
327
|
+
row = conn.execute("SELECT * FROM todo_claims WHERE todo_id = ?",
|
|
328
|
+
(t["id"],)).fetchone()
|
|
329
|
+
if row is not None and row["expires_at"] > _ts(now):
|
|
330
|
+
raise _held_elsewhere(t, row, now, who["holder"])
|
|
331
|
+
if row is not None and row["holder"] == who["holder"]:
|
|
332
|
+
raise ClaimError("claim_expired", f"Your claim on todo #{t['id']} expired at "
|
|
333
|
+
f"{row['expires_at']} UTC, so it can't be renewed. Claim it "
|
|
334
|
+
f"again if it is still free: manage_todos(action=\"claim\").",
|
|
335
|
+
expired_at=row["expires_at"])
|
|
336
|
+
raise ClaimError("not_claimed", f"You don't hold a claim on todo #{t['id']}. "
|
|
337
|
+
f"Claim it first: manage_todos(action=\"claim\").")
|
|
338
|
+
_event(conn, t["id"], pid, "renew", f"renewed claim on todo #{t['id']} ({mins}m)", who)
|
|
339
|
+
conn.execute("COMMIT")
|
|
340
|
+
except Exception:
|
|
341
|
+
conn.execute("ROLLBACK")
|
|
342
|
+
raise
|
|
343
|
+
return {"todo_id": t["id"], "renewed": True, "expires_at": expires,
|
|
344
|
+
"expires_in": relative(expires, now)}
|
|
345
|
+
finally:
|
|
346
|
+
conn.close()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _drop_own(conn, t, pid: int, who: dict, event: str, summary: str) -> bool:
|
|
350
|
+
"""Delete the caller's claim on t (inside the open transaction). True if there was one;
|
|
351
|
+
raises if someone else holds it."""
|
|
352
|
+
now = utcnow()
|
|
353
|
+
if conn.execute("DELETE FROM todo_claims WHERE todo_id = ? AND holder = ? AND expires_at <= ?",
|
|
354
|
+
(t["id"], who["holder"], _ts(now))).rowcount:
|
|
355
|
+
return False # it had expired: it was no longer held (the stale row goes)
|
|
356
|
+
cur = conn.execute("DELETE FROM todo_claims WHERE todo_id = ? AND holder = ?",
|
|
357
|
+
(t["id"], who["holder"]))
|
|
358
|
+
if cur.rowcount != 1:
|
|
359
|
+
row = conn.execute("SELECT * FROM todo_claims WHERE todo_id = ? AND expires_at > ?",
|
|
360
|
+
(t["id"], _ts(now))).fetchone()
|
|
361
|
+
if row is not None:
|
|
362
|
+
raise ClaimError("not_your_claim", f"Todo #{t['id']} is claimed by {holder_name(row)} "
|
|
363
|
+
f"(expires in {relative(row['expires_at'], now)}), not by you.",
|
|
364
|
+
holder=holder_name(row), expires_at=row["expires_at"])
|
|
365
|
+
return False
|
|
366
|
+
_event(conn, t["id"], pid, event, summary, who)
|
|
367
|
+
return True
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def release(db_path: Path, pid: int, todo_id, client: Optional[str] = None) -> dict:
|
|
371
|
+
who = me(client)
|
|
372
|
+
conn = connect(db_path)
|
|
373
|
+
try:
|
|
374
|
+
_need(conn)
|
|
375
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
376
|
+
try:
|
|
377
|
+
t = _todo(conn, pid, todo_id)
|
|
378
|
+
had = _drop_own(conn, t, pid, who, "release", f"released claim on todo #{t['id']}")
|
|
379
|
+
conn.execute("COMMIT")
|
|
380
|
+
except Exception:
|
|
381
|
+
conn.execute("ROLLBACK")
|
|
382
|
+
raise
|
|
383
|
+
return {"todo_id": t["id"], "released": had,
|
|
384
|
+
"note": "Others can claim it now." if had else
|
|
385
|
+
"You held no claim on it (it may have expired); nothing to release."}
|
|
386
|
+
finally:
|
|
387
|
+
conn.close()
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def handoff(db_path: Path, pid: int, todo_id, where_i_was: Optional[str] = None,
|
|
391
|
+
next_step: Optional[str] = None, client: Optional[str] = None) -> dict:
|
|
392
|
+
"""Leave a handoff note (handoffs.py) about this todo and drop your claim."""
|
|
393
|
+
who = me(client)
|
|
394
|
+
conn = connect(db_path)
|
|
395
|
+
try:
|
|
396
|
+
_need(conn)
|
|
397
|
+
t = _todo(conn, pid, todo_id)
|
|
398
|
+
row = active(conn, [t["id"]]).get(t["id"])
|
|
399
|
+
if row is not None and row["holder"] != who["holder"]:
|
|
400
|
+
raise ClaimError("not_your_claim", f"Todo #{t['id']} is claimed by {holder_name(row)}, "
|
|
401
|
+
f"not by you; leave a note with manage_handoffs instead.",
|
|
402
|
+
holder=holder_name(row), expires_at=row["expires_at"])
|
|
403
|
+
finally:
|
|
404
|
+
conn.close()
|
|
405
|
+
title = links._one_line(t["title"], 80)
|
|
406
|
+
where = (where_i_was or "").strip()
|
|
407
|
+
nxt = (next_step or "").strip() or (f"Claim todo #{t['id']} (manage_todos action=claim) "
|
|
408
|
+
f"and carry on.")
|
|
409
|
+
note = handoffs.leave(db_path, pid, f"Todo #{t['id']} {title}: "
|
|
410
|
+
+ (where or "started, not finished."), nxt, client or who["app"])
|
|
411
|
+
conn = connect(db_path)
|
|
412
|
+
try:
|
|
413
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
414
|
+
try:
|
|
415
|
+
had = _drop_own(conn, t, pid, who, "handoff",
|
|
416
|
+
f"handed off todo #{t['id']} (note H{note['handoff_id']})")
|
|
417
|
+
if not had: # still log the handoff in the trail
|
|
418
|
+
_event(conn, t["id"], pid, "handoff",
|
|
419
|
+
f"handed off todo #{t['id']} (note H{note['handoff_id']})", who)
|
|
420
|
+
conn.execute("COMMIT")
|
|
421
|
+
except Exception:
|
|
422
|
+
conn.execute("ROLLBACK")
|
|
423
|
+
raise
|
|
424
|
+
finally:
|
|
425
|
+
conn.close()
|
|
426
|
+
return {"todo_id": t["id"], "released": had, "handoff_id": note["handoff_id"],
|
|
427
|
+
"note": "Claim dropped; the next start_here shows your handoff note."}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def after_update(db_path: Path, pid: int, todo_id: int, action: str, status: Optional[str],
|
|
431
|
+
client: Optional[str] = None) -> Optional[str]:
|
|
432
|
+
"""After someone changed a todo: an `override` event and a warning when another agent
|
|
433
|
+
holds it; done / cancelled clears the claim. Returns the warning text, or None."""
|
|
434
|
+
who = me(client)
|
|
435
|
+
conn = connect(db_path)
|
|
436
|
+
try:
|
|
437
|
+
if not available(conn):
|
|
438
|
+
return None
|
|
439
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
440
|
+
try:
|
|
441
|
+
row = active(conn, [todo_id]).get(todo_id)
|
|
442
|
+
warning = None
|
|
443
|
+
if row is not None and row["holder"] != who["holder"]:
|
|
444
|
+
left = relative(row["expires_at"])
|
|
445
|
+
warning = (f"Heads up: todo #{todo_id} is claimed by {holder_name(row)} (expires "
|
|
446
|
+
f"in {left}). Your {action} was saved anyway; tell the user or that "
|
|
447
|
+
f"agent if this was not intended.")
|
|
448
|
+
_event(conn, todo_id, pid, "override",
|
|
449
|
+
f"{action} on todo #{todo_id} claimed by {holder_name(row)}", who)
|
|
450
|
+
if status in CLOSED:
|
|
451
|
+
cur = conn.execute("DELETE FROM todo_claims WHERE todo_id = ?", (todo_id,))
|
|
452
|
+
if cur.rowcount and row is not None:
|
|
453
|
+
_event(conn, todo_id, pid, "cleared",
|
|
454
|
+
f"claim on todo #{todo_id} cleared ({status})", who)
|
|
455
|
+
conn.execute("COMMIT")
|
|
456
|
+
except Exception:
|
|
457
|
+
conn.execute("ROLLBACK")
|
|
458
|
+
raise
|
|
459
|
+
return warning
|
|
460
|
+
finally:
|
|
461
|
+
conn.close()
|