taskboy 0.1.1__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.
- taskboy/__init__.py +0 -0
- taskboy/adapters/__init__.py +0 -0
- taskboy/adapters/_util.py +30 -0
- taskboy/adapters/aws_read.py +107 -0
- taskboy/adapters/confluence.py +88 -0
- taskboy/adapters/github_api.py +564 -0
- taskboy/adapters/issues.py +435 -0
- taskboy/adapters/jira.py +263 -0
- taskboy/adapters/sentry.py +82 -0
- taskboy/adapters/slack_history.py +204 -0
- taskboy/assets.py +27 -0
- taskboy/audit.py +78 -0
- taskboy/broker.py +266 -0
- taskboy/classifier.py +219 -0
- taskboy/cli.py +161 -0
- taskboy/config.py +539 -0
- taskboy/dashboard/__init__.py +5 -0
- taskboy/dashboard/api.py +963 -0
- taskboy/dashboard/app.py +78 -0
- taskboy/dashboard/auth.py +117 -0
- taskboy/dashboard/editors.py +140 -0
- taskboy/dashboard/gitops.py +60 -0
- taskboy/dashboard/render.py +48 -0
- taskboy/debug_feed.py +170 -0
- taskboy/deploy/env.example +17 -0
- taskboy/deploy/git-cred-helper.py +36 -0
- taskboy/deploy/install.sh +101 -0
- taskboy/deploy/remote-update.sh +52 -0
- taskboy/deploy/taskboy-restart.path +10 -0
- taskboy/deploy/taskboy-restart.service +8 -0
- taskboy/deploy/taskboy.service +22 -0
- taskboy/hooks.py +208 -0
- taskboy/issue_runs.py +203 -0
- taskboy/llm.py +125 -0
- taskboy/main.py +269 -0
- taskboy/memory.py +59 -0
- taskboy/models.py +103 -0
- taskboy/mrkdwn.py +19 -0
- taskboy/notify.py +41 -0
- taskboy/orchestrator.py +401 -0
- taskboy/personality.py +17 -0
- taskboy/prompts.py +288 -0
- taskboy/quick.py +234 -0
- taskboy/redact.py +49 -0
- taskboy/repocache.py +117 -0
- taskboy/review_requests.py +388 -0
- taskboy/router.py +146 -0
- taskboy/runner.py +581 -0
- taskboy/scheduler.py +301 -0
- taskboy/secrets.py +83 -0
- taskboy/settings.py +19 -0
- taskboy/setup_checks.py +184 -0
- taskboy/setup_wizard.py +699 -0
- taskboy/skills.py +95 -0
- taskboy/slack.py +666 -0
- taskboy/slack_users.py +32 -0
- taskboy/started_messages.py +33 -0
- taskboy/store.py +1393 -0
- taskboy/task_actions.py +86 -0
- taskboy/templates/config.example.yaml +149 -0
- taskboy/templates/conventions.md +60 -0
- taskboy/templates/help.example.md +29 -0
- taskboy/templates/personality_agent.example.md +5 -0
- taskboy/templates/personality_reviewer.example.md +5 -0
- taskboy/templates/services/aws.yaml +7 -0
- taskboy/templates/services/confluence.yaml +4 -0
- taskboy/templates/services/github.yaml +14 -0
- taskboy/templates/services/jira.yaml +6 -0
- taskboy/templates/services/sentry.yaml +4 -0
- taskboy/templates/services/slack.yaml +9 -0
- taskboy/templates/skills/README.md +33 -0
- taskboy/templates/skills/discoverissues/SKILL.md +46 -0
- taskboy/templates/skills/implementapprovedissues/SKILL.md +41 -0
- taskboy/templates/skills/jira2pr/SKILL.md +66 -0
- taskboy/templates/skills/monitor/SKILL.md +32 -0
- taskboy/templates/skills/monitornew/SKILL.md +27 -0
- taskboy/templates/skills/refineissue/SKILL.md +35 -0
- taskboy/templates/skills/release/SKILL.md +36 -0
- taskboy/templates/skills/review/SKILL.md +89 -0
- taskboy/templates/skills/reviewandmonitor/SKILL.md +22 -0
- taskboy/templates/skills/reviews/SKILL.md +26 -0
- taskboy/templates/skills/slack2jira/SKILL.md +47 -0
- taskboy/templates/skills/slack2pr/SKILL.md +64 -0
- taskboy/templates/skills/spec2pr/SKILL.md +35 -0
- taskboy/templates/slack_app_manifest.yaml +48 -0
- taskboy/templates/task_started_messages.yaml +14 -0
- taskboy/ui_dist/assets/index-DmYxR9Qy.css +1 -0
- taskboy/ui_dist/assets/index-LDDO4iT2.js +15 -0
- taskboy/ui_dist/index.html +15 -0
- taskboy/workspace.py +86 -0
- taskboy-0.1.1.dist-info/METADATA +120 -0
- taskboy-0.1.1.dist-info/RECORD +95 -0
- taskboy-0.1.1.dist-info/WHEEL +5 -0
- taskboy-0.1.1.dist-info/entry_points.txt +2 -0
- taskboy-0.1.1.dist-info/top_level.txt +1 -0
taskboy/store.py
ADDED
|
@@ -0,0 +1,1393 @@
|
|
|
1
|
+
"""the only module that talks sql. everything durable lives here: tasks, slack dedup, audit events, artifacts, usage.
|
|
2
|
+
|
|
3
|
+
this interface is the future off-host seam — swapping sqlite for dynamodb/postgres touches nothing else.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import sqlite3
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from taskboy.models import ALLOWED_TRANSITIONS, QUEUED, TERMINAL_STATES, Task, new_task_id, utcnow
|
|
13
|
+
from taskboy.redact import redactor
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IllegalTransition(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TransitionRaced(Exception):
|
|
21
|
+
"""the row was not in from_state when the update ran — someone else transitioned it first."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# the issues table's status vocabulary (see the CHECK constraint on the issues table below); shared with
|
|
25
|
+
# adapters/issues.py so tool-level validation stays in sync with the schema
|
|
26
|
+
ISSUE_STATUSES = ("proposed", "approved", "denied", "implementation_queued", "in_progress", "in_review", "done", "failed")
|
|
27
|
+
|
|
28
|
+
# columns transition() may update alongside state; anything else is a programming error
|
|
29
|
+
UPDATABLE_FIELDS = {
|
|
30
|
+
"attempt",
|
|
31
|
+
"resume_session_id",
|
|
32
|
+
"not_before",
|
|
33
|
+
"persona",
|
|
34
|
+
"classification_json",
|
|
35
|
+
"task_type",
|
|
36
|
+
"complexity",
|
|
37
|
+
"risk",
|
|
38
|
+
"model_alias",
|
|
39
|
+
"model_id",
|
|
40
|
+
"profile",
|
|
41
|
+
"routing_rationale",
|
|
42
|
+
"model_override",
|
|
43
|
+
"effort_override",
|
|
44
|
+
"effort",
|
|
45
|
+
"session_id",
|
|
46
|
+
"workspace_path",
|
|
47
|
+
"max_budget_usd",
|
|
48
|
+
"max_turns",
|
|
49
|
+
"max_runtime_minutes",
|
|
50
|
+
"cost_usd",
|
|
51
|
+
"num_turns",
|
|
52
|
+
"blocked_reason",
|
|
53
|
+
"error",
|
|
54
|
+
"result_summary",
|
|
55
|
+
"reply",
|
|
56
|
+
"debug_thread_ts",
|
|
57
|
+
"debug_permalink",
|
|
58
|
+
"started_at",
|
|
59
|
+
"finished_at",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# free-text columns are long-term memory: secrets must never land in them (MEM-011)
|
|
63
|
+
REDACTED_FIELDS = {"request_text", "thread_context", "result_summary", "reply", "error", "blocked_reason", "routing_rationale", "classification_json"}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _redact_fields(fields: dict) -> dict:
|
|
67
|
+
return {key: redactor.redact(value) if key in REDACTED_FIELDS and isinstance(value, str) else value for key, value in fields.items()}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
MIGRATIONS = [
|
|
71
|
+
# single base schema: this project ships with no deployed databases, so the historical
|
|
72
|
+
# incremental migrations were squashed. append new migrations below; never edit this one.
|
|
73
|
+
"""
|
|
74
|
+
CREATE TABLE tasks (
|
|
75
|
+
task_id TEXT PRIMARY KEY,
|
|
76
|
+
idempotency_key TEXT NOT NULL UNIQUE,
|
|
77
|
+
state TEXT NOT NULL CHECK (state IN ('received','queued','running','blocked','completed','failed','cancelled','refused')),
|
|
78
|
+
attempt INTEGER NOT NULL DEFAULT 0,
|
|
79
|
+
resume_session_id TEXT,
|
|
80
|
+
slack_team_id TEXT NOT NULL,
|
|
81
|
+
slack_channel_id TEXT NOT NULL,
|
|
82
|
+
slack_thread_ts TEXT NOT NULL,
|
|
83
|
+
slack_message_ts TEXT NOT NULL,
|
|
84
|
+
slack_user_id TEXT NOT NULL,
|
|
85
|
+
request_text TEXT NOT NULL,
|
|
86
|
+
thread_context TEXT,
|
|
87
|
+
parent_task_id TEXT REFERENCES tasks(task_id),
|
|
88
|
+
classification_json TEXT,
|
|
89
|
+
task_type TEXT,
|
|
90
|
+
complexity TEXT,
|
|
91
|
+
risk TEXT,
|
|
92
|
+
model_alias TEXT,
|
|
93
|
+
model_id TEXT,
|
|
94
|
+
profile TEXT,
|
|
95
|
+
routing_rationale TEXT,
|
|
96
|
+
model_override TEXT,
|
|
97
|
+
effort_override TEXT,
|
|
98
|
+
effort TEXT,
|
|
99
|
+
session_id TEXT,
|
|
100
|
+
workspace_path TEXT,
|
|
101
|
+
max_budget_usd REAL,
|
|
102
|
+
max_turns INTEGER,
|
|
103
|
+
max_runtime_minutes INTEGER,
|
|
104
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
105
|
+
num_turns INTEGER NOT NULL DEFAULT 0,
|
|
106
|
+
blocked_reason TEXT,
|
|
107
|
+
error TEXT,
|
|
108
|
+
result_summary TEXT,
|
|
109
|
+
reply TEXT,
|
|
110
|
+
debug_thread_ts TEXT,
|
|
111
|
+
debug_permalink TEXT,
|
|
112
|
+
persona TEXT,
|
|
113
|
+
not_before TEXT,
|
|
114
|
+
schedule_name TEXT,
|
|
115
|
+
created_at TEXT NOT NULL,
|
|
116
|
+
started_at TEXT,
|
|
117
|
+
finished_at TEXT,
|
|
118
|
+
updated_at TEXT NOT NULL
|
|
119
|
+
);
|
|
120
|
+
CREATE INDEX idx_tasks_state ON tasks(state);
|
|
121
|
+
CREATE INDEX idx_tasks_thread ON tasks(slack_channel_id, slack_thread_ts, created_at);
|
|
122
|
+
|
|
123
|
+
CREATE TABLE slack_events (
|
|
124
|
+
event_id TEXT PRIMARY KEY,
|
|
125
|
+
received_at TEXT NOT NULL
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
CREATE TABLE task_events (
|
|
129
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
130
|
+
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
|
131
|
+
ts TEXT NOT NULL,
|
|
132
|
+
kind TEXT NOT NULL,
|
|
133
|
+
tool_name TEXT,
|
|
134
|
+
is_write INTEGER,
|
|
135
|
+
detail_json TEXT NOT NULL,
|
|
136
|
+
hash TEXT
|
|
137
|
+
);
|
|
138
|
+
CREATE INDEX idx_task_events_task ON task_events(task_id, id);
|
|
139
|
+
|
|
140
|
+
CREATE TABLE artifacts (
|
|
141
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
142
|
+
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
|
143
|
+
kind TEXT NOT NULL,
|
|
144
|
+
external_id TEXT NOT NULL,
|
|
145
|
+
url TEXT,
|
|
146
|
+
created_at TEXT NOT NULL,
|
|
147
|
+
UNIQUE(task_id, kind, external_id)
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
CREATE TABLE usage (
|
|
151
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
152
|
+
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
|
153
|
+
ts TEXT NOT NULL,
|
|
154
|
+
source TEXT NOT NULL,
|
|
155
|
+
model TEXT NOT NULL,
|
|
156
|
+
input_tokens INTEGER,
|
|
157
|
+
output_tokens INTEGER,
|
|
158
|
+
cache_read_tokens INTEGER,
|
|
159
|
+
cache_write_tokens INTEGER,
|
|
160
|
+
cost_usd REAL
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
-- unauthorized mentions never create a task, but the audit trail records the authorization result
|
|
164
|
+
CREATE TABLE intake_denials (
|
|
165
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
166
|
+
ts TEXT NOT NULL,
|
|
167
|
+
team_id TEXT NOT NULL,
|
|
168
|
+
channel_id TEXT NOT NULL,
|
|
169
|
+
user_id TEXT NOT NULL,
|
|
170
|
+
reason TEXT NOT NULL
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
CREATE TABLE slack_users (
|
|
174
|
+
user_id TEXT PRIMARY KEY,
|
|
175
|
+
team_id TEXT,
|
|
176
|
+
username TEXT,
|
|
177
|
+
real_name TEXT,
|
|
178
|
+
display_name TEXT,
|
|
179
|
+
email TEXT,
|
|
180
|
+
title TEXT,
|
|
181
|
+
tz TEXT,
|
|
182
|
+
is_bot INTEGER,
|
|
183
|
+
updated_at TEXT NOT NULL
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
CREATE TABLE errors (
|
|
187
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
188
|
+
ts TEXT NOT NULL,
|
|
189
|
+
task_id TEXT,
|
|
190
|
+
component TEXT NOT NULL,
|
|
191
|
+
kind TEXT NOT NULL,
|
|
192
|
+
message TEXT NOT NULL,
|
|
193
|
+
traceback TEXT,
|
|
194
|
+
context_json TEXT
|
|
195
|
+
);
|
|
196
|
+
CREATE INDEX idx_errors_component_ts ON errors(component, ts);
|
|
197
|
+
|
|
198
|
+
CREATE TABLE admin_events (
|
|
199
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
200
|
+
ts TEXT NOT NULL,
|
|
201
|
+
actor TEXT NOT NULL,
|
|
202
|
+
action TEXT NOT NULL,
|
|
203
|
+
target TEXT NOT NULL,
|
|
204
|
+
outcome TEXT NOT NULL,
|
|
205
|
+
detail_json TEXT NOT NULL,
|
|
206
|
+
hash TEXT NOT NULL
|
|
207
|
+
);
|
|
208
|
+
CREATE INDEX idx_admin_events_ts ON admin_events(ts, id);
|
|
209
|
+
|
|
210
|
+
CREATE TABLE permission_requests (
|
|
211
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
212
|
+
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
|
213
|
+
kind TEXT NOT NULL CHECK (kind IN ('tool','repo')),
|
|
214
|
+
target TEXT NOT NULL,
|
|
215
|
+
reason TEXT NOT NULL,
|
|
216
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','granted','denied')),
|
|
217
|
+
decided_by TEXT,
|
|
218
|
+
requested_at TEXT NOT NULL,
|
|
219
|
+
decided_at TEXT,
|
|
220
|
+
UNIQUE(task_id, kind, target)
|
|
221
|
+
);
|
|
222
|
+
CREATE INDEX idx_permission_requests_task ON permission_requests(task_id, id);
|
|
223
|
+
|
|
224
|
+
CREATE TABLE rate_limits (
|
|
225
|
+
rate_limit_type TEXT PRIMARY KEY,
|
|
226
|
+
status TEXT NOT NULL,
|
|
227
|
+
utilization REAL,
|
|
228
|
+
resets_at INTEGER,
|
|
229
|
+
observed_at TEXT NOT NULL
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
CREATE TABLE task_questions (
|
|
233
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
234
|
+
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
|
235
|
+
questions TEXT NOT NULL,
|
|
236
|
+
answer_text TEXT,
|
|
237
|
+
answered_by TEXT,
|
|
238
|
+
asked_at TEXT NOT NULL,
|
|
239
|
+
answered_at TEXT
|
|
240
|
+
);
|
|
241
|
+
CREATE INDEX idx_task_questions_task ON task_questions(task_id, id);
|
|
242
|
+
|
|
243
|
+
CREATE TABLE task_feedback (
|
|
244
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
245
|
+
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
|
246
|
+
submitted_by TEXT NOT NULL,
|
|
247
|
+
rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5),
|
|
248
|
+
comment TEXT,
|
|
249
|
+
created_at TEXT NOT NULL,
|
|
250
|
+
updated_at TEXT NOT NULL,
|
|
251
|
+
UNIQUE(task_id, submitted_by)
|
|
252
|
+
);
|
|
253
|
+
CREATE INDEX idx_task_feedback_task ON task_feedback(task_id, id);
|
|
254
|
+
|
|
255
|
+
CREATE TABLE schedules (
|
|
256
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
257
|
+
name TEXT NOT NULL,
|
|
258
|
+
request_text TEXT NOT NULL,
|
|
259
|
+
model_alias TEXT,
|
|
260
|
+
effort TEXT,
|
|
261
|
+
kind TEXT NOT NULL CHECK (kind IN ('once','interval','daily')),
|
|
262
|
+
interval_minutes INTEGER,
|
|
263
|
+
at_time TEXT,
|
|
264
|
+
run_at TEXT,
|
|
265
|
+
timezone TEXT,
|
|
266
|
+
max_runs INTEGER,
|
|
267
|
+
run_count INTEGER NOT NULL DEFAULT 0,
|
|
268
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
269
|
+
next_run_at TEXT NOT NULL,
|
|
270
|
+
last_run_at TEXT,
|
|
271
|
+
last_task_id TEXT,
|
|
272
|
+
seed_key TEXT UNIQUE,
|
|
273
|
+
created_by TEXT NOT NULL,
|
|
274
|
+
created_at TEXT NOT NULL,
|
|
275
|
+
updated_at TEXT NOT NULL
|
|
276
|
+
);
|
|
277
|
+
CREATE INDEX idx_schedules_due ON schedules(enabled, next_run_at);
|
|
278
|
+
|
|
279
|
+
CREATE TABLE issues (
|
|
280
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
281
|
+
dedupe_key TEXT NOT NULL UNIQUE,
|
|
282
|
+
repo TEXT NOT NULL,
|
|
283
|
+
summary TEXT NOT NULL,
|
|
284
|
+
issue_type TEXT NOT NULL,
|
|
285
|
+
details TEXT NOT NULL,
|
|
286
|
+
priority INTEGER NOT NULL DEFAULT 50,
|
|
287
|
+
status TEXT NOT NULL DEFAULT 'proposed' CHECK (status IN ('proposed','approved','denied','implementation_queued','in_progress','in_review','done','failed')),
|
|
288
|
+
source_json TEXT,
|
|
289
|
+
spec TEXT,
|
|
290
|
+
task_id TEXT REFERENCES tasks(task_id),
|
|
291
|
+
pr_url TEXT,
|
|
292
|
+
decided_by TEXT,
|
|
293
|
+
decided_at TEXT,
|
|
294
|
+
reserved_by TEXT,
|
|
295
|
+
created_at TEXT NOT NULL,
|
|
296
|
+
updated_at TEXT NOT NULL
|
|
297
|
+
);
|
|
298
|
+
CREATE INDEX idx_issues_status ON issues(status, priority DESC, id);
|
|
299
|
+
CREATE INDEX idx_issues_repo ON issues(repo);
|
|
300
|
+
|
|
301
|
+
CREATE TABLE issue_comments (
|
|
302
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
303
|
+
issue_id INTEGER NOT NULL REFERENCES issues(id),
|
|
304
|
+
parent_comment_id INTEGER REFERENCES issue_comments(id),
|
|
305
|
+
author TEXT NOT NULL,
|
|
306
|
+
body TEXT NOT NULL,
|
|
307
|
+
resolved INTEGER NOT NULL DEFAULT 0,
|
|
308
|
+
resolved_by TEXT,
|
|
309
|
+
resolved_at TEXT,
|
|
310
|
+
edited_at TEXT,
|
|
311
|
+
deleted_at TEXT,
|
|
312
|
+
created_at TEXT NOT NULL,
|
|
313
|
+
updated_at TEXT NOT NULL
|
|
314
|
+
);
|
|
315
|
+
CREATE INDEX idx_issue_comments_issue ON issue_comments(issue_id, id);
|
|
316
|
+
|
|
317
|
+
CREATE TABLE issue_attachments (
|
|
318
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
319
|
+
issue_id INTEGER NOT NULL REFERENCES issues(id),
|
|
320
|
+
comment_id INTEGER REFERENCES issue_comments(id),
|
|
321
|
+
filename TEXT NOT NULL,
|
|
322
|
+
content_type TEXT,
|
|
323
|
+
size_bytes INTEGER NOT NULL,
|
|
324
|
+
s3_key TEXT NOT NULL,
|
|
325
|
+
uploaded_by TEXT NOT NULL,
|
|
326
|
+
created_at TEXT NOT NULL
|
|
327
|
+
);
|
|
328
|
+
CREATE INDEX idx_issue_attachments_issue ON issue_attachments(issue_id, id);
|
|
329
|
+
""",
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
SCHEDULE_UPDATABLE = {"name", "request_text", "model_alias", "effort", "kind", "interval_minutes", "at_time", "run_at", "timezone", "max_runs", "enabled", "next_run_at", "run_count", "last_run_at", "last_task_id"}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _clamp_priority(priority: int) -> int:
|
|
336
|
+
try:
|
|
337
|
+
return max(1, min(100, int(priority)))
|
|
338
|
+
except (TypeError, ValueError):
|
|
339
|
+
return 50
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def event_hash(prev_hash: str, ts: str, task_id: str, kind: str, tool_name: str | None, detail_json: str) -> str:
|
|
343
|
+
return hashlib.sha256(f"{prev_hash}|{ts}|{task_id}|{kind}|{tool_name or ''}|{detail_json}".encode()).hexdigest()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def admin_event_hash(prev_hash: str, ts: str, actor: str, action: str, target: str, outcome: str, detail_json: str) -> str:
|
|
347
|
+
return hashlib.sha256(f"{prev_hash}|{ts}|{actor}|{action}|{target}|{outcome}|{detail_json}".encode()).hexdigest()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class Store:
|
|
351
|
+
def __init__(self, path: str):
|
|
352
|
+
if path != ":memory:":
|
|
353
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
354
|
+
self.conn = sqlite3.connect(path)
|
|
355
|
+
self.conn.row_factory = sqlite3.Row
|
|
356
|
+
self.conn.execute("PRAGMA journal_mode=WAL")
|
|
357
|
+
self.conn.execute("PRAGMA synchronous=NORMAL")
|
|
358
|
+
self.conn.execute("PRAGMA foreign_keys=ON")
|
|
359
|
+
self.conn.execute("PRAGMA busy_timeout=5000")
|
|
360
|
+
self._migrate()
|
|
361
|
+
|
|
362
|
+
def close(self) -> None:
|
|
363
|
+
self.conn.close()
|
|
364
|
+
|
|
365
|
+
def _migrate(self) -> None:
|
|
366
|
+
self.conn.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
|
367
|
+
row = self.conn.execute("SELECT value FROM meta WHERE key = 'schema_version'").fetchone()
|
|
368
|
+
version = int(row["value"]) if row else 0
|
|
369
|
+
for i, script in enumerate(MIGRATIONS[version:], start=version + 1):
|
|
370
|
+
self.conn.executescript(script)
|
|
371
|
+
self.conn.execute("INSERT INTO meta (key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", (str(i),))
|
|
372
|
+
self.conn.commit()
|
|
373
|
+
|
|
374
|
+
# -- tasks --------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
def create_task(
|
|
377
|
+
self,
|
|
378
|
+
*,
|
|
379
|
+
slack_team_id: str,
|
|
380
|
+
slack_channel_id: str,
|
|
381
|
+
slack_thread_ts: str,
|
|
382
|
+
slack_message_ts: str,
|
|
383
|
+
slack_user_id: str,
|
|
384
|
+
request_text: str,
|
|
385
|
+
parent_task_id: str | None = None,
|
|
386
|
+
model_override: str | None = None,
|
|
387
|
+
effort_override: str | None = None,
|
|
388
|
+
thread_context: str | None = None,
|
|
389
|
+
pre_classification: dict | None = None,
|
|
390
|
+
persona: str | None = None,
|
|
391
|
+
schedule_name: str | None = None,
|
|
392
|
+
debug_thread_ts: str | None = None,
|
|
393
|
+
debug_permalink: str | None = None,
|
|
394
|
+
) -> tuple[Task, bool]:
|
|
395
|
+
"""durably record a task before anything executes (REL-001). one task per slack message, ever (SLK-004/008)."""
|
|
396
|
+
idempotency_key = f"{slack_team_id}:{slack_channel_id}:{slack_message_ts}"
|
|
397
|
+
task_id = new_task_id()
|
|
398
|
+
now = utcnow()
|
|
399
|
+
fields = _redact_fields(
|
|
400
|
+
{
|
|
401
|
+
"request_text": request_text,
|
|
402
|
+
"thread_context": thread_context,
|
|
403
|
+
"classification_json": json.dumps(pre_classification) if pre_classification is not None else None,
|
|
404
|
+
}
|
|
405
|
+
)
|
|
406
|
+
cur = self.conn.execute(
|
|
407
|
+
"""INSERT INTO tasks (task_id, idempotency_key, state, attempt, slack_team_id, slack_channel_id, slack_thread_ts, slack_message_ts, slack_user_id, request_text, thread_context, classification_json, persona, schedule_name, parent_task_id, model_override, effort_override, debug_thread_ts, debug_permalink, cost_usd, num_turns, created_at, updated_at)
|
|
408
|
+
VALUES (?, ?, 'received', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)
|
|
409
|
+
ON CONFLICT(idempotency_key) DO NOTHING""",
|
|
410
|
+
(
|
|
411
|
+
task_id,
|
|
412
|
+
idempotency_key,
|
|
413
|
+
slack_team_id,
|
|
414
|
+
slack_channel_id,
|
|
415
|
+
slack_thread_ts,
|
|
416
|
+
slack_message_ts,
|
|
417
|
+
slack_user_id,
|
|
418
|
+
fields["request_text"],
|
|
419
|
+
fields["thread_context"],
|
|
420
|
+
fields["classification_json"],
|
|
421
|
+
persona,
|
|
422
|
+
schedule_name,
|
|
423
|
+
parent_task_id,
|
|
424
|
+
model_override,
|
|
425
|
+
effort_override,
|
|
426
|
+
debug_thread_ts,
|
|
427
|
+
debug_permalink,
|
|
428
|
+
now,
|
|
429
|
+
now,
|
|
430
|
+
),
|
|
431
|
+
)
|
|
432
|
+
created = cur.rowcount == 1
|
|
433
|
+
if created:
|
|
434
|
+
self._insert_event(task_id, "intake", {"user": slack_user_id, "channel": slack_channel_id})
|
|
435
|
+
self.conn.commit()
|
|
436
|
+
if created:
|
|
437
|
+
task = self.get_task(task_id)
|
|
438
|
+
assert task is not None
|
|
439
|
+
return task, True
|
|
440
|
+
row = self.conn.execute("SELECT * FROM tasks WHERE idempotency_key = ?", (idempotency_key,)).fetchone()
|
|
441
|
+
return Task(**dict(row)), False
|
|
442
|
+
|
|
443
|
+
def task_by_intake_key(self, team_id: str, channel_id: str, message_ts: str) -> Task | None:
|
|
444
|
+
"""the task a given intake (team, channel, message) created, if any — the same key create_task dedups on."""
|
|
445
|
+
row = self.conn.execute("SELECT * FROM tasks WHERE idempotency_key = ?", (f"{team_id}:{channel_id}:{message_ts}",)).fetchone()
|
|
446
|
+
return Task(**dict(row)) if row else None
|
|
447
|
+
|
|
448
|
+
def record_quick_answer(
|
|
449
|
+
self,
|
|
450
|
+
*,
|
|
451
|
+
slack_team_id: str,
|
|
452
|
+
slack_channel_id: str,
|
|
453
|
+
slack_thread_ts: str,
|
|
454
|
+
slack_message_ts: str,
|
|
455
|
+
slack_user_id: str,
|
|
456
|
+
request_text: str,
|
|
457
|
+
answer_text: str,
|
|
458
|
+
model_alias: str,
|
|
459
|
+
model_id: str,
|
|
460
|
+
parent_task_id: str | None = None,
|
|
461
|
+
latency_s: float = 0.0,
|
|
462
|
+
debug_thread_ts: str | None = None,
|
|
463
|
+
debug_permalink: str | None = None,
|
|
464
|
+
) -> Task:
|
|
465
|
+
"""record a quick answer as completed from birth so the dispatcher never sees it."""
|
|
466
|
+
idempotency_key = f"{slack_team_id}:{slack_channel_id}:{slack_message_ts}"
|
|
467
|
+
task_id = new_task_id()
|
|
468
|
+
now = utcnow()
|
|
469
|
+
fields = _redact_fields({"request_text": request_text, "result_summary": answer_text})
|
|
470
|
+
cur = self.conn.execute(
|
|
471
|
+
"""INSERT INTO tasks (task_id, idempotency_key, state, attempt, slack_team_id, slack_channel_id, slack_thread_ts, slack_message_ts, slack_user_id, request_text, parent_task_id, task_type, model_alias, model_id, routing_rationale, result_summary, reply, debug_thread_ts, debug_permalink, cost_usd, num_turns, created_at, started_at, finished_at, updated_at)
|
|
472
|
+
VALUES (?, ?, 'completed', 0, ?, ?, ?, ?, ?, ?, ?, 'question', ?, ?, 'quick-answer', ?, ?, ?, ?, 0, 0, ?, ?, ?, ?)
|
|
473
|
+
ON CONFLICT(idempotency_key) DO NOTHING""",
|
|
474
|
+
(task_id, idempotency_key, slack_team_id, slack_channel_id, slack_thread_ts, slack_message_ts, slack_user_id, fields["request_text"], parent_task_id, model_alias, model_id, fields["result_summary"], fields["result_summary"], debug_thread_ts, debug_permalink, now, now, now, now),
|
|
475
|
+
)
|
|
476
|
+
if cur.rowcount == 1:
|
|
477
|
+
self._insert_event(task_id, "intake", {"user": slack_user_id, "channel": slack_channel_id})
|
|
478
|
+
self._insert_event(task_id, "quick_answer", {"escalated": False, "latency_s": latency_s})
|
|
479
|
+
self.conn.commit()
|
|
480
|
+
task = self.get_task(task_id)
|
|
481
|
+
assert task is not None
|
|
482
|
+
return task
|
|
483
|
+
self.conn.rollback()
|
|
484
|
+
row = self.conn.execute("SELECT * FROM tasks WHERE idempotency_key = ?", (idempotency_key,)).fetchone()
|
|
485
|
+
return Task(**dict(row))
|
|
486
|
+
|
|
487
|
+
def get_task(self, task_id: str) -> Task | None:
|
|
488
|
+
row = self.conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
|
|
489
|
+
return Task(**dict(row)) if row else None
|
|
490
|
+
|
|
491
|
+
def tasks_in_state(self, state: str) -> list[Task]:
|
|
492
|
+
# rowid = insertion order; created_at alone is only second-precise
|
|
493
|
+
rows = self.conn.execute("SELECT * FROM tasks WHERE state = ? ORDER BY rowid", (state,)).fetchall()
|
|
494
|
+
return [Task(**dict(row)) for row in rows]
|
|
495
|
+
|
|
496
|
+
def next_queued(self) -> Task | None:
|
|
497
|
+
row = self.conn.execute("SELECT * FROM tasks WHERE state = ? AND (not_before IS NULL OR not_before <= ?) ORDER BY rowid LIMIT 1", (QUEUED, utcnow())).fetchone()
|
|
498
|
+
return Task(**dict(row)) if row else None
|
|
499
|
+
|
|
500
|
+
def count_tasks(self, state: str) -> int:
|
|
501
|
+
row = self.conn.execute("SELECT COUNT(*) AS n FROM tasks WHERE state = ?", (state,)).fetchone()
|
|
502
|
+
return int(row["n"])
|
|
503
|
+
|
|
504
|
+
def terminal_tasks_updated_before(self, cutoff_iso: str) -> list[Task]:
|
|
505
|
+
"""finished tasks whose workspaces are candidates for the retention sweep (MEM-012)."""
|
|
506
|
+
rows = self.conn.execute("SELECT * FROM tasks WHERE state IN ('completed','failed','cancelled') AND updated_at < ? AND workspace_path IS NOT NULL", (cutoff_iso,)).fetchall()
|
|
507
|
+
return [Task(**dict(row)) for row in rows]
|
|
508
|
+
|
|
509
|
+
def purge_slack_events(self, cutoff_iso: str) -> int:
|
|
510
|
+
cur = self.conn.execute("DELETE FROM slack_events WHERE received_at < ?", (cutoff_iso,))
|
|
511
|
+
self.conn.commit()
|
|
512
|
+
return cur.rowcount
|
|
513
|
+
|
|
514
|
+
def recent_tasks(self, limit: int = 20) -> list[Task]:
|
|
515
|
+
rows = self.conn.execute("SELECT * FROM tasks ORDER BY rowid DESC LIMIT ?", (limit,)).fetchall()
|
|
516
|
+
return [Task(**dict(row)) for row in rows]
|
|
517
|
+
|
|
518
|
+
def list_tasks(self, state: str | None = None, limit: int = 50, offset: int = 0, query: str | None = None, task_type: str | None = None) -> list[Task]:
|
|
519
|
+
clauses: list[str] = []
|
|
520
|
+
values: list[object] = []
|
|
521
|
+
if state:
|
|
522
|
+
clauses.append("state = ?")
|
|
523
|
+
values.append(state)
|
|
524
|
+
if task_type:
|
|
525
|
+
clauses.append("task_type = ?")
|
|
526
|
+
values.append(task_type)
|
|
527
|
+
if query:
|
|
528
|
+
clauses.append("(task_id LIKE ? OR request_text LIKE ?)")
|
|
529
|
+
pattern = f"%{query}%"
|
|
530
|
+
values.extend((pattern, pattern))
|
|
531
|
+
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
532
|
+
rows = self.conn.execute(f"SELECT * FROM tasks{where} ORDER BY rowid DESC LIMIT ? OFFSET ?", (*values, min(max(limit, 1), 200), max(offset, 0))).fetchall()
|
|
533
|
+
return [Task(**dict(row)) for row in rows]
|
|
534
|
+
|
|
535
|
+
def children_of(self, parent_task_id: str) -> list[Task]:
|
|
536
|
+
rows = self.conn.execute("SELECT * FROM tasks WHERE parent_task_id = ? ORDER BY rowid", (parent_task_id,)).fetchall()
|
|
537
|
+
return [Task(**dict(row)) for row in rows]
|
|
538
|
+
|
|
539
|
+
def task_counts(self) -> dict[str, int]:
|
|
540
|
+
rows = self.conn.execute("SELECT state, COUNT(*) AS n FROM tasks GROUP BY state").fetchall()
|
|
541
|
+
return {row["state"]: int(row["n"]) for row in rows}
|
|
542
|
+
|
|
543
|
+
def transition(self, task_id: str, from_state: str, to_state: str, reason: str = "", **fields) -> Task:
|
|
544
|
+
"""the only way a task changes state: guarded update + audit event in one transaction. raced updates fail loudly."""
|
|
545
|
+
if to_state not in ALLOWED_TRANSITIONS.get(from_state, []):
|
|
546
|
+
raise IllegalTransition(f"{from_state} -> {to_state} is not allowed")
|
|
547
|
+
unknown = set(fields) - UPDATABLE_FIELDS
|
|
548
|
+
if unknown:
|
|
549
|
+
raise ValueError(f"transition cannot update fields: {sorted(unknown)}")
|
|
550
|
+
fields = _redact_fields(fields)
|
|
551
|
+
sets = "".join(f", {name} = ?" for name in fields)
|
|
552
|
+
cur = self.conn.execute(
|
|
553
|
+
f"UPDATE tasks SET state = ?, updated_at = ?{sets} WHERE task_id = ? AND state = ?",
|
|
554
|
+
(to_state, utcnow(), *fields.values(), task_id, from_state),
|
|
555
|
+
)
|
|
556
|
+
if cur.rowcount == 0:
|
|
557
|
+
self.conn.rollback()
|
|
558
|
+
current = self.get_task(task_id)
|
|
559
|
+
raise TransitionRaced(f"task {task_id} is {current.state if current else 'missing'}, expected {from_state}")
|
|
560
|
+
self._insert_event(task_id, "state_change", {"from": from_state, "to": to_state, "reason": reason})
|
|
561
|
+
self.conn.commit()
|
|
562
|
+
task = self.get_task(task_id)
|
|
563
|
+
assert task is not None
|
|
564
|
+
if to_state in TERMINAL_STATES:
|
|
565
|
+
# a coordinator holds its issue batch from before its own task existed; no terminal task keeps one
|
|
566
|
+
self.release_reserved_issues(task_id)
|
|
567
|
+
# a no-op if the issue already left in_progress on its own (e.g. finish_issue -> in_review) (#76)
|
|
568
|
+
self.reopen_linked_issue(task)
|
|
569
|
+
return task
|
|
570
|
+
|
|
571
|
+
def set_fields(self, task_id: str, **fields) -> None:
|
|
572
|
+
"""update task columns without a state change — e.g. persisting the sdk session id the moment it appears."""
|
|
573
|
+
unknown = set(fields) - UPDATABLE_FIELDS
|
|
574
|
+
if unknown:
|
|
575
|
+
raise ValueError(f"set_fields cannot update fields: {sorted(unknown)}")
|
|
576
|
+
fields = _redact_fields(fields)
|
|
577
|
+
sets = ", ".join(f"{name} = ?" for name in fields)
|
|
578
|
+
self.conn.execute(f"UPDATE tasks SET updated_at = ?, {sets} WHERE task_id = ?", (utcnow(), *fields.values(), task_id))
|
|
579
|
+
self.conn.commit()
|
|
580
|
+
|
|
581
|
+
def latest_task_in_thread(self, channel_id: str, thread_ts: str) -> Task | None:
|
|
582
|
+
"""most recent task rooted at this slack thread — the lineage anchor for follow-up mentions (MEM-009/010)."""
|
|
583
|
+
row = self.conn.execute("SELECT * FROM tasks WHERE slack_channel_id = ? AND slack_thread_ts = ? ORDER BY rowid DESC LIMIT 1", (channel_id, thread_ts)).fetchone()
|
|
584
|
+
return Task(**dict(row)) if row else None
|
|
585
|
+
|
|
586
|
+
# -- slack dedup / intake authz --------------------------------------------
|
|
587
|
+
|
|
588
|
+
def slack_event_seen(self, event_id: str) -> bool:
|
|
589
|
+
"""true if this event id was already recorded (SLK-008)."""
|
|
590
|
+
cur = self.conn.execute("INSERT OR IGNORE INTO slack_events (event_id, received_at) VALUES (?, ?)", (event_id, utcnow()))
|
|
591
|
+
self.conn.commit()
|
|
592
|
+
return cur.rowcount == 0
|
|
593
|
+
|
|
594
|
+
def record_intake_denial(self, team_id: str, channel_id: str, user_id: str, reason: str) -> None:
|
|
595
|
+
self.conn.execute("INSERT INTO intake_denials (ts, team_id, channel_id, user_id, reason) VALUES (?, ?, ?, ?, ?)", (utcnow(), team_id, channel_id, user_id, reason))
|
|
596
|
+
self.conn.commit()
|
|
597
|
+
|
|
598
|
+
# -- audit / artifacts / usage -------------------------------------------
|
|
599
|
+
|
|
600
|
+
def _insert_event(self, task_id: str, kind: str, detail: dict, tool_name: str | None = None, is_write: bool | None = None) -> None:
|
|
601
|
+
# two connections write the chain (orchestrator + dashboard/cli); lock before reading prev hash
|
|
602
|
+
if not self.conn.in_transaction:
|
|
603
|
+
self.conn.execute("BEGIN IMMEDIATE")
|
|
604
|
+
previous = self.conn.execute("SELECT hash FROM task_events ORDER BY id DESC LIMIT 1").fetchone()
|
|
605
|
+
prev_hash = (previous["hash"] if previous else None) or ""
|
|
606
|
+
ts = utcnow()
|
|
607
|
+
detail_json = redactor.redact(json.dumps(detail))
|
|
608
|
+
self.conn.execute(
|
|
609
|
+
"INSERT INTO task_events (task_id, ts, kind, tool_name, is_write, detail_json, hash) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
610
|
+
(task_id, ts, kind, tool_name, None if is_write is None else int(is_write), detail_json, event_hash(prev_hash, ts, task_id, kind, tool_name, detail_json)),
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
def add_event(self, task_id: str, kind: str, detail: dict, tool_name: str | None = None, is_write: bool | None = None) -> None:
|
|
614
|
+
self._insert_event(task_id, kind, detail, tool_name, is_write)
|
|
615
|
+
self.conn.commit()
|
|
616
|
+
|
|
617
|
+
def events_for(self, task_id: str, limit: int | None = None, offset: int = 0) -> list[dict]:
|
|
618
|
+
if limit is None:
|
|
619
|
+
rows = self.conn.execute("SELECT * FROM task_events WHERE task_id = ? ORDER BY id", (task_id,)).fetchall()
|
|
620
|
+
else:
|
|
621
|
+
rows = self.conn.execute("SELECT * FROM task_events WHERE task_id = ? ORDER BY id LIMIT ? OFFSET ?", (task_id, min(max(limit, 1), 1000), max(offset, 0))).fetchall()
|
|
622
|
+
return [dict(row) for row in rows]
|
|
623
|
+
|
|
624
|
+
def event_count(self, task_id: str) -> int:
|
|
625
|
+
row = self.conn.execute("SELECT COUNT(*) AS n FROM task_events WHERE task_id = ?", (task_id,)).fetchone()
|
|
626
|
+
return int(row["n"])
|
|
627
|
+
|
|
628
|
+
def events_for_kinds(self, task_id: str, kinds: set[str], limit: int = 500) -> list[dict]:
|
|
629
|
+
if not kinds:
|
|
630
|
+
return []
|
|
631
|
+
placeholders = ",".join("?" for _ in kinds)
|
|
632
|
+
rows = self.conn.execute(f"SELECT * FROM task_events WHERE task_id = ? AND kind IN ({placeholders}) ORDER BY id LIMIT ?", (task_id, *sorted(kinds), min(max(limit, 1), 1000))).fetchall()
|
|
633
|
+
return [dict(row) for row in rows]
|
|
634
|
+
|
|
635
|
+
def last_event_ts(self, task_id: str, kind: str, field: str, value: str) -> str | None:
|
|
636
|
+
row = self.conn.execute(
|
|
637
|
+
"SELECT ts FROM task_events WHERE task_id = ? AND kind = ? AND json_extract(detail_json, '$.' || ?) = ? ORDER BY id DESC LIMIT 1",
|
|
638
|
+
(task_id, kind, field, value),
|
|
639
|
+
).fetchone()
|
|
640
|
+
return str(row["ts"]) if row else None
|
|
641
|
+
|
|
642
|
+
def events_after(self, after_id: int, limit: int = 10000) -> list[dict]:
|
|
643
|
+
rows = self.conn.execute("SELECT * FROM task_events WHERE id > ? ORDER BY id LIMIT ?", (after_id, limit)).fetchall()
|
|
644
|
+
return [dict(row) for row in rows]
|
|
645
|
+
|
|
646
|
+
def latest_event_id(self) -> int:
|
|
647
|
+
row = self.conn.execute("SELECT COALESCE(MAX(id), 0) AS n FROM task_events").fetchone()
|
|
648
|
+
return int(row["n"])
|
|
649
|
+
|
|
650
|
+
def add_artifact(self, task_id: str, kind: str, external_id: str, url: str | None = None) -> bool:
|
|
651
|
+
"""true if newly recorded; false if this artifact was already known (retry-safe, ORC-012)."""
|
|
652
|
+
cur = self.conn.execute("INSERT OR IGNORE INTO artifacts (task_id, kind, external_id, url, created_at) VALUES (?, ?, ?, ?, ?)", (task_id, kind, external_id, url, utcnow()))
|
|
653
|
+
self.conn.commit()
|
|
654
|
+
return cur.rowcount == 1
|
|
655
|
+
|
|
656
|
+
def artifacts_for(self, task_id: str) -> list[dict]:
|
|
657
|
+
rows = self.conn.execute("SELECT * FROM artifacts WHERE task_id = ? ORDER BY id", (task_id,)).fetchall()
|
|
658
|
+
return [dict(row) for row in rows]
|
|
659
|
+
|
|
660
|
+
def add_usage(self, task_id: str, source: str, model: str, input_tokens: int | None = None, output_tokens: int | None = None, cache_read_tokens: int | None = None, cache_write_tokens: int | None = None, cost_usd: float | None = None) -> None:
|
|
661
|
+
self.conn.execute(
|
|
662
|
+
"INSERT INTO usage (task_id, ts, source, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_usd) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
663
|
+
(task_id, utcnow(), source, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_usd),
|
|
664
|
+
)
|
|
665
|
+
self.conn.commit()
|
|
666
|
+
|
|
667
|
+
def record_rate_limit(self, rate_limit_type: str, status: str, utilization: float | None, resets_at: int | None) -> None:
|
|
668
|
+
self.conn.execute(
|
|
669
|
+
"""INSERT INTO rate_limits (rate_limit_type, status, utilization, resets_at, observed_at)
|
|
670
|
+
VALUES (?, ?, ?, ?, ?)
|
|
671
|
+
ON CONFLICT(rate_limit_type) DO UPDATE SET
|
|
672
|
+
status = excluded.status,
|
|
673
|
+
utilization = excluded.utilization,
|
|
674
|
+
resets_at = excluded.resets_at,
|
|
675
|
+
observed_at = excluded.observed_at""",
|
|
676
|
+
(rate_limit_type, status, utilization, resets_at, utcnow()),
|
|
677
|
+
)
|
|
678
|
+
self.conn.commit()
|
|
679
|
+
|
|
680
|
+
def rate_limit_windows(self) -> list[dict]:
|
|
681
|
+
rows = self.conn.execute("SELECT * FROM rate_limits ORDER BY rate_limit_type").fetchall()
|
|
682
|
+
return [dict(row) for row in rows]
|
|
683
|
+
|
|
684
|
+
def usage_for(self, task_id: str) -> list[dict]:
|
|
685
|
+
rows = self.conn.execute("SELECT * FROM usage WHERE task_id = ? ORDER BY id", (task_id,)).fetchall()
|
|
686
|
+
return [dict(row) for row in rows]
|
|
687
|
+
|
|
688
|
+
def usage_totals(self, since_iso: str | None = None, model: str | None = None) -> dict:
|
|
689
|
+
clauses: list[str] = []
|
|
690
|
+
values: list[object] = []
|
|
691
|
+
if since_iso:
|
|
692
|
+
clauses.append("ts >= ?")
|
|
693
|
+
values.append(since_iso)
|
|
694
|
+
if model:
|
|
695
|
+
clauses.append("model = ?")
|
|
696
|
+
values.append(model)
|
|
697
|
+
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
698
|
+
row = self.conn.execute(
|
|
699
|
+
f"""SELECT COUNT(*) AS rows, COUNT(DISTINCT task_id) AS task_count,
|
|
700
|
+
COALESCE(SUM(input_tokens), 0) AS input_tokens,
|
|
701
|
+
COALESCE(SUM(output_tokens), 0) AS output_tokens,
|
|
702
|
+
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
|
|
703
|
+
COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens,
|
|
704
|
+
COALESCE(SUM(cost_usd), 0) AS cost_usd, MAX(ts) AS last_updated
|
|
705
|
+
FROM usage{where}""",
|
|
706
|
+
values,
|
|
707
|
+
).fetchone()
|
|
708
|
+
return dict(row)
|
|
709
|
+
|
|
710
|
+
def usage_by_model(self, since_iso: str | None = None, model: str | None = None) -> list[dict]:
|
|
711
|
+
clauses: list[str] = []
|
|
712
|
+
values: list[object] = []
|
|
713
|
+
if since_iso:
|
|
714
|
+
clauses.append("ts >= ?")
|
|
715
|
+
values.append(since_iso)
|
|
716
|
+
if model:
|
|
717
|
+
clauses.append("model = ?")
|
|
718
|
+
values.append(model)
|
|
719
|
+
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
720
|
+
rows = self.conn.execute(
|
|
721
|
+
f"""SELECT model, COALESCE(SUM(input_tokens), 0) AS input_tokens,
|
|
722
|
+
COALESCE(SUM(output_tokens), 0) AS output_tokens,
|
|
723
|
+
COALESCE(SUM(cache_read_tokens), 0) + COALESCE(SUM(cache_write_tokens), 0) AS cache_tokens,
|
|
724
|
+
COALESCE(SUM(cost_usd), 0) AS cost_usd
|
|
725
|
+
FROM usage{where} GROUP BY model ORDER BY input_tokens + output_tokens + cache_tokens DESC""",
|
|
726
|
+
values,
|
|
727
|
+
).fetchall()
|
|
728
|
+
return [dict(row) for row in rows]
|
|
729
|
+
|
|
730
|
+
def usage_timeseries(self, since_iso: str, bucket_hours: int = 1) -> list[dict]:
|
|
731
|
+
"""hourly-bucketed totals per model for charts; ts is iso so substr gives the hour."""
|
|
732
|
+
rows = self.conn.execute(
|
|
733
|
+
"""SELECT substr(ts, 1, 13) AS bucket, model,
|
|
734
|
+
COALESCE(SUM(input_tokens), 0) + COALESCE(SUM(output_tokens), 0)
|
|
735
|
+
+ COALESCE(SUM(cache_read_tokens), 0) + COALESCE(SUM(cache_write_tokens), 0) AS total_tokens,
|
|
736
|
+
COALESCE(SUM(output_tokens), 0) AS output_tokens,
|
|
737
|
+
COALESCE(SUM(cost_usd), 0) AS cost_usd
|
|
738
|
+
FROM usage WHERE ts >= ? GROUP BY bucket, model ORDER BY bucket""",
|
|
739
|
+
(since_iso,),
|
|
740
|
+
).fetchall()
|
|
741
|
+
return [dict(row) for row in rows]
|
|
742
|
+
|
|
743
|
+
# -- permission requests -------------------------------------------------
|
|
744
|
+
|
|
745
|
+
def request_permission(self, task_id: str, kind: str, target: str, reason: str) -> dict:
|
|
746
|
+
"""record (or re-open) a sub-agent's request for one additional tool or repo. retry-safe: a repeat resets it to pending."""
|
|
747
|
+
now = utcnow()
|
|
748
|
+
self.conn.execute(
|
|
749
|
+
"""INSERT INTO permission_requests (task_id, kind, target, reason, status, requested_at)
|
|
750
|
+
VALUES (?, ?, ?, ?, 'pending', ?)
|
|
751
|
+
ON CONFLICT(task_id, kind, target) DO UPDATE SET
|
|
752
|
+
reason = excluded.reason, status = 'pending', requested_at = excluded.requested_at, decided_by = NULL, decided_at = NULL""",
|
|
753
|
+
(task_id, kind, target, redactor.redact(reason), now),
|
|
754
|
+
)
|
|
755
|
+
self.conn.commit()
|
|
756
|
+
row = self.conn.execute("SELECT * FROM permission_requests WHERE task_id = ? AND kind = ? AND target = ?", (task_id, kind, target)).fetchone()
|
|
757
|
+
return dict(row)
|
|
758
|
+
|
|
759
|
+
def permission_requests_for(self, task_id: str) -> list[dict]:
|
|
760
|
+
rows = self.conn.execute("SELECT * FROM permission_requests WHERE task_id = ? ORDER BY id", (task_id,)).fetchall()
|
|
761
|
+
return [dict(row) for row in rows]
|
|
762
|
+
|
|
763
|
+
def has_pending_permission_request(self, task_id: str) -> bool:
|
|
764
|
+
"""true while a request is awaiting a decision — the orchestrator leaves such a task BLOCKED (not cancelled) so decide_permission's resume path still works."""
|
|
765
|
+
return self.conn.execute("SELECT 1 FROM permission_requests WHERE task_id = ? AND status = 'pending' LIMIT 1", (task_id,)).fetchone() is not None
|
|
766
|
+
|
|
767
|
+
def decide_permission_request(self, task_id: str, kind: str, target: str, status: str, actor: str) -> dict | None:
|
|
768
|
+
"""grant or deny a pending request; returns the updated row, or None when nothing was pending (already decided or unknown)."""
|
|
769
|
+
if status not in ("granted", "denied"):
|
|
770
|
+
raise ValueError(f"permission decision must be 'granted' or 'denied', got {status!r}")
|
|
771
|
+
cur = self.conn.execute(
|
|
772
|
+
"UPDATE permission_requests SET status = ?, decided_by = ?, decided_at = ? WHERE task_id = ? AND kind = ? AND target = ? AND status = 'pending'",
|
|
773
|
+
(status, actor, utcnow(), task_id, kind, target),
|
|
774
|
+
)
|
|
775
|
+
self.conn.commit()
|
|
776
|
+
if cur.rowcount == 0:
|
|
777
|
+
return None
|
|
778
|
+
row = self.conn.execute("SELECT * FROM permission_requests WHERE task_id = ? AND kind = ? AND target = ?", (task_id, kind, target)).fetchone()
|
|
779
|
+
return dict(row)
|
|
780
|
+
|
|
781
|
+
def granted_permissions_for(self, task_id: str) -> dict[str, list[str]]:
|
|
782
|
+
"""tools and repos an operator has granted this task — the runner merges them into the session's scope on (re)start."""
|
|
783
|
+
rows = self.conn.execute("SELECT kind, target FROM permission_requests WHERE task_id = ? AND status = 'granted' ORDER BY id", (task_id,)).fetchall()
|
|
784
|
+
result: dict[str, list[str]] = {"tools": [], "repos": []}
|
|
785
|
+
for row in rows:
|
|
786
|
+
bucket = "tools" if row["kind"] == "tool" else "repos"
|
|
787
|
+
if row["target"] not in result[bucket]:
|
|
788
|
+
result[bucket].append(row["target"])
|
|
789
|
+
return result
|
|
790
|
+
|
|
791
|
+
# -- follow-up questions ---------------------------------------------------
|
|
792
|
+
|
|
793
|
+
def ask_questions(self, task_id: str, questions: str) -> dict:
|
|
794
|
+
"""record one round of follow-up questions for the requester."""
|
|
795
|
+
cur = self.conn.execute("INSERT INTO task_questions (task_id, questions, asked_at) VALUES (?, ?, ?)", (task_id, redactor.redact(questions), utcnow()))
|
|
796
|
+
self.conn.commit()
|
|
797
|
+
row = self.conn.execute("SELECT * FROM task_questions WHERE id = ?", (cur.lastrowid,)).fetchone()
|
|
798
|
+
return dict(row)
|
|
799
|
+
|
|
800
|
+
def pending_questions_for(self, task_id: str) -> dict | None:
|
|
801
|
+
"""the newest unanswered question round, if any — the thing a thread reply would be answering."""
|
|
802
|
+
row = self.conn.execute("SELECT * FROM task_questions WHERE task_id = ? AND answered_at IS NULL ORDER BY id DESC LIMIT 1", (task_id,)).fetchone()
|
|
803
|
+
return dict(row) if row else None
|
|
804
|
+
|
|
805
|
+
def answer_questions(self, task_id: str, answer_text: str, answered_by: str) -> dict | None:
|
|
806
|
+
"""attach the requester's answer to the pending round; returns None when nothing was pending."""
|
|
807
|
+
pending = self.pending_questions_for(task_id)
|
|
808
|
+
if pending is None:
|
|
809
|
+
return None
|
|
810
|
+
self.conn.execute("UPDATE task_questions SET answer_text = ?, answered_by = ?, answered_at = ? WHERE id = ?", (redactor.redact(answer_text), answered_by, utcnow(), pending["id"]))
|
|
811
|
+
self.conn.commit()
|
|
812
|
+
row = self.conn.execute("SELECT * FROM task_questions WHERE id = ?", (pending["id"],)).fetchone()
|
|
813
|
+
return dict(row)
|
|
814
|
+
|
|
815
|
+
def answered_questions_for(self, task_id: str) -> list[dict]:
|
|
816
|
+
"""all answered rounds in order — the runner replays them into the prompt on (re)start."""
|
|
817
|
+
rows = self.conn.execute("SELECT * FROM task_questions WHERE task_id = ? AND answered_at IS NOT NULL ORDER BY id", (task_id,)).fetchall()
|
|
818
|
+
return [dict(row) for row in rows]
|
|
819
|
+
|
|
820
|
+
def questions_for(self, task_id: str) -> list[dict]:
|
|
821
|
+
rows = self.conn.execute("SELECT * FROM task_questions WHERE task_id = ? ORDER BY id", (task_id,)).fetchall()
|
|
822
|
+
return [dict(row) for row in rows]
|
|
823
|
+
|
|
824
|
+
# -- task feedback (dashboard input, read by the issues agent) --
|
|
825
|
+
|
|
826
|
+
def add_feedback(self, task_id: str, submitted_by: str, rating: int, comment: str | None = None) -> dict:
|
|
827
|
+
"""one row per (task, reviewer); resubmitting revises the earlier rating."""
|
|
828
|
+
now = utcnow()
|
|
829
|
+
self.conn.execute(
|
|
830
|
+
"""INSERT INTO task_feedback (task_id, submitted_by, rating, comment, created_at, updated_at)
|
|
831
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
832
|
+
ON CONFLICT(task_id, submitted_by) DO UPDATE SET rating = excluded.rating, comment = excluded.comment, updated_at = excluded.updated_at""",
|
|
833
|
+
(task_id, submitted_by, rating, redactor.redact(comment) if comment else None, now, now),
|
|
834
|
+
)
|
|
835
|
+
self._insert_event(task_id, "feedback", {"by": submitted_by, "rating": rating})
|
|
836
|
+
self.conn.commit()
|
|
837
|
+
row = self.conn.execute("SELECT * FROM task_feedback WHERE task_id = ? AND submitted_by = ?", (task_id, submitted_by)).fetchone()
|
|
838
|
+
return dict(row)
|
|
839
|
+
|
|
840
|
+
def feedback_for(self, task_id: str) -> list[dict]:
|
|
841
|
+
rows = self.conn.execute("SELECT * FROM task_feedback WHERE task_id = ? ORDER BY id", (task_id,)).fetchall()
|
|
842
|
+
return [dict(row) for row in rows]
|
|
843
|
+
|
|
844
|
+
def recent_feedback(self, limit: int = 100, offset: int = 0) -> list[dict]:
|
|
845
|
+
rows = self.conn.execute("SELECT * FROM task_feedback ORDER BY updated_at DESC, id DESC LIMIT ? OFFSET ?", (min(max(limit, 1), 1000), max(offset, 0))).fetchall()
|
|
846
|
+
return [dict(row) for row in rows]
|
|
847
|
+
|
|
848
|
+
# -- issues (issues loop: discovery -> approval -> implementation) --
|
|
849
|
+
|
|
850
|
+
def record_issue(self, dedupe_key: str, repo: str, summary: str, issue_type: str, details: str, priority: int, source: dict | None = None) -> dict:
|
|
851
|
+
"""record (or refresh) a proposed issue. re-running discovery updates the content and priority of an
|
|
852
|
+
existing proposal without disturbing an operator's decision or an in-flight implementation."""
|
|
853
|
+
now = utcnow()
|
|
854
|
+
source_json = redactor.redact(json.dumps(source, default=str)) if source is not None else None
|
|
855
|
+
self.conn.execute(
|
|
856
|
+
"""INSERT INTO issues (dedupe_key, repo, summary, issue_type, details, priority, source_json, created_at, updated_at)
|
|
857
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
858
|
+
ON CONFLICT(dedupe_key) DO UPDATE SET
|
|
859
|
+
repo = excluded.repo, summary = excluded.summary, issue_type = excluded.issue_type,
|
|
860
|
+
details = excluded.details, priority = excluded.priority, source_json = excluded.source_json,
|
|
861
|
+
updated_at = excluded.updated_at
|
|
862
|
+
WHERE issues.status = 'proposed'""",
|
|
863
|
+
(dedupe_key, repo, redactor.redact(summary[:300]), issue_type[:60], redactor.redact(details[:4000]), _clamp_priority(priority), source_json, now, now),
|
|
864
|
+
)
|
|
865
|
+
self.conn.commit()
|
|
866
|
+
row = self.conn.execute("SELECT * FROM issues WHERE dedupe_key = ?", (dedupe_key,)).fetchone()
|
|
867
|
+
return dict(row)
|
|
868
|
+
|
|
869
|
+
def list_issues(self, status: str | None = None, limit: int = 500, offset: int = 0, repo: str | None = None) -> list[dict]:
|
|
870
|
+
"""issues ordered most-important first; rank is this ordering, so a new higher-priority item shifts the rest."""
|
|
871
|
+
limit = min(max(limit, 1), 1000)
|
|
872
|
+
offset = max(offset, 0)
|
|
873
|
+
clauses: list[str] = []
|
|
874
|
+
values: list[object] = []
|
|
875
|
+
if status:
|
|
876
|
+
clauses.append("status = ?")
|
|
877
|
+
values.append(status)
|
|
878
|
+
if repo:
|
|
879
|
+
clauses.append("repo = ?")
|
|
880
|
+
values.append(repo)
|
|
881
|
+
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
882
|
+
rows = self.conn.execute(f"SELECT * FROM issues{where} ORDER BY priority DESC, id LIMIT ? OFFSET ?", (*values, limit, offset)).fetchall()
|
|
883
|
+
return [dict(row) for row in rows]
|
|
884
|
+
|
|
885
|
+
def get_issue(self, issue_id: int) -> dict | None:
|
|
886
|
+
row = self.conn.execute("SELECT * FROM issues WHERE id = ?", (issue_id,)).fetchone()
|
|
887
|
+
return dict(row) if row else None
|
|
888
|
+
|
|
889
|
+
def decide_issue(self, issue_id: int, status: str, actor: str) -> dict | None:
|
|
890
|
+
"""operator approve/deny from the dashboard. only a not-yet-implemented row may be re-decided; returns None otherwise."""
|
|
891
|
+
if status not in ("approved", "denied", "proposed"):
|
|
892
|
+
raise ValueError(f"issue decision must be approved, denied, or proposed, got {status!r}")
|
|
893
|
+
cur = self.conn.execute(
|
|
894
|
+
"UPDATE issues SET status = ?, decided_by = ?, decided_at = ?, updated_at = ? WHERE id = ? AND status IN ('proposed','approved','denied')",
|
|
895
|
+
(status, actor, utcnow(), utcnow(), issue_id),
|
|
896
|
+
)
|
|
897
|
+
self.conn.commit()
|
|
898
|
+
if cur.rowcount == 0:
|
|
899
|
+
return None
|
|
900
|
+
return self.get_issue(issue_id)
|
|
901
|
+
|
|
902
|
+
def update_issue(self, issue_id: int, summary: str, details: str, priority: int | None = None) -> dict | None:
|
|
903
|
+
"""operator edit from the dashboard: correct the summary/details before it is decided or implemented."""
|
|
904
|
+
if priority is None:
|
|
905
|
+
current = self.get_issue(issue_id)
|
|
906
|
+
if current is None:
|
|
907
|
+
return None
|
|
908
|
+
priority = current["priority"]
|
|
909
|
+
cur = self.conn.execute(
|
|
910
|
+
"UPDATE issues SET summary = ?, details = ?, priority = ?, updated_at = ? WHERE id = ? AND status IN ('proposed','approved')",
|
|
911
|
+
(redactor.redact(summary[:300]), redactor.redact(details[:4000]), _clamp_priority(priority), utcnow(), issue_id),
|
|
912
|
+
)
|
|
913
|
+
self.conn.commit()
|
|
914
|
+
if cur.rowcount == 0:
|
|
915
|
+
return None
|
|
916
|
+
return self.get_issue(issue_id)
|
|
917
|
+
|
|
918
|
+
def set_issue_priority(self, issue_id: int, priority: int) -> dict | None:
|
|
919
|
+
"""set priority while an issue is still editable; callers validate the 1-100 input before clamping."""
|
|
920
|
+
cur = self.conn.execute(
|
|
921
|
+
"UPDATE issues SET priority = ?, updated_at = ? WHERE id = ? AND status IN ('proposed','approved')",
|
|
922
|
+
(_clamp_priority(priority), utcnow(), issue_id),
|
|
923
|
+
)
|
|
924
|
+
self.conn.commit()
|
|
925
|
+
if cur.rowcount == 0:
|
|
926
|
+
return None
|
|
927
|
+
return self.get_issue(issue_id)
|
|
928
|
+
|
|
929
|
+
def delete_issue(self, issue_id: int) -> dict | None:
|
|
930
|
+
"""hard-delete the issue with its comments/attachments; blocked while in_progress/in_review."""
|
|
931
|
+
row = self.get_issue(issue_id)
|
|
932
|
+
if row is None or row["status"] in ("in_progress", "in_review"):
|
|
933
|
+
return None
|
|
934
|
+
self.conn.execute("DELETE FROM issue_attachments WHERE issue_id = ?", (issue_id,))
|
|
935
|
+
self.conn.execute("DELETE FROM issue_comments WHERE issue_id = ?", (issue_id,))
|
|
936
|
+
cur = self.conn.execute("DELETE FROM issues WHERE id = ?", (issue_id,))
|
|
937
|
+
self.conn.commit()
|
|
938
|
+
if cur.rowcount == 0:
|
|
939
|
+
return None
|
|
940
|
+
return row
|
|
941
|
+
|
|
942
|
+
def start_issue(self, issue_id: int, task_id: str | None, spec: str) -> dict | None:
|
|
943
|
+
"""the implementation agent claims a reserved issue: store its spec and (once known) link the PR task it
|
|
944
|
+
enqueued. plain 'approved' is still accepted too, for the legacy manual-enqueue path."""
|
|
945
|
+
cur = self.conn.execute(
|
|
946
|
+
"UPDATE issues SET status = 'in_progress', spec = ?, task_id = ?, updated_at = ? WHERE id = ? AND status IN ('approved','implementation_queued')",
|
|
947
|
+
(redactor.redact(spec), task_id, utcnow(), issue_id),
|
|
948
|
+
)
|
|
949
|
+
self.conn.commit()
|
|
950
|
+
if cur.rowcount == 0:
|
|
951
|
+
return None
|
|
952
|
+
return self.get_issue(issue_id)
|
|
953
|
+
|
|
954
|
+
def link_issue_task(self, issue_id: int, task_id: str) -> None:
|
|
955
|
+
"""attach the spec2pr child task id to an issue already claimed by start_issue."""
|
|
956
|
+
self.conn.execute("UPDATE issues SET task_id = ?, updated_at = ? WHERE id = ?", (task_id, utcnow(), issue_id))
|
|
957
|
+
self.conn.commit()
|
|
958
|
+
|
|
959
|
+
def issue_for_task(self, task_id: str) -> dict | None:
|
|
960
|
+
"""the issue this task is implementing, if any."""
|
|
961
|
+
row = self.conn.execute("SELECT * FROM issues WHERE task_id = ?", (task_id,)).fetchone()
|
|
962
|
+
return dict(row) if row else None
|
|
963
|
+
|
|
964
|
+
def _reopen_issue_row(self, issue_id: int, comment: str) -> dict | None:
|
|
965
|
+
"""shared UPDATE behind reopen_linked_issue and the untracked-issue path in reopen_stranded_issues."""
|
|
966
|
+
cur = self.conn.execute(
|
|
967
|
+
"""UPDATE issues SET status = 'proposed', task_id = NULL, spec = NULL, reserved_by = NULL,
|
|
968
|
+
decided_by = NULL, decided_at = NULL, updated_at = ?
|
|
969
|
+
WHERE id = ? AND status = 'in_progress'""",
|
|
970
|
+
(utcnow(), issue_id),
|
|
971
|
+
)
|
|
972
|
+
self.conn.commit()
|
|
973
|
+
if cur.rowcount == 0:
|
|
974
|
+
return None
|
|
975
|
+
reopened = self.get_issue(issue_id)
|
|
976
|
+
assert reopened is not None
|
|
977
|
+
self.add_issue_comment(issue_id, "agent", comment)
|
|
978
|
+
return reopened
|
|
979
|
+
|
|
980
|
+
def reopen_linked_issue(self, task: Task) -> dict | None:
|
|
981
|
+
"""hand a task's linked issue back to `proposed`, or track its PR instead of reopening if one was already opened (#87)."""
|
|
982
|
+
issue = self.issue_for_task(task.task_id)
|
|
983
|
+
if issue is None:
|
|
984
|
+
return None
|
|
985
|
+
reason = task.error or task.blocked_reason or "it never recorded an outcome"
|
|
986
|
+
parts = [f"task `{task.task_id}` did not finish: {reason}"]
|
|
987
|
+
pending = self.pending_questions_for(task.task_id)
|
|
988
|
+
if pending is not None:
|
|
989
|
+
parts.append(f"Open questions:\n{pending['questions']}")
|
|
990
|
+
# a task can touch more than one approved repo, so match this issue's repo; a close-then-recreate leaves
|
|
991
|
+
# an earlier closed-pr artifact alongside the new one, so take the most recently recorded match (#88)
|
|
992
|
+
matching_pr_artifacts = [a for a in self.artifacts_for(task.task_id) if a["kind"] == "pull_request" and a["url"] and a["external_id"].startswith(f"{issue['repo']}#")]
|
|
993
|
+
pr_artifact = matching_pr_artifacts[-1] if matching_pr_artifacts else None
|
|
994
|
+
if pr_artifact is not None:
|
|
995
|
+
if issue["status"] != "in_progress":
|
|
996
|
+
return None
|
|
997
|
+
# so issue_runs.sync_in_review's merge/close check decides its outcome instead of a blind reopen
|
|
998
|
+
tracked = self.finish_issue(issue["id"], "in_review", pr_artifact["url"])
|
|
999
|
+
if tracked is None:
|
|
1000
|
+
return None
|
|
1001
|
+
parts.append(f"it had already opened {pr_artifact['url']} — tracking that PR instead of reopening this issue")
|
|
1002
|
+
self.add_issue_comment(issue["id"], "agent", "\n\n".join(parts))
|
|
1003
|
+
return tracked
|
|
1004
|
+
return self._reopen_issue_row(issue["id"], "\n\n".join(parts))
|
|
1005
|
+
|
|
1006
|
+
def reopen_stranded_issues(self) -> dict[str, int]:
|
|
1007
|
+
"""startup safety net: resolves in_progress issues left behind by a task that finished terminally before
|
|
1008
|
+
transition()'s hook could catch it, or with no linked task at all. counts split by actual outcome —
|
|
1009
|
+
'reopened' back to proposed vs. 'tracked' as in_review behind an already-open PR (#87) — so a caller logging
|
|
1010
|
+
the result doesn't claim a reopen for a row that was actually left tracking a PR."""
|
|
1011
|
+
rows = self.conn.execute(
|
|
1012
|
+
"""SELECT issues.id AS issue_id, issues.task_id FROM issues
|
|
1013
|
+
LEFT JOIN tasks ON tasks.task_id = issues.task_id
|
|
1014
|
+
WHERE issues.status = 'in_progress'
|
|
1015
|
+
AND (issues.task_id IS NULL OR tasks.state IN ('failed', 'cancelled', 'refused', 'completed'))"""
|
|
1016
|
+
).fetchall()
|
|
1017
|
+
counts = {"reopened": 0, "tracked": 0}
|
|
1018
|
+
for row in rows:
|
|
1019
|
+
if row["task_id"] is None:
|
|
1020
|
+
if self._reopen_issue_row(row["issue_id"], "reopened: left in_progress with no linked task") is not None:
|
|
1021
|
+
counts["reopened"] += 1
|
|
1022
|
+
continue
|
|
1023
|
+
task = self.get_task(row["task_id"])
|
|
1024
|
+
if task is None:
|
|
1025
|
+
continue
|
|
1026
|
+
result = self.reopen_linked_issue(task)
|
|
1027
|
+
if result is not None:
|
|
1028
|
+
counts["tracked" if result["status"] == "in_review" else "reopened"] += 1
|
|
1029
|
+
return counts
|
|
1030
|
+
|
|
1031
|
+
def active_implementation_run(self) -> str | None:
|
|
1032
|
+
"""the id of a not-yet-terminal /implementapprovedissues coordinator task, if one exists. used to keep the
|
|
1033
|
+
dashboard button disabled and repeated clicks from starting a second coordinator."""
|
|
1034
|
+
row = self.conn.execute("SELECT task_id FROM tasks WHERE request_text LIKE '/implementapprovedissues%' AND state IN ('received','queued','running','blocked') ORDER BY created_at DESC LIMIT 1").fetchone()
|
|
1035
|
+
return row["task_id"] if row else None
|
|
1036
|
+
|
|
1037
|
+
def blocked_implementation_run(self) -> str | None:
|
|
1038
|
+
"""the least-recently-updated blocked coordinator — oldest-first so a stale one is never hidden behind a fresher one."""
|
|
1039
|
+
row = self.conn.execute(
|
|
1040
|
+
"SELECT task_id FROM tasks WHERE request_text LIKE '/implementapprovedissues%' AND state = 'blocked' ORDER BY updated_at ASC LIMIT 1",
|
|
1041
|
+
).fetchone()
|
|
1042
|
+
return row["task_id"] if row else None
|
|
1043
|
+
|
|
1044
|
+
def active_refine_task(self, issue_id: int) -> str | None:
|
|
1045
|
+
"""return the active /refineissue task for exactly this id, without letting #7 match #70."""
|
|
1046
|
+
return self.active_refine_tasks_by_issue().get(issue_id)
|
|
1047
|
+
|
|
1048
|
+
def active_refine_tasks_by_issue(self) -> dict[int, str]:
|
|
1049
|
+
"""return the newest active /refineissue task for each issue in one task-table scan."""
|
|
1050
|
+
rows = self.conn.execute(
|
|
1051
|
+
"SELECT task_id, request_text FROM tasks WHERE state IN ('received','queued','running','blocked') ORDER BY created_at DESC",
|
|
1052
|
+
).fetchall()
|
|
1053
|
+
pattern = re.compile(r"/refineissue ([0-9]+)(?![0-9])")
|
|
1054
|
+
result: dict[int, str] = {}
|
|
1055
|
+
for row in rows:
|
|
1056
|
+
match = pattern.search(row["request_text"] or "")
|
|
1057
|
+
if match:
|
|
1058
|
+
result.setdefault(int(match.group(1)), row["task_id"])
|
|
1059
|
+
return result
|
|
1060
|
+
|
|
1061
|
+
def has_active_main_task_referencing(self, fragment: str, include_reviewer: bool = False) -> bool:
|
|
1062
|
+
"""True if a non-terminal task's request text references `fragment` as a bounded token (e.g. a PR URL); excludes `blocked` so a main-agent task parked on follow-up questions can't suppress reviews indefinitely. reviewer-persona tasks count only when `include_reviewer` is set."""
|
|
1063
|
+
persona_filter = "" if include_reviewer else " AND (persona IS NULL OR persona != 'reviewer')"
|
|
1064
|
+
rows = self.conn.execute("SELECT request_text FROM tasks WHERE state IN ('received','queued','running')" + persona_filter).fetchall()
|
|
1065
|
+
# bounded match, not a raw substring: require a non-digit (or end of string) after the fragment so .../pull/7 doesn't match .../pull/70, and escape the fragment so any _/% in a repo/org name aren't treated as wildcards
|
|
1066
|
+
pattern = re.compile(re.escape(fragment) + r"(?![0-9])")
|
|
1067
|
+
return any(row["request_text"] and pattern.search(row["request_text"]) for row in rows)
|
|
1068
|
+
|
|
1069
|
+
def reserve_issues_by_id(self, reserved_by: str, ids: list[int]) -> list[dict]:
|
|
1070
|
+
"""atomically reserve exactly these approved issues by id, skipping any that aren't currently approved."""
|
|
1071
|
+
if not ids:
|
|
1072
|
+
return []
|
|
1073
|
+
placeholders = ",".join("?" * len(ids))
|
|
1074
|
+
self.conn.execute(
|
|
1075
|
+
f"""UPDATE issues SET status = 'implementation_queued', reserved_by = ?, updated_at = ?
|
|
1076
|
+
WHERE status = 'approved' AND id IN ({placeholders})""",
|
|
1077
|
+
(reserved_by, utcnow(), *ids),
|
|
1078
|
+
)
|
|
1079
|
+
self.conn.commit()
|
|
1080
|
+
return self.issues_reserved_by(reserved_by)
|
|
1081
|
+
|
|
1082
|
+
def reserve_issues(self, reserved_by: str, limit: int = 5) -> list[dict]:
|
|
1083
|
+
"""atomically reserve up to `limit` highest-priority approved issues for one implementation run,
|
|
1084
|
+
moving them to implementation_queued. one UPDATE, synchronous on the shared connection, so two callers
|
|
1085
|
+
racing this can never reserve the same row."""
|
|
1086
|
+
self.conn.execute(
|
|
1087
|
+
"""UPDATE issues SET status = 'implementation_queued', reserved_by = ?, updated_at = ?
|
|
1088
|
+
WHERE id IN (SELECT id FROM issues WHERE status = 'approved' ORDER BY priority DESC, id LIMIT ?)""",
|
|
1089
|
+
(reserved_by, utcnow(), limit),
|
|
1090
|
+
)
|
|
1091
|
+
self.conn.commit()
|
|
1092
|
+
return self.issues_reserved_by(reserved_by)
|
|
1093
|
+
|
|
1094
|
+
def issues_reserved_by(self, reserved_by: str) -> list[dict]:
|
|
1095
|
+
rows = self.conn.execute("SELECT * FROM issues WHERE status = 'implementation_queued' AND reserved_by = ? ORDER BY priority DESC, id", (reserved_by,)).fetchall()
|
|
1096
|
+
return [dict(row) for row in rows]
|
|
1097
|
+
|
|
1098
|
+
def has_ever_reserved(self, reserved_by: str) -> bool:
|
|
1099
|
+
"""true once anything has been reserved for this id, even after the batch is claimed."""
|
|
1100
|
+
return self.conn.execute("SELECT 1 FROM issues WHERE reserved_by = ? LIMIT 1", (reserved_by,)).fetchone() is not None
|
|
1101
|
+
|
|
1102
|
+
def has_pending_implementation_reservation(self) -> bool:
|
|
1103
|
+
"""true while a pending: marker still holds a reservation not yet assigned to a coordinator task."""
|
|
1104
|
+
return self.conn.execute("SELECT 1 FROM issues WHERE status = 'implementation_queued' AND reserved_by LIKE 'pending:%' LIMIT 1").fetchone() is not None
|
|
1105
|
+
|
|
1106
|
+
def assign_reservation(self, reserved_by: str, task_id: str) -> int:
|
|
1107
|
+
"""move a pending marker's reservation onto the real coordinator task id once accept_task creates it."""
|
|
1108
|
+
cur = self.conn.execute(
|
|
1109
|
+
"UPDATE issues SET reserved_by = ?, updated_at = ? WHERE reserved_by = ? AND status = 'implementation_queued'",
|
|
1110
|
+
(task_id, utcnow(), reserved_by),
|
|
1111
|
+
)
|
|
1112
|
+
self.conn.commit()
|
|
1113
|
+
return cur.rowcount
|
|
1114
|
+
|
|
1115
|
+
def release_reserved_issues(self, reserved_by: str) -> int:
|
|
1116
|
+
"""restore issues reserved by `reserved_by` back to approved; a no-op for anything not holding a
|
|
1117
|
+
reservation. used when a coordinator never gets created, or dies/finishes with rows still queued."""
|
|
1118
|
+
cur = self.conn.execute(
|
|
1119
|
+
"UPDATE issues SET status = 'approved', reserved_by = NULL, updated_at = ? WHERE reserved_by = ? AND status = 'implementation_queued'",
|
|
1120
|
+
(utcnow(), reserved_by),
|
|
1121
|
+
)
|
|
1122
|
+
self.conn.commit()
|
|
1123
|
+
return cur.rowcount
|
|
1124
|
+
|
|
1125
|
+
def release_stale_reservations(self) -> int:
|
|
1126
|
+
"""startup safety net: restore every implementation_queued row whose reservation can never be fulfilled —
|
|
1127
|
+
no reserved_by, a pending marker that never became a task, or a coordinator task already in a terminal state."""
|
|
1128
|
+
cur = self.conn.execute(
|
|
1129
|
+
"""UPDATE issues SET status = 'approved', reserved_by = NULL, updated_at = ?
|
|
1130
|
+
WHERE status = 'implementation_queued' AND (
|
|
1131
|
+
reserved_by IS NULL
|
|
1132
|
+
OR reserved_by LIKE 'pending:%'
|
|
1133
|
+
OR reserved_by NOT IN (SELECT task_id FROM tasks)
|
|
1134
|
+
OR reserved_by IN (SELECT task_id FROM tasks WHERE state IN ('completed','failed','cancelled','refused'))
|
|
1135
|
+
)""",
|
|
1136
|
+
(utcnow(),),
|
|
1137
|
+
)
|
|
1138
|
+
self.conn.commit()
|
|
1139
|
+
return cur.rowcount
|
|
1140
|
+
|
|
1141
|
+
def finish_issue(self, issue_id: int, status: str, pr_url: str | None = None) -> dict | None:
|
|
1142
|
+
"""records the outcome of a PR attempt (spec2pr agent) or resolves one whose PR has now merged/closed
|
|
1143
|
+
(the sync_in_review housekeeping process) — both transition out of in_progress or in_review alike."""
|
|
1144
|
+
if status not in ("in_review", "done", "failed"):
|
|
1145
|
+
raise ValueError(f"issue finish status must be in_review, done, or failed, got {status!r}")
|
|
1146
|
+
cur = self.conn.execute(
|
|
1147
|
+
"UPDATE issues SET status = ?, pr_url = ?, updated_at = ? WHERE id = ? AND status IN ('in_progress','in_review')",
|
|
1148
|
+
(status, pr_url, utcnow(), issue_id),
|
|
1149
|
+
)
|
|
1150
|
+
self.conn.commit()
|
|
1151
|
+
if cur.rowcount == 0:
|
|
1152
|
+
return None
|
|
1153
|
+
return self.get_issue(issue_id)
|
|
1154
|
+
|
|
1155
|
+
# -- issue discussion -----------------------------------------------------
|
|
1156
|
+
|
|
1157
|
+
def add_issue_comment(self, issue_id: int, author: str, body: str, parent_comment_id: int | None = None) -> dict:
|
|
1158
|
+
if self.get_issue(issue_id) is None:
|
|
1159
|
+
raise LookupError("issue not found")
|
|
1160
|
+
if parent_comment_id is not None:
|
|
1161
|
+
parent = self.get_issue_comment(parent_comment_id)
|
|
1162
|
+
if parent is None or parent["issue_id"] != issue_id:
|
|
1163
|
+
raise ValueError("parent comment must belong to this issue")
|
|
1164
|
+
if parent["parent_comment_id"] is not None:
|
|
1165
|
+
raise ValueError("replies may only target top-level comments")
|
|
1166
|
+
now = utcnow()
|
|
1167
|
+
cur = self.conn.execute(
|
|
1168
|
+
"""INSERT INTO issue_comments (issue_id, parent_comment_id, author, body, created_at, updated_at)
|
|
1169
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
1170
|
+
(issue_id, parent_comment_id, author, redactor.redact(body[:4000]), now, now),
|
|
1171
|
+
)
|
|
1172
|
+
self.conn.commit()
|
|
1173
|
+
row = self.conn.execute("SELECT * FROM issue_comments WHERE id = ?", (cur.lastrowid,)).fetchone()
|
|
1174
|
+
return dict(row)
|
|
1175
|
+
|
|
1176
|
+
def get_issue_comment(self, comment_id: int) -> dict | None:
|
|
1177
|
+
row = self.conn.execute("SELECT * FROM issue_comments WHERE id = ?", (comment_id,)).fetchone()
|
|
1178
|
+
return dict(row) if row else None
|
|
1179
|
+
|
|
1180
|
+
def list_issue_comments(self, issue_id: int) -> list[dict]:
|
|
1181
|
+
rows = self.conn.execute("SELECT * FROM issue_comments WHERE issue_id = ? ORDER BY created_at, id", (issue_id,)).fetchall()
|
|
1182
|
+
return [dict(row) for row in rows]
|
|
1183
|
+
|
|
1184
|
+
def count_issue_comments(self, issue_id: int) -> int:
|
|
1185
|
+
row = self.conn.execute("SELECT COUNT(*) AS n FROM issue_comments WHERE issue_id = ?", (issue_id,)).fetchone()
|
|
1186
|
+
return int(row["n"])
|
|
1187
|
+
|
|
1188
|
+
def count_issue_comments_by_issue(self) -> dict[int, int]:
|
|
1189
|
+
rows = self.conn.execute("SELECT issue_id, COUNT(*) AS n FROM issue_comments GROUP BY issue_id").fetchall()
|
|
1190
|
+
return {int(row["issue_id"]): int(row["n"]) for row in rows}
|
|
1191
|
+
|
|
1192
|
+
def update_issue_comment(self, comment_id: int, body: str) -> dict | None:
|
|
1193
|
+
now = utcnow()
|
|
1194
|
+
cur = self.conn.execute(
|
|
1195
|
+
"UPDATE issue_comments SET body = ?, edited_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL",
|
|
1196
|
+
(redactor.redact(body[:4000]), now, now, comment_id),
|
|
1197
|
+
)
|
|
1198
|
+
self.conn.commit()
|
|
1199
|
+
if cur.rowcount == 0:
|
|
1200
|
+
return None
|
|
1201
|
+
return self.get_issue_comment(comment_id)
|
|
1202
|
+
|
|
1203
|
+
def delete_issue_comment(self, comment_id: int) -> dict | None:
|
|
1204
|
+
now = utcnow()
|
|
1205
|
+
cur = self.conn.execute(
|
|
1206
|
+
"UPDATE issue_comments SET body = '', deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL",
|
|
1207
|
+
(now, now, comment_id),
|
|
1208
|
+
)
|
|
1209
|
+
self.conn.commit()
|
|
1210
|
+
if cur.rowcount == 0:
|
|
1211
|
+
return None
|
|
1212
|
+
return self.get_issue_comment(comment_id)
|
|
1213
|
+
|
|
1214
|
+
def resolve_issue_comment(self, comment_id: int, actor: str, resolved: bool = True) -> dict | None:
|
|
1215
|
+
now = utcnow()
|
|
1216
|
+
cur = self.conn.execute(
|
|
1217
|
+
"UPDATE issue_comments SET resolved = ?, resolved_by = ?, resolved_at = ?, updated_at = ? WHERE id = ?",
|
|
1218
|
+
(int(resolved), actor if resolved else None, now if resolved else None, now, comment_id),
|
|
1219
|
+
)
|
|
1220
|
+
self.conn.commit()
|
|
1221
|
+
if cur.rowcount == 0:
|
|
1222
|
+
return None
|
|
1223
|
+
return self.get_issue_comment(comment_id)
|
|
1224
|
+
|
|
1225
|
+
# -- issue attachments ----------------------------------------------------
|
|
1226
|
+
|
|
1227
|
+
def add_issue_attachment(self, issue_id: int, comment_id: int | None, filename: str, content_type: str | None, size_bytes: int, s3_key: str, uploaded_by: str) -> dict:
|
|
1228
|
+
now = utcnow()
|
|
1229
|
+
cur = self.conn.execute(
|
|
1230
|
+
"""INSERT INTO issue_attachments (issue_id, comment_id, filename, content_type, size_bytes, s3_key, uploaded_by, created_at)
|
|
1231
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
1232
|
+
(issue_id, comment_id, filename, content_type, size_bytes, s3_key, uploaded_by, now),
|
|
1233
|
+
)
|
|
1234
|
+
self.conn.commit()
|
|
1235
|
+
row = self.conn.execute("SELECT * FROM issue_attachments WHERE id = ?", (cur.lastrowid,)).fetchone()
|
|
1236
|
+
return dict(row)
|
|
1237
|
+
|
|
1238
|
+
def list_issue_attachments(self, issue_id: int) -> list[dict]:
|
|
1239
|
+
rows = self.conn.execute("SELECT * FROM issue_attachments WHERE issue_id = ? ORDER BY id", (issue_id,)).fetchall()
|
|
1240
|
+
return [dict(row) for row in rows]
|
|
1241
|
+
|
|
1242
|
+
def get_issue_attachment(self, attachment_id: int) -> dict | None:
|
|
1243
|
+
row = self.conn.execute("SELECT * FROM issue_attachments WHERE id = ?", (attachment_id,)).fetchone()
|
|
1244
|
+
return dict(row) if row else None
|
|
1245
|
+
|
|
1246
|
+
# -- schedules (dashboard-defined recurring/one-off tasks) ----------------
|
|
1247
|
+
|
|
1248
|
+
def create_schedule(
|
|
1249
|
+
self, *, name: str, request_text: str, model_alias: str | None, kind: str, interval_minutes: int | None, at_time: str | None, run_at: str | None, timezone: str | None, max_runs: int | None, next_run_at: str, created_by: str, seed_key: str | None = None, effort: str | None = None
|
|
1250
|
+
) -> dict:
|
|
1251
|
+
now = utcnow()
|
|
1252
|
+
cur = self.conn.execute(
|
|
1253
|
+
"""INSERT INTO schedules (name, request_text, model_alias, effort, kind, interval_minutes, at_time, run_at, timezone, max_runs, next_run_at, seed_key, created_by, created_at, updated_at)
|
|
1254
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
1255
|
+
(redactor.redact(name), redactor.redact(request_text), model_alias, effort, kind, interval_minutes, at_time, run_at, timezone, max_runs, next_run_at, seed_key, created_by, now, now),
|
|
1256
|
+
)
|
|
1257
|
+
self.conn.commit()
|
|
1258
|
+
row = self.conn.execute("SELECT * FROM schedules WHERE id = ?", (cur.lastrowid,)).fetchone()
|
|
1259
|
+
return dict(row)
|
|
1260
|
+
|
|
1261
|
+
def seed_schedule_once(self, seed_key: str, **fields) -> dict | None:
|
|
1262
|
+
"""create a shipped default schedule exactly once. returns the row if created, None if the seed key already existed."""
|
|
1263
|
+
cur = self.conn.execute("SELECT 1 FROM schedules WHERE seed_key = ?", (seed_key,)).fetchone()
|
|
1264
|
+
if cur is not None:
|
|
1265
|
+
return None
|
|
1266
|
+
return self.create_schedule(seed_key=seed_key, **fields)
|
|
1267
|
+
|
|
1268
|
+
def list_schedules(self) -> list[dict]:
|
|
1269
|
+
rows = self.conn.execute("SELECT * FROM schedules ORDER BY enabled DESC, next_run_at").fetchall()
|
|
1270
|
+
return [dict(row) for row in rows]
|
|
1271
|
+
|
|
1272
|
+
def get_schedule(self, schedule_id: int) -> dict | None:
|
|
1273
|
+
row = self.conn.execute("SELECT * FROM schedules WHERE id = ?", (schedule_id,)).fetchone()
|
|
1274
|
+
return dict(row) if row else None
|
|
1275
|
+
|
|
1276
|
+
def due_schedules(self, now_iso: str) -> list[dict]:
|
|
1277
|
+
rows = self.conn.execute("SELECT * FROM schedules WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at", (now_iso,)).fetchall()
|
|
1278
|
+
return [dict(row) for row in rows]
|
|
1279
|
+
|
|
1280
|
+
def update_schedule(self, schedule_id: int, **fields) -> dict | None:
|
|
1281
|
+
unknown = set(fields) - SCHEDULE_UPDATABLE
|
|
1282
|
+
if unknown:
|
|
1283
|
+
raise ValueError(f"update_schedule cannot update fields: {sorted(unknown)}")
|
|
1284
|
+
if "request_text" in fields and isinstance(fields["request_text"], str):
|
|
1285
|
+
fields["request_text"] = redactor.redact(fields["request_text"])
|
|
1286
|
+
if "name" in fields and isinstance(fields["name"], str):
|
|
1287
|
+
fields["name"] = redactor.redact(fields["name"])
|
|
1288
|
+
sets = ", ".join(f"{name} = ?" for name in fields)
|
|
1289
|
+
cur = self.conn.execute(f"UPDATE schedules SET updated_at = ?, {sets} WHERE id = ?", (utcnow(), *fields.values(), schedule_id))
|
|
1290
|
+
self.conn.commit()
|
|
1291
|
+
return self.get_schedule(schedule_id) if cur.rowcount else None
|
|
1292
|
+
|
|
1293
|
+
def record_schedule_fire(self, schedule_id: int, task_id: str | None, next_run_at: str, enabled: bool) -> None:
|
|
1294
|
+
"""advance a schedule after a fire: bump run_count, remember the task, set the next fire time, and disable if exhausted."""
|
|
1295
|
+
self.conn.execute(
|
|
1296
|
+
"UPDATE schedules SET run_count = run_count + 1, last_run_at = ?, last_task_id = ?, next_run_at = ?, enabled = ?, updated_at = ? WHERE id = ?",
|
|
1297
|
+
(utcnow(), task_id, next_run_at, 1 if enabled else 0, utcnow(), schedule_id),
|
|
1298
|
+
)
|
|
1299
|
+
self.conn.commit()
|
|
1300
|
+
|
|
1301
|
+
def delete_schedule(self, schedule_id: int) -> bool:
|
|
1302
|
+
cur = self.conn.execute("DELETE FROM schedules WHERE id = ?", (schedule_id,))
|
|
1303
|
+
self.conn.commit()
|
|
1304
|
+
return cur.rowcount > 0
|
|
1305
|
+
|
|
1306
|
+
# -- requester profiles / errors -----------------------------------------
|
|
1307
|
+
|
|
1308
|
+
def upsert_slack_user(self, user_id: str, **fields) -> dict:
|
|
1309
|
+
allowed = {"team_id", "username", "real_name", "display_name", "email", "title", "tz", "is_bot"}
|
|
1310
|
+
unknown = set(fields) - allowed
|
|
1311
|
+
if unknown:
|
|
1312
|
+
raise ValueError(f"unknown slack user fields: {sorted(unknown)}")
|
|
1313
|
+
values = {key: redactor.redact(value) if isinstance(value, str) else value for key, value in fields.items()}
|
|
1314
|
+
columns = ["user_id", *values, "updated_at"]
|
|
1315
|
+
params = [user_id, *values.values(), utcnow()]
|
|
1316
|
+
updates = ", ".join(f"{column} = excluded.{column}" for column in [*values, "updated_at"])
|
|
1317
|
+
placeholders = ", ".join("?" for _ in columns)
|
|
1318
|
+
self.conn.execute(f"INSERT INTO slack_users ({', '.join(columns)}) VALUES ({placeholders}) ON CONFLICT(user_id) DO UPDATE SET {updates}", params)
|
|
1319
|
+
self.conn.commit()
|
|
1320
|
+
result = self.get_slack_user(user_id)
|
|
1321
|
+
assert result is not None
|
|
1322
|
+
return result
|
|
1323
|
+
|
|
1324
|
+
def get_slack_user(self, user_id: str) -> dict | None:
|
|
1325
|
+
row = self.conn.execute("SELECT * FROM slack_users WHERE user_id = ?", (user_id,)).fetchone()
|
|
1326
|
+
return dict(row) if row else None
|
|
1327
|
+
|
|
1328
|
+
def add_error(self, component: str, kind: str, message: str, task_id: str | None = None, traceback: str | None = None, context: dict | None = None) -> None:
|
|
1329
|
+
self.conn.execute(
|
|
1330
|
+
"INSERT INTO errors (ts, task_id, component, kind, message, traceback, context_json) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
1331
|
+
(utcnow(), task_id, component, kind, redactor.redact(str(message)), redactor.redact(traceback) if traceback else None, redactor.redact(json.dumps(context, default=str)) if context is not None else None),
|
|
1332
|
+
)
|
|
1333
|
+
self.conn.commit()
|
|
1334
|
+
|
|
1335
|
+
def recent_errors(self, limit: int = 20, offset: int = 0, component: str | None = None, kind: str | None = None) -> list[dict]:
|
|
1336
|
+
clauses: list[str] = []
|
|
1337
|
+
values: list[object] = []
|
|
1338
|
+
if component:
|
|
1339
|
+
clauses.append("component = ?")
|
|
1340
|
+
values.append(component)
|
|
1341
|
+
if kind:
|
|
1342
|
+
clauses.append("kind = ?")
|
|
1343
|
+
values.append(kind)
|
|
1344
|
+
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
1345
|
+
rows = self.conn.execute(f"SELECT * FROM errors{where} ORDER BY id DESC LIMIT ? OFFSET ?", (*values, limit, offset)).fetchall()
|
|
1346
|
+
return [dict(row) for row in rows]
|
|
1347
|
+
|
|
1348
|
+
def errors_for(self, task_id: str) -> list[dict]:
|
|
1349
|
+
rows = self.conn.execute("SELECT * FROM errors WHERE task_id = ? ORDER BY id", (task_id,)).fetchall()
|
|
1350
|
+
return [dict(row) for row in rows]
|
|
1351
|
+
|
|
1352
|
+
def purge_errors(self, cutoff_iso: str) -> int:
|
|
1353
|
+
cur = self.conn.execute("DELETE FROM errors WHERE ts < ?", (cutoff_iso,))
|
|
1354
|
+
self.conn.commit()
|
|
1355
|
+
return cur.rowcount
|
|
1356
|
+
|
|
1357
|
+
# -- dashboard administration audit -------------------------------------
|
|
1358
|
+
|
|
1359
|
+
def add_admin_event(self, actor: str, action: str, target: str, outcome: str, detail: dict | None = None) -> None:
|
|
1360
|
+
self.conn.execute("BEGIN IMMEDIATE")
|
|
1361
|
+
previous = self.conn.execute("SELECT hash FROM admin_events ORDER BY id DESC LIMIT 1").fetchone()
|
|
1362
|
+
prev_hash = previous["hash"] if previous else ""
|
|
1363
|
+
ts = utcnow()
|
|
1364
|
+
detail_json = redactor.redact(json.dumps(detail or {}, default=str, sort_keys=True))
|
|
1365
|
+
digest = admin_event_hash(prev_hash, ts, actor, action, target, outcome, detail_json)
|
|
1366
|
+
self.conn.execute("INSERT INTO admin_events (ts, actor, action, target, outcome, detail_json, hash) VALUES (?, ?, ?, ?, ?, ?, ?)", (ts, actor, action, target, outcome, detail_json, digest))
|
|
1367
|
+
self.conn.commit()
|
|
1368
|
+
|
|
1369
|
+
def admin_events(self, limit: int = 100) -> list[dict]:
|
|
1370
|
+
rows = self.conn.execute("SELECT * FROM admin_events ORDER BY id DESC LIMIT ?", (min(max(limit, 1), 1000),)).fetchall()
|
|
1371
|
+
return [dict(row) for row in rows]
|
|
1372
|
+
|
|
1373
|
+
def admin_events_after(self, after_id: int, limit: int = 10000) -> list[dict]:
|
|
1374
|
+
rows = self.conn.execute("SELECT * FROM admin_events WHERE id > ? ORDER BY id LIMIT ?", (after_id, limit)).fetchall()
|
|
1375
|
+
return [dict(row) for row in rows]
|
|
1376
|
+
|
|
1377
|
+
# -- meta -----------------------------------------------------------------
|
|
1378
|
+
|
|
1379
|
+
def meta_get(self, key: str) -> str | None:
|
|
1380
|
+
row = self.conn.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
|
|
1381
|
+
return row["value"] if row else None
|
|
1382
|
+
|
|
1383
|
+
def meta_set(self, key: str, value: str) -> None:
|
|
1384
|
+
self.conn.execute("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value))
|
|
1385
|
+
self.conn.commit()
|
|
1386
|
+
|
|
1387
|
+
def meta_delete(self, key: str) -> None:
|
|
1388
|
+
self.conn.execute("DELETE FROM meta WHERE key = ?", (key,))
|
|
1389
|
+
self.conn.commit()
|
|
1390
|
+
|
|
1391
|
+
def meta_keys_with_prefix(self, prefix: str) -> list[str]:
|
|
1392
|
+
rows = self.conn.execute("SELECT key FROM meta").fetchall()
|
|
1393
|
+
return [row["key"] for row in rows if row["key"].startswith(prefix)]
|