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.
Files changed (95) hide show
  1. taskboy/__init__.py +0 -0
  2. taskboy/adapters/__init__.py +0 -0
  3. taskboy/adapters/_util.py +30 -0
  4. taskboy/adapters/aws_read.py +107 -0
  5. taskboy/adapters/confluence.py +88 -0
  6. taskboy/adapters/github_api.py +564 -0
  7. taskboy/adapters/issues.py +435 -0
  8. taskboy/adapters/jira.py +263 -0
  9. taskboy/adapters/sentry.py +82 -0
  10. taskboy/adapters/slack_history.py +204 -0
  11. taskboy/assets.py +27 -0
  12. taskboy/audit.py +78 -0
  13. taskboy/broker.py +266 -0
  14. taskboy/classifier.py +219 -0
  15. taskboy/cli.py +161 -0
  16. taskboy/config.py +539 -0
  17. taskboy/dashboard/__init__.py +5 -0
  18. taskboy/dashboard/api.py +963 -0
  19. taskboy/dashboard/app.py +78 -0
  20. taskboy/dashboard/auth.py +117 -0
  21. taskboy/dashboard/editors.py +140 -0
  22. taskboy/dashboard/gitops.py +60 -0
  23. taskboy/dashboard/render.py +48 -0
  24. taskboy/debug_feed.py +170 -0
  25. taskboy/deploy/env.example +17 -0
  26. taskboy/deploy/git-cred-helper.py +36 -0
  27. taskboy/deploy/install.sh +101 -0
  28. taskboy/deploy/remote-update.sh +52 -0
  29. taskboy/deploy/taskboy-restart.path +10 -0
  30. taskboy/deploy/taskboy-restart.service +8 -0
  31. taskboy/deploy/taskboy.service +22 -0
  32. taskboy/hooks.py +208 -0
  33. taskboy/issue_runs.py +203 -0
  34. taskboy/llm.py +125 -0
  35. taskboy/main.py +269 -0
  36. taskboy/memory.py +59 -0
  37. taskboy/models.py +103 -0
  38. taskboy/mrkdwn.py +19 -0
  39. taskboy/notify.py +41 -0
  40. taskboy/orchestrator.py +401 -0
  41. taskboy/personality.py +17 -0
  42. taskboy/prompts.py +288 -0
  43. taskboy/quick.py +234 -0
  44. taskboy/redact.py +49 -0
  45. taskboy/repocache.py +117 -0
  46. taskboy/review_requests.py +388 -0
  47. taskboy/router.py +146 -0
  48. taskboy/runner.py +581 -0
  49. taskboy/scheduler.py +301 -0
  50. taskboy/secrets.py +83 -0
  51. taskboy/settings.py +19 -0
  52. taskboy/setup_checks.py +184 -0
  53. taskboy/setup_wizard.py +699 -0
  54. taskboy/skills.py +95 -0
  55. taskboy/slack.py +666 -0
  56. taskboy/slack_users.py +32 -0
  57. taskboy/started_messages.py +33 -0
  58. taskboy/store.py +1393 -0
  59. taskboy/task_actions.py +86 -0
  60. taskboy/templates/config.example.yaml +149 -0
  61. taskboy/templates/conventions.md +60 -0
  62. taskboy/templates/help.example.md +29 -0
  63. taskboy/templates/personality_agent.example.md +5 -0
  64. taskboy/templates/personality_reviewer.example.md +5 -0
  65. taskboy/templates/services/aws.yaml +7 -0
  66. taskboy/templates/services/confluence.yaml +4 -0
  67. taskboy/templates/services/github.yaml +14 -0
  68. taskboy/templates/services/jira.yaml +6 -0
  69. taskboy/templates/services/sentry.yaml +4 -0
  70. taskboy/templates/services/slack.yaml +9 -0
  71. taskboy/templates/skills/README.md +33 -0
  72. taskboy/templates/skills/discoverissues/SKILL.md +46 -0
  73. taskboy/templates/skills/implementapprovedissues/SKILL.md +41 -0
  74. taskboy/templates/skills/jira2pr/SKILL.md +66 -0
  75. taskboy/templates/skills/monitor/SKILL.md +32 -0
  76. taskboy/templates/skills/monitornew/SKILL.md +27 -0
  77. taskboy/templates/skills/refineissue/SKILL.md +35 -0
  78. taskboy/templates/skills/release/SKILL.md +36 -0
  79. taskboy/templates/skills/review/SKILL.md +89 -0
  80. taskboy/templates/skills/reviewandmonitor/SKILL.md +22 -0
  81. taskboy/templates/skills/reviews/SKILL.md +26 -0
  82. taskboy/templates/skills/slack2jira/SKILL.md +47 -0
  83. taskboy/templates/skills/slack2pr/SKILL.md +64 -0
  84. taskboy/templates/skills/spec2pr/SKILL.md +35 -0
  85. taskboy/templates/slack_app_manifest.yaml +48 -0
  86. taskboy/templates/task_started_messages.yaml +14 -0
  87. taskboy/ui_dist/assets/index-DmYxR9Qy.css +1 -0
  88. taskboy/ui_dist/assets/index-LDDO4iT2.js +15 -0
  89. taskboy/ui_dist/index.html +15 -0
  90. taskboy/workspace.py +86 -0
  91. taskboy-0.1.1.dist-info/METADATA +120 -0
  92. taskboy-0.1.1.dist-info/RECORD +95 -0
  93. taskboy-0.1.1.dist-info/WHEEL +5 -0
  94. taskboy-0.1.1.dist-info/entry_points.txt +2 -0
  95. taskboy-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,963 @@
1
+ """json api for mission control. reads are domain-wide; writes require dashboard.admin_emails."""
2
+
3
+ import asyncio
4
+ import json
5
+ import re
6
+ import uuid
7
+ from dataclasses import asdict
8
+ from datetime import datetime, timedelta, timezone
9
+ from pathlib import Path
10
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
11
+
12
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
13
+ from fastapi.responses import RedirectResponse, StreamingResponse
14
+
15
+ from taskboy import memory, settings, skills
16
+ from taskboy.config import KNOWN_SERVICES, ConfigError
17
+ from taskboy.dashboard import gitops
18
+ from taskboy.dashboard.auth import Viewer, require_admin, require_viewer, require_viewer_write
19
+ from taskboy.dashboard.editors import EDITABLE_KINDS, atomic_write, contains_secret_submission, content_hash, target_for, unified_diff, validate
20
+ from taskboy.dashboard.render import bounded_text, redact_bounded_value, redact_value, safe_text
21
+ from taskboy.issue_runs import IMPLEMENTATION_BATCH_SIZE, start_implementation_run, start_issue_task, start_refine_task
22
+ from taskboy.models import CANCELLED, EFFORT_LEVELS, FAILED, RUNNING, STATES, TERMINAL_STATES, Task, utcnow
23
+ from taskboy.scheduler import fire_schedule_now, next_run_after
24
+ from taskboy.skills import SkillError
25
+ from taskboy.task_actions import cancel_task, decide_permission, retry_task
26
+
27
+ router = APIRouter()
28
+ TASK_ID = re.compile(r"^t[0-9]{8}-[a-f0-9]{8}$")
29
+
30
+ # skills an operator can trigger from the issues page; both run as the system identity
31
+ ISSUE_SKILLS = {"discoverissues", "implementapprovedissues"}
32
+ # ranked statuses get a 1..N importance order; decided-and-done ones drop out of the ranking
33
+ RANKED_STATUSES = ("proposed", "approved", "in_progress")
34
+ MAX_ISSUE_UPLOAD_BYTES = 5 * 1024 * 1024
35
+
36
+
37
+ def _put_object(bucket: str, key: str, body: bytes, content_type: str | None) -> None:
38
+ """S3 upload seam, kept lazy so local/test imports never need AWS configuration."""
39
+ import boto3
40
+
41
+ kwargs = {"Bucket": bucket, "Key": key, "Body": body}
42
+ if content_type:
43
+ kwargs["ContentType"] = content_type
44
+ boto3.client("s3").put_object(**kwargs)
45
+
46
+
47
+ def _presign(bucket: str, key: str) -> str:
48
+ import boto3
49
+
50
+ return boto3.client("s3").generate_presigned_url("get_object", Params={"Bucket": bucket, "Key": key}, ExpiresIn=300)
51
+
52
+
53
+ def _approved_repos(config) -> list[str]:
54
+ return list(((config.raw.get("github") or {}).get("approved_repos")) or [])
55
+
56
+
57
+ def _now() -> datetime:
58
+ return datetime.now(timezone.utc)
59
+
60
+
61
+ def _iso(moment: datetime) -> str:
62
+ return moment.isoformat(timespec="seconds")
63
+
64
+
65
+ def _slim(store, task: Task, names: dict[str, str | None]) -> dict:
66
+ if task.slack_user_id not in names:
67
+ user = store.get_slack_user(task.slack_user_id)
68
+ names[task.slack_user_id] = (user.get("real_name") or user.get("display_name")) if user else None
69
+ return {
70
+ "task_id": task.task_id,
71
+ "state": task.state,
72
+ "request_text": bounded_text(task.request_text, 140),
73
+ "task_type": task.task_type,
74
+ "complexity": task.complexity,
75
+ "model_alias": task.model_alias,
76
+ "effort": task.effort_override or task.effort,
77
+ "persona": task.persona,
78
+ "profile": task.profile,
79
+ "attempt": task.attempt,
80
+ "cost_usd": task.cost_usd,
81
+ "num_turns": task.num_turns,
82
+ "slack_user_id": task.slack_user_id,
83
+ "requester": names[task.slack_user_id],
84
+ "parent_task_id": task.parent_task_id,
85
+ "created_at": task.created_at,
86
+ "started_at": task.started_at,
87
+ "finished_at": task.finished_at,
88
+ }
89
+
90
+
91
+ @router.get("/api/me")
92
+ async def me(request: Request, viewer: Viewer = Depends(require_viewer)) -> dict:
93
+ config = request.app.state.config
94
+ return {"email": viewer.email, "admin": viewer.admin, "bot_name": config.agent_name, "reviewer_name": config.reviewer.name}
95
+
96
+
97
+ @router.get("/api/overview")
98
+ async def overview(request: Request, viewer: Viewer = Depends(require_viewer)) -> dict:
99
+ store = request.app.state.store
100
+ config = request.app.state.config
101
+ names: dict[str, str | None] = {}
102
+ counts = store.task_counts()
103
+ return {
104
+ "counts": {state: counts.get(state, 0) for state in STATES},
105
+ "running": [_slim(store, task, names) for task in store.tasks_in_state(RUNNING)],
106
+ "recent": [_slim(store, task, names) for task in store.recent_tasks(15)],
107
+ "errors": [redact_bounded_value(row) for row in store.recent_errors(5)],
108
+ "intake_paused": store.meta_get("intake_paused") == "1",
109
+ "usage_5h": store.usage_totals(since_iso=_iso(_now() - timedelta(hours=5))),
110
+ "environment": settings.ENVIRONMENT,
111
+ "bot_name": config.agent_name,
112
+ "reviewer_name": config.reviewer.name,
113
+ "max_concurrency": config.max_concurrency,
114
+ "queue_max": config.queue_max,
115
+ }
116
+
117
+
118
+ @router.get("/api/tasks")
119
+ async def list_tasks(request: Request, state: str | None = None, q: str | None = None, page: int = 1, viewer: Viewer = Depends(require_viewer)) -> dict:
120
+ if state and state not in STATES:
121
+ raise HTTPException(status_code=400, detail="unknown task state")
122
+ store = request.app.state.store
123
+ config = request.app.state.config
124
+ names: dict[str, str | None] = {}
125
+ page = max(page, 1)
126
+ tasks = store.list_tasks(state=state, query=q, limit=50, offset=(page - 1) * 50)
127
+ return {"tasks": [_slim(store, task, names) for task in tasks], "page": page, "page_size": 50, "bot_name": config.agent_name, "reviewer_name": config.reviewer.name}
128
+
129
+
130
+ @router.get("/api/tasks/{task_id}")
131
+ async def task_detail(request: Request, task_id: str, event_page: int = 1, viewer: Viewer = Depends(require_viewer)) -> dict:
132
+ if not TASK_ID.fullmatch(task_id):
133
+ raise HTTPException(status_code=404, detail="task not found")
134
+ store = request.app.state.store
135
+ config = request.app.state.config
136
+ task = store.get_task(task_id)
137
+ if task is None:
138
+ raise HTTPException(status_code=404, detail="task not found")
139
+ event_page = max(event_page, 1)
140
+ names: dict[str, str | None] = {}
141
+ own_memory = memory.read_summary(settings.MEMORY_ROOT, task_id)
142
+ parent_memory = memory.read_summary(settings.MEMORY_ROOT, task.parent_task_id) if task.parent_task_id else None
143
+ return {
144
+ "task": redact_bounded_value(asdict(task)),
145
+ "bot_name": config.agent_name,
146
+ "reviewer_name": config.reviewer.name,
147
+ "requester": _slim(store, task, names)["requester"],
148
+ "events": [redact_bounded_value(row) for row in store.events_for(task_id, limit=100, offset=(event_page - 1) * 100)],
149
+ "event_page": event_page,
150
+ "event_count": store.event_count(task_id),
151
+ "children": [_slim(store, child, names) for child in store.children_of(task_id)],
152
+ "errors": [redact_bounded_value(row) for row in store.errors_for(task_id)],
153
+ "usage": [redact_bounded_value(row) for row in store.usage_for(task_id)],
154
+ "timings": [redact_bounded_value(row) for row in store.events_for_kinds(task_id, {"timing"})],
155
+ "artifacts": [redact_bounded_value(row) for row in store.artifacts_for(task_id)],
156
+ "permission_requests": [redact_bounded_value(row) for row in store.permission_requests_for(task_id)],
157
+ "questions": [redact_bounded_value(row) for row in store.questions_for(task_id)],
158
+ "feedback": [redact_bounded_value(row) for row in store.feedback_for(task_id)],
159
+ "own_memory": bounded_text(own_memory, 100000) if own_memory else None,
160
+ "parent_memory": bounded_text(parent_memory, 100000) if parent_memory else None,
161
+ "can_cancel": task.state not in TERMINAL_STATES,
162
+ "can_retry": task.state in (FAILED, CANCELLED),
163
+ }
164
+
165
+
166
+ @router.post("/api/tasks/{task_id}/cancel")
167
+ async def task_cancel(request: Request, task_id: str, viewer: Viewer = Depends(require_admin)) -> dict:
168
+ if not TASK_ID.fullmatch(task_id):
169
+ raise HTTPException(status_code=404, detail="task not found")
170
+ store = request.app.state.store
171
+ task, status = cancel_task(store, task_id, viewer.email)
172
+ store.add_admin_event(viewer.email, "task_cancel", task_id, status)
173
+ orchestrator = request.app.state.orchestrator
174
+ if orchestrator is not None:
175
+ orchestrator.wake.set() # same process: pick up the cancel now instead of on the next poll
176
+ return {"status": status, "state": task.state if task else None}
177
+
178
+
179
+ @router.post("/api/tasks/{task_id}/retry")
180
+ async def task_retry(request: Request, task_id: str, viewer: Viewer = Depends(require_admin)) -> dict:
181
+ if not TASK_ID.fullmatch(task_id):
182
+ raise HTTPException(status_code=404, detail="task not found")
183
+ store = request.app.state.store
184
+ retried, status = await retry_task(store, request.app.state.config, request.app.state.notifier, task_id, viewer.email)
185
+ store.add_admin_event(viewer.email, "task_retry", task_id, status, {"new_task_id": retried.task_id if retried and status == "created" else None})
186
+ orchestrator = request.app.state.orchestrator
187
+ if orchestrator is not None:
188
+ orchestrator.wake.set()
189
+ return {"status": status, "new_task_id": retried.task_id if retried and status == "created" else None}
190
+
191
+
192
+ @router.post("/api/tasks/{task_id}/permissions")
193
+ async def task_permission(request: Request, task_id: str, viewer: Viewer = Depends(require_admin)) -> dict:
194
+ if not TASK_ID.fullmatch(task_id):
195
+ raise HTTPException(status_code=404, detail="task not found")
196
+ try:
197
+ body = await request.json()
198
+ except json.JSONDecodeError:
199
+ raise HTTPException(status_code=400, detail="json body required")
200
+ kind = str(body.get("kind") or "")
201
+ target = str(body.get("target") or "")
202
+ decision = str(body.get("decision") or "")
203
+ if kind not in ("tool", "repo"):
204
+ raise HTTPException(status_code=400, detail="kind must be tool or repo")
205
+ if decision not in ("granted", "denied"):
206
+ raise HTTPException(status_code=400, detail="decision must be granted or denied")
207
+ store = request.app.state.store
208
+ task, status = await decide_permission(store, request.app.state.notifier, task_id, kind, target, decision, viewer.email)
209
+ store.add_admin_event(viewer.email, f"permission_{decision}", f"{task_id}:{kind}:{target}", status)
210
+ orchestrator = request.app.state.orchestrator
211
+ if orchestrator is not None:
212
+ orchestrator.wake.set() # same process: pick up a resumed task now instead of on the next poll
213
+ return {"status": status, "state": task.state if task else None}
214
+
215
+
216
+ @router.post("/api/tasks/{task_id}/feedback")
217
+ async def task_feedback(request: Request, task_id: str, viewer: Viewer = Depends(require_viewer_write)) -> dict:
218
+ if not TASK_ID.fullmatch(task_id):
219
+ raise HTTPException(status_code=404, detail="task not found")
220
+ store = request.app.state.store
221
+ if store.get_task(task_id) is None:
222
+ raise HTTPException(status_code=404, detail="task not found")
223
+ try:
224
+ body = await request.json()
225
+ except json.JSONDecodeError:
226
+ raise HTTPException(status_code=400, detail="json body required")
227
+ rating = body.get("rating")
228
+ if not isinstance(rating, int) or isinstance(rating, bool) or not 1 <= rating <= 5:
229
+ raise HTTPException(status_code=400, detail="rating must be an integer between 1 and 5")
230
+ comment = body.get("comment") or ""
231
+ if not isinstance(comment, str):
232
+ raise HTTPException(status_code=400, detail="comment must be a string")
233
+ row = store.add_feedback(task_id, viewer.email, rating, bounded_text(comment.strip(), 4000) or None)
234
+ return {"status": "recorded", "feedback": redact_bounded_value(row)}
235
+
236
+
237
+ @router.get("/api/issues")
238
+ async def list_issues(request: Request, status: str | None = None, viewer: Viewer = Depends(require_viewer)) -> dict:
239
+ store = request.app.state.store
240
+ rows = store.list_issues(status=status)
241
+ comment_counts = store.count_issue_comments_by_issue()
242
+ refine_tasks = store.active_refine_tasks_by_issue()
243
+ # rank is the importance order over still-actionable issues; new high-priority items shift the rest
244
+ rank = 0
245
+ result = []
246
+ for row in rows:
247
+ if row["status"] in RANKED_STATUSES:
248
+ rank += 1
249
+ row["rank"] = rank
250
+ else:
251
+ row["rank"] = None
252
+ row["comment_count"] = comment_counts.get(row["id"], 0)
253
+ row["refine_task_id"] = refine_tasks.get(row["id"])
254
+ result.append(redact_bounded_value(row))
255
+ configured = _approved_repos(request.app.state.config)
256
+ repos = configured + sorted({row["repo"] for row in rows if row["repo"] not in configured})
257
+ return {"issues": result, "repos": repos, "implementation_active": store.active_implementation_run()}
258
+
259
+
260
+ @router.post("/api/issues")
261
+ async def create_issue(request: Request, viewer: Viewer = Depends(require_admin)) -> dict:
262
+ try:
263
+ body = await request.json()
264
+ except json.JSONDecodeError:
265
+ raise HTTPException(status_code=400, detail="json body required")
266
+ repo = bounded_text(str(body.get("repo") or "").strip(), 200)
267
+ summary = bounded_text(str(body.get("summary") or "").strip(), 300)
268
+ issue_type = bounded_text(str(body.get("issue_type") or "").strip(), 60)
269
+ details = bounded_text(str(body.get("details") or "").strip(), 4000)
270
+ if not (repo and summary and issue_type and details):
271
+ raise HTTPException(status_code=400, detail="repo, summary, issue_type, and details are required")
272
+ if repo not in _approved_repos(request.app.state.config):
273
+ raise HTTPException(status_code=400, detail=f"repo must be one of {sorted(_approved_repos(request.app.state.config))}")
274
+ priority = body.get("priority", 50)
275
+ if not isinstance(priority, int) or isinstance(priority, bool) or not 1 <= priority <= 100:
276
+ raise HTTPException(status_code=400, detail="priority must be an integer between 1 and 100")
277
+ store = request.app.state.store
278
+ # a unique key per submission: operator entries never refresh an existing discovery proposal
279
+ row = store.record_issue(f"dashboard:{uuid.uuid4().hex[:12]}", repo, summary, issue_type, details, priority, source={"created_by": viewer.email})
280
+ store.add_admin_event(viewer.email, "issue_create", str(row["id"]), "created", {"repo": repo, "issue_type": issue_type})
281
+ return {"status": "created", "issue": redact_bounded_value(row)}
282
+
283
+
284
+ def _thread_issue_comments(store, issue_id: int) -> list[dict]:
285
+ roots: list[dict] = []
286
+ by_id: dict[int, dict] = {}
287
+ for row in store.list_issue_comments(issue_id):
288
+ item = {**row, "resolved": bool(row["resolved"]), "replies": []}
289
+ by_id[row["id"]] = item
290
+ if row["parent_comment_id"] is None:
291
+ roots.append(item)
292
+ elif row["parent_comment_id"] in by_id:
293
+ by_id[row["parent_comment_id"]]["replies"].append(item)
294
+ return roots
295
+
296
+
297
+ def _public_attachment(row: dict) -> dict:
298
+ return {key: row[key] for key in ("id", "issue_id", "comment_id", "filename", "content_type", "size_bytes", "uploaded_by", "created_at")}
299
+
300
+
301
+ @router.get("/api/issues/{issue_id}")
302
+ async def issue_detail(request: Request, issue_id: int, viewer: Viewer = Depends(require_viewer)) -> dict:
303
+ store = request.app.state.store
304
+ row = store.get_issue(issue_id)
305
+ if row is None:
306
+ raise HTTPException(status_code=404, detail="issue not found")
307
+ row["rank"] = None
308
+ row["comment_count"] = store.count_issue_comments(issue_id)
309
+ row["refine_task_id"] = store.active_refine_task(issue_id)
310
+ return {
311
+ "issue": redact_bounded_value(row),
312
+ "comments": redact_bounded_value(_thread_issue_comments(store, issue_id)),
313
+ "attachments": redact_bounded_value([_public_attachment(attachment) for attachment in store.list_issue_attachments(issue_id)]),
314
+ "refine_task_id": row["refine_task_id"],
315
+ }
316
+
317
+
318
+ @router.post("/api/issues/{issue_id}/decision")
319
+ async def issue_decision(request: Request, issue_id: int, viewer: Viewer = Depends(require_admin)) -> dict:
320
+ try:
321
+ body = await request.json()
322
+ except json.JSONDecodeError:
323
+ raise HTTPException(status_code=400, detail="json body required")
324
+ decision = str(body.get("decision") or "")
325
+ if decision not in ("approved", "denied", "proposed"):
326
+ raise HTTPException(status_code=400, detail="decision must be approved, denied, or proposed")
327
+ store = request.app.state.store
328
+ row = store.decide_issue(issue_id, decision, viewer.email)
329
+ if row is None:
330
+ raise HTTPException(status_code=409, detail="issue not found or already being implemented")
331
+ store.add_admin_event(viewer.email, "issue_decision", str(issue_id), decision)
332
+ return {"status": "recorded", "issue": redact_bounded_value(row)}
333
+
334
+
335
+ @router.post("/api/issues/{issue_id}/update")
336
+ async def update_issue(request: Request, issue_id: int, viewer: Viewer = Depends(require_admin)) -> dict:
337
+ try:
338
+ body = await request.json()
339
+ except json.JSONDecodeError:
340
+ raise HTTPException(status_code=400, detail="json body required")
341
+ summary = bounded_text(str(body.get("summary") or "").strip(), 300)
342
+ details = bounded_text(str(body.get("details") or "").strip(), 4000)
343
+ if not (summary and details):
344
+ raise HTTPException(status_code=400, detail="summary and details are required")
345
+ store = request.app.state.store
346
+ row = store.update_issue(issue_id, summary, details)
347
+ if row is None:
348
+ raise HTTPException(status_code=409, detail="issue not found or not editable")
349
+ store.add_admin_event(viewer.email, "issue_update", str(issue_id), "updated")
350
+ return {"status": "updated", "issue": redact_bounded_value(row)}
351
+
352
+
353
+ @router.post("/api/issues/{issue_id}/priority")
354
+ async def update_issue_priority(request: Request, issue_id: int, viewer: Viewer = Depends(require_admin)) -> dict:
355
+ try:
356
+ body = await request.json()
357
+ except json.JSONDecodeError:
358
+ raise HTTPException(status_code=400, detail="json body required")
359
+ priority = body.get("priority")
360
+ if not isinstance(priority, int) or isinstance(priority, bool) or not 1 <= priority <= 100:
361
+ raise HTTPException(status_code=400, detail="priority must be an integer between 1 and 100")
362
+ row = request.app.state.store.set_issue_priority(issue_id, priority)
363
+ if row is None:
364
+ raise HTTPException(status_code=409, detail="issue not found or priority is locked")
365
+ request.app.state.store.add_admin_event(viewer.email, "issue_priority", str(issue_id), "updated", {"priority": priority})
366
+ return {"status": "updated", "issue": redact_bounded_value(row)}
367
+
368
+
369
+ @router.post("/api/issues/{issue_id}/refine")
370
+ async def refine_issue(request: Request, issue_id: int, viewer: Viewer = Depends(require_admin)) -> dict:
371
+ store = request.app.state.store
372
+ if store.get_issue(issue_id) is None:
373
+ raise HTTPException(status_code=404, detail="issue not found")
374
+ task, status, active_task_id = await start_refine_task(store, request.app.state.config, request.app.state.notifier, issue_id, source=f"dashboard:{viewer.email}")
375
+ task_id = task.task_id if task else active_task_id
376
+ if status == "already_running":
377
+ raise HTTPException(status_code=409, detail=f"refine task already running: {task_id}")
378
+ store.add_admin_event(viewer.email, "issue_refine", str(issue_id), status, {"task_id": task_id})
379
+ orchestrator = request.app.state.orchestrator
380
+ if orchestrator is not None:
381
+ orchestrator.wake.set()
382
+ return {"status": status, "task_id": task_id}
383
+
384
+
385
+ @router.post("/api/issues/{issue_id}/implement")
386
+ async def implement_issue(request: Request, issue_id: int, viewer: Viewer = Depends(require_admin)) -> dict:
387
+ """the per-issue rocket action: approve (if needed) and jump the queue for this one issue."""
388
+ store = request.app.state.store
389
+ existing = store.get_issue(issue_id)
390
+ if existing is None:
391
+ raise HTTPException(status_code=404, detail="issue not found")
392
+ if existing["status"] not in ("proposed", "approved"):
393
+ raise HTTPException(status_code=409, detail="issue is not eligible for implementation")
394
+ task, status, active_task_id = await start_implementation_run(
395
+ store,
396
+ request.app.state.config,
397
+ request.app.state.notifier,
398
+ issue_ids=[issue_id],
399
+ actor=viewer.email,
400
+ thread_key=f"dashboard-implement:{viewer.email}@{utcnow()}",
401
+ )
402
+ task_id = task.task_id if task else active_task_id
403
+ if status == "already_running":
404
+ raise HTTPException(status_code=409, detail=f"implementation already running: {task_id}")
405
+ if status == "no_approved_issues":
406
+ raise HTTPException(status_code=409, detail="issue is not eligible for implementation")
407
+ store.add_admin_event(viewer.email, "issue_implement", str(issue_id), status, {"task_id": task_id})
408
+ orchestrator = request.app.state.orchestrator
409
+ if orchestrator is not None:
410
+ orchestrator.wake.set()
411
+ return {"status": status, "task_id": task_id}
412
+
413
+
414
+ @router.delete("/api/issues/{issue_id}")
415
+ async def delete_issue(request: Request, issue_id: int, viewer: Viewer = Depends(require_admin)) -> dict:
416
+ store = request.app.state.store
417
+ existing = store.get_issue(issue_id)
418
+ if existing is None:
419
+ raise HTTPException(status_code=404, detail="issue not found")
420
+ if existing["status"] in ("in_progress", "in_review"):
421
+ raise HTTPException(status_code=409, detail="cannot delete an issue that is in progress or in review")
422
+ if store.delete_issue(issue_id) is None:
423
+ raise HTTPException(status_code=409, detail="issue not found or not deletable")
424
+ store.add_admin_event(viewer.email, "issue_delete", str(issue_id), "deleted", {"repo": existing["repo"]})
425
+ return {"status": "deleted"}
426
+
427
+
428
+ @router.post("/api/issues/bulk")
429
+ async def bulk_issues(request: Request, viewer: Viewer = Depends(require_admin)) -> dict:
430
+ try:
431
+ body = await request.json()
432
+ except json.JSONDecodeError:
433
+ raise HTTPException(status_code=400, detail="json body required")
434
+ ids = body.get("ids")
435
+ action = str(body.get("action") or "")
436
+ if not isinstance(ids, list) or not ids or not all(isinstance(value, int) and not isinstance(value, bool) for value in ids):
437
+ raise HTTPException(status_code=400, detail="ids must be a non-empty list of integers")
438
+ if action not in ("approve", "deny", "refine", "delete", "implement"):
439
+ raise HTTPException(status_code=400, detail="action must be approve, deny, refine, delete, or implement")
440
+ store = request.app.state.store
441
+ unique_ids = list(dict.fromkeys(ids))
442
+ results = []
443
+ if action == "implement":
444
+ eligible_ids = []
445
+ for issue_id in unique_ids:
446
+ row = store.get_issue(issue_id)
447
+ if row is None:
448
+ results.append({"id": issue_id, "status": "not_found"})
449
+ elif row["status"] not in ("proposed", "approved"):
450
+ results.append({"id": issue_id, "status": "skipped"})
451
+ else:
452
+ eligible_ids.append(issue_id)
453
+ # implement starts one coordinator scoped to the whole selection, so an over-cap selection is rejected outright rather than silently truncated
454
+ if len(eligible_ids) > IMPLEMENTATION_BATCH_SIZE:
455
+ raise HTTPException(status_code=400, detail=f"cannot implement more than {IMPLEMENTATION_BATCH_SIZE} issues at once ({len(eligible_ids)} eligible)")
456
+ if eligible_ids:
457
+ task, status, active_task_id = await start_implementation_run(
458
+ store,
459
+ request.app.state.config,
460
+ request.app.state.notifier,
461
+ issue_ids=eligible_ids,
462
+ actor=viewer.email,
463
+ thread_key=f"dashboard-bulk-implement:{viewer.email}@{utcnow()}",
464
+ )
465
+ task_id = task.task_id if task else active_task_id
466
+ results += [{"id": issue_id, "status": status, "task_id": task_id} for issue_id in eligible_ids]
467
+ else:
468
+ for issue_id in unique_ids:
469
+ if action in ("approve", "deny"):
470
+ row = store.decide_issue(issue_id, "approved" if action == "approve" else "denied", viewer.email)
471
+ results.append({"id": issue_id, "status": ("approved" if action == "approve" else "denied") if row else "skipped"})
472
+ elif action == "delete":
473
+ # delete_issue already refuses in_progress/in_review rows
474
+ results.append({"id": issue_id, "status": "deleted" if store.delete_issue(issue_id) else "skipped"})
475
+ elif store.get_issue(issue_id) is None:
476
+ results.append({"id": issue_id, "status": "not_found"})
477
+ else:
478
+ task, status, active_task_id = await start_refine_task(store, request.app.state.config, request.app.state.notifier, issue_id, source=f"dashboard-bulk:{viewer.email}")
479
+ results.append({"id": issue_id, "status": status, "task_id": task.task_id if task else active_task_id})
480
+ store.add_admin_event(viewer.email, "issue_bulk", action, "completed", {"ids": ids, "results": results})
481
+ orchestrator = request.app.state.orchestrator
482
+ if orchestrator is not None and action in ("refine", "implement"):
483
+ orchestrator.wake.set()
484
+ return {"results": results}
485
+
486
+
487
+ @router.post("/api/issues/{issue_id}/comments")
488
+ async def add_issue_comment(request: Request, issue_id: int, viewer: Viewer = Depends(require_viewer_write)) -> dict:
489
+ try:
490
+ body = await request.json()
491
+ except json.JSONDecodeError:
492
+ raise HTTPException(status_code=400, detail="json body required")
493
+ comment_body = bounded_text(str(body.get("body") or "").strip(), 4000)
494
+ parent_comment_id = body.get("parent_comment_id")
495
+ if not comment_body:
496
+ raise HTTPException(status_code=400, detail="body is required")
497
+ if parent_comment_id is not None and (not isinstance(parent_comment_id, int) or isinstance(parent_comment_id, bool)):
498
+ raise HTTPException(status_code=400, detail="parent_comment_id must be an integer or null")
499
+ store = request.app.state.store
500
+ try:
501
+ row = store.add_issue_comment(issue_id, viewer.email, comment_body, parent_comment_id)
502
+ except LookupError:
503
+ raise HTTPException(status_code=404, detail="issue not found")
504
+ except ValueError as exc:
505
+ raise HTTPException(status_code=400, detail=str(exc))
506
+ store.add_admin_event(viewer.email, "issue_comment_create", str(row["id"]), "created", {"issue_id": issue_id, "parent_comment_id": parent_comment_id})
507
+ return {"status": "created", "comment": redact_bounded_value(row)}
508
+
509
+
510
+ def _editable_comment(store, issue_id: int, comment_id: int, viewer: Viewer, action: str) -> dict:
511
+ comment = store.get_issue_comment(comment_id)
512
+ if comment is None or comment["issue_id"] != issue_id:
513
+ raise HTTPException(status_code=404, detail="comment not found")
514
+ if comment["author"].lower() != viewer.email.lower():
515
+ raise HTTPException(status_code=403, detail=f"only the comment author can {action} it")
516
+ if comment["deleted_at"] is not None:
517
+ raise HTTPException(status_code=409, detail="comment is already deleted")
518
+ return comment
519
+
520
+
521
+ @router.post("/api/issues/{issue_id}/comments/{comment_id}/update")
522
+ async def update_issue_comment(request: Request, issue_id: int, comment_id: int, viewer: Viewer = Depends(require_viewer_write)) -> dict:
523
+ store = request.app.state.store
524
+ _editable_comment(store, issue_id, comment_id, viewer, "edit")
525
+ try:
526
+ body = await request.json()
527
+ except json.JSONDecodeError:
528
+ raise HTTPException(status_code=400, detail="json body required")
529
+ comment_body = bounded_text(str(body.get("body") or "").strip(), 4000)
530
+ if not comment_body:
531
+ raise HTTPException(status_code=400, detail="body is required")
532
+ row = store.update_issue_comment(comment_id, comment_body)
533
+ store.add_admin_event(viewer.email, "issue_comment_update", str(comment_id), "updated", {"issue_id": issue_id})
534
+ return {"status": "updated", "comment": redact_bounded_value(row)}
535
+
536
+
537
+ @router.post("/api/issues/{issue_id}/comments/{comment_id}/delete")
538
+ async def delete_issue_comment(request: Request, issue_id: int, comment_id: int, viewer: Viewer = Depends(require_viewer_write)) -> dict:
539
+ store = request.app.state.store
540
+ _editable_comment(store, issue_id, comment_id, viewer, "delete")
541
+ row = store.delete_issue_comment(comment_id)
542
+ store.add_admin_event(viewer.email, "issue_comment_delete", str(comment_id), "deleted", {"issue_id": issue_id})
543
+ return {"status": "deleted", "comment": redact_bounded_value(row)}
544
+
545
+
546
+ @router.post("/api/issues/{issue_id}/attachments")
547
+ async def upload_issue_attachment(
548
+ request: Request,
549
+ issue_id: int,
550
+ file: UploadFile = File(...),
551
+ comment_id: int | None = Form(None),
552
+ viewer: Viewer = Depends(require_viewer_write),
553
+ ) -> dict:
554
+ store = request.app.state.store
555
+ if store.get_issue(issue_id) is None:
556
+ raise HTTPException(status_code=404, detail="issue not found")
557
+ if comment_id is not None:
558
+ comment = store.get_issue_comment(comment_id)
559
+ if comment is None or comment["issue_id"] != issue_id:
560
+ raise HTTPException(status_code=400, detail="comment_id must belong to this issue")
561
+ bucket = str(((request.app.state.config.raw.get("issues") or {}).get("uploads_bucket")) or "")
562
+ if not bucket:
563
+ raise HTTPException(status_code=503, detail="issue uploads are not configured")
564
+ original_name = Path(file.filename or "").name.strip()
565
+ if not original_name:
566
+ raise HTTPException(status_code=400, detail="filename is required")
567
+ safe_name = re.sub(r"[^A-Za-z0-9._-]+", "_", original_name).strip("._") or "attachment"
568
+ chunks = []
569
+ size = 0
570
+ while True:
571
+ chunk = await file.read(1024 * 1024)
572
+ if not chunk:
573
+ break
574
+ size += len(chunk)
575
+ if size > MAX_ISSUE_UPLOAD_BYTES:
576
+ raise HTTPException(status_code=413, detail="attachment exceeds the 5 MB limit")
577
+ chunks.append(chunk)
578
+ if size == 0:
579
+ raise HTTPException(status_code=400, detail="attachment must not be empty")
580
+ key = f"issues/{issue_id}/{uuid.uuid4().hex}/{safe_name}"
581
+ try:
582
+ await asyncio.to_thread(_put_object, bucket, key, b"".join(chunks), file.content_type)
583
+ except Exception:
584
+ raise HTTPException(status_code=502, detail="issue attachment upload failed")
585
+ row = store.add_issue_attachment(issue_id, comment_id, original_name, file.content_type, size, key, viewer.email)
586
+ store.add_admin_event(viewer.email, "issue_attachment_upload", str(row["id"]), "uploaded", {"issue_id": issue_id, "comment_id": comment_id, "size_bytes": size})
587
+ return {"status": "uploaded", "attachment": redact_bounded_value(_public_attachment(row))}
588
+
589
+
590
+ @router.get("/api/issues/{issue_id}/attachments/{attachment_id}/download")
591
+ async def download_issue_attachment(request: Request, issue_id: int, attachment_id: int, viewer: Viewer = Depends(require_viewer)):
592
+ attachment = request.app.state.store.get_issue_attachment(attachment_id)
593
+ if attachment is None or attachment["issue_id"] != issue_id:
594
+ raise HTTPException(status_code=404, detail="attachment not found")
595
+ bucket = str(((request.app.state.config.raw.get("issues") or {}).get("uploads_bucket")) or "")
596
+ if not bucket:
597
+ raise HTTPException(status_code=503, detail="issue uploads are not configured")
598
+ try:
599
+ url = await asyncio.to_thread(_presign, bucket, attachment["s3_key"])
600
+ except Exception:
601
+ raise HTTPException(status_code=502, detail="issue attachment download failed")
602
+ return RedirectResponse(url, status_code=307)
603
+
604
+
605
+ @router.post("/api/issues/run")
606
+ async def issues_run(request: Request, viewer: Viewer = Depends(require_admin)) -> dict:
607
+ try:
608
+ body = await request.json()
609
+ except json.JSONDecodeError:
610
+ raise HTTPException(status_code=400, detail="json body required")
611
+ skill = str(body.get("skill") or "")
612
+ if skill not in ISSUE_SKILLS:
613
+ raise HTTPException(status_code=400, detail=f"skill must be one of {sorted(ISSUE_SKILLS)}")
614
+ store = request.app.state.store
615
+ if skill == "implementapprovedissues":
616
+ task, status, active_task_id = await start_implementation_run(store, request.app.state.config, request.app.state.notifier, thread_key=f"dashboard:{viewer.email}@{utcnow()}")
617
+ task_id = task.task_id if task else active_task_id
618
+ else:
619
+ repo = str(body.get("repo") or "").strip()
620
+ if not repo or repo not in _approved_repos(request.app.state.config):
621
+ raise HTTPException(status_code=400, detail=f"repo must be one of {sorted(_approved_repos(request.app.state.config))}")
622
+ task, status = await start_issue_task(store, request.app.state.config, request.app.state.notifier, skill, repo, source=f"dashboard:{viewer.email}")
623
+ task_id = task.task_id if task else None
624
+ store.add_admin_event(viewer.email, "issue_run", skill, status, {"task_id": task_id, "repo": body.get("repo")})
625
+ orchestrator = request.app.state.orchestrator
626
+ if orchestrator is not None:
627
+ orchestrator.wake.set()
628
+ return {"status": status, "task_id": task_id}
629
+
630
+
631
+ def _catalog_models(config) -> list[str]:
632
+ return sorted((config.raw.get("models") or {}).keys())
633
+
634
+
635
+ def _validate_schedule(body: dict, models: set[str], now: datetime) -> dict:
636
+ """normalize and validate a schedule create/update body, computing its first next_run_at (UTC)."""
637
+ name = str(body.get("name") or "").strip()
638
+ request_text = str(body.get("request_text") or "").strip()
639
+ if not name or not request_text:
640
+ raise HTTPException(status_code=400, detail="name and request_text are required")
641
+ kind = str(body.get("kind") or "")
642
+ if kind not in ("once", "interval", "daily"):
643
+ raise HTTPException(status_code=400, detail="kind must be once, interval, or daily")
644
+ model_alias = str(body.get("model_alias") or "").strip() or None
645
+ if model_alias is not None and model_alias not in models:
646
+ raise HTTPException(status_code=400, detail=f"model must be blank or one of {sorted(models)}")
647
+ effort = str(body.get("effort") or "").strip() or None
648
+ if effort is not None and effort not in EFFORT_LEVELS:
649
+ raise HTTPException(status_code=400, detail=f"effort must be blank or one of {EFFORT_LEVELS}")
650
+ tzname = str(body.get("timezone") or "").strip() or None
651
+ if tzname is not None:
652
+ try:
653
+ ZoneInfo(tzname)
654
+ except (ZoneInfoNotFoundError, ValueError):
655
+ raise HTTPException(status_code=400, detail=f"timezone {tzname!r} is not a known IANA timezone")
656
+ max_runs = body.get("max_runs")
657
+ if max_runs is not None and (not isinstance(max_runs, int) or isinstance(max_runs, bool) or max_runs < 1):
658
+ raise HTTPException(status_code=400, detail="max_runs must be a positive integer or null")
659
+ interval_minutes = at_time = run_at = None
660
+ if kind == "interval":
661
+ interval_minutes = body.get("interval_minutes")
662
+ if not isinstance(interval_minutes, int) or isinstance(interval_minutes, bool) or interval_minutes < 1:
663
+ raise HTTPException(status_code=400, detail="interval_minutes must be a positive integer")
664
+ elif kind == "daily":
665
+ at_time = str(body.get("at_time") or "")
666
+ if re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", at_time) is None:
667
+ raise HTTPException(status_code=400, detail="at_time must be HH:MM in 24-hour time")
668
+ else: # once
669
+ try:
670
+ local = datetime.fromisoformat(str(body.get("run_at") or "").strip())
671
+ except ValueError:
672
+ raise HTTPException(status_code=400, detail="run_at must be an ISO date-time like 2026-08-01T14:00")
673
+ tzinfo = ZoneInfo(tzname) if tzname else timezone.utc
674
+ if local.tzinfo is None:
675
+ local = local.replace(tzinfo=tzinfo)
676
+ run_at = local.astimezone(timezone.utc).isoformat(timespec="seconds")
677
+ max_runs = 1 # a one-off runs exactly once
678
+ nxt = next_run_after(kind, interval_minutes=interval_minutes, at_time=at_time, run_at=run_at, tzname=tzname, after=now)
679
+ if nxt is None:
680
+ raise HTTPException(status_code=400, detail="that schedule has no future run time — a one-off must be in the future")
681
+ return {
682
+ "name": bounded_text(name, 200),
683
+ "request_text": bounded_text(request_text, 4000),
684
+ "model_alias": model_alias,
685
+ "effort": effort,
686
+ "kind": kind,
687
+ "interval_minutes": interval_minutes,
688
+ "at_time": at_time,
689
+ "run_at": run_at,
690
+ "timezone": tzname,
691
+ "max_runs": max_runs,
692
+ "next_run_at": nxt.isoformat(timespec="seconds"),
693
+ }
694
+
695
+
696
+ @router.get("/api/schedules")
697
+ async def list_schedules(request: Request, viewer: Viewer = Depends(require_viewer)) -> dict:
698
+ store = request.app.state.store
699
+ return {"schedules": [redact_bounded_value(row) for row in store.list_schedules()], "models": _catalog_models(request.app.state.config)}
700
+
701
+
702
+ @router.post("/api/schedules")
703
+ async def create_schedule(request: Request, viewer: Viewer = Depends(require_admin)) -> dict:
704
+ try:
705
+ body = await request.json()
706
+ except json.JSONDecodeError:
707
+ raise HTTPException(status_code=400, detail="json body required")
708
+ fields = _validate_schedule(body, set(_catalog_models(request.app.state.config)), _now())
709
+ row = request.app.state.store.create_schedule(created_by=viewer.email, **fields)
710
+ request.app.state.store.add_admin_event(viewer.email, "schedule_create", str(row["id"]), "created", {"kind": fields["kind"]})
711
+ return {"schedule": redact_bounded_value(row)}
712
+
713
+
714
+ @router.post("/api/schedules/{schedule_id}")
715
+ async def update_schedule(request: Request, schedule_id: int, viewer: Viewer = Depends(require_admin)) -> dict:
716
+ store = request.app.state.store
717
+ if store.get_schedule(schedule_id) is None:
718
+ raise HTTPException(status_code=404, detail="schedule not found")
719
+ try:
720
+ body = await request.json()
721
+ except json.JSONDecodeError:
722
+ raise HTTPException(status_code=400, detail="json body required")
723
+ if set(body) <= {"enabled"}: # lightweight enable/disable toggle
724
+ enabled = bool(body.get("enabled"))
725
+ row = store.update_schedule(schedule_id, enabled=1 if enabled else 0)
726
+ store.add_admin_event(viewer.email, "schedule_update", str(schedule_id), "enabled" if enabled else "disabled")
727
+ return {"schedule": redact_bounded_value(row)}
728
+ fields = _validate_schedule(body, set(_catalog_models(request.app.state.config)), _now())
729
+ row = store.update_schedule(schedule_id, **fields)
730
+ store.add_admin_event(viewer.email, "schedule_update", str(schedule_id), "updated", {"kind": fields["kind"]})
731
+ return {"schedule": redact_bounded_value(row)}
732
+
733
+
734
+ @router.post("/api/schedules/{schedule_id}/run")
735
+ async def run_schedule(request: Request, schedule_id: int, viewer: Viewer = Depends(require_admin)) -> dict:
736
+ store = request.app.state.store
737
+ schedule = store.get_schedule(schedule_id)
738
+ if schedule is None:
739
+ raise HTTPException(status_code=404, detail="schedule not found")
740
+ task, status = await fire_schedule_now(store, request.app.state.config, request.app.state.notifier, schedule)
741
+ store.add_admin_event(viewer.email, "schedule_run", str(schedule_id), status, {"task_id": task.task_id if task else None})
742
+ orchestrator = request.app.state.orchestrator
743
+ if orchestrator is not None:
744
+ orchestrator.wake.set()
745
+ return {"status": status, "task_id": task.task_id if task else None}
746
+
747
+
748
+ @router.delete("/api/schedules/{schedule_id}")
749
+ async def delete_schedule(request: Request, schedule_id: int, viewer: Viewer = Depends(require_admin)) -> dict:
750
+ store = request.app.state.store
751
+ if not store.delete_schedule(schedule_id):
752
+ raise HTTPException(status_code=404, detail="schedule not found")
753
+ store.add_admin_event(viewer.email, "schedule_delete", str(schedule_id), "deleted")
754
+ return {"status": "deleted"}
755
+
756
+
757
+ @router.get("/api/memory")
758
+ async def memory_index(request: Request, q: str = "", viewer: Viewer = Depends(require_viewer)) -> dict:
759
+ root = Path(settings.MEMORY_ROOT) / "tasks"
760
+ records = []
761
+ if root.is_dir():
762
+ for path in sorted(root.glob("*.md"), key=lambda item: item.stat().st_mtime, reverse=True):
763
+ try:
764
+ content = path.read_text()
765
+ if q.lower() not in path.stem.lower() and q.lower() not in content.lower():
766
+ continue
767
+ info = path.stat()
768
+ records.append({"task_id": path.stem, "modified": datetime.fromtimestamp(info.st_mtime, timezone.utc).isoformat(timespec="seconds"), "size": info.st_size, "preview": bounded_text(content, 200)})
769
+ except (OSError, UnicodeError):
770
+ continue
771
+ if len(records) >= 200:
772
+ break
773
+ return {"records": records}
774
+
775
+
776
+ @router.get("/api/memory/{task_id}")
777
+ async def memory_detail(request: Request, task_id: str, viewer: Viewer = Depends(require_viewer)) -> dict:
778
+ if not TASK_ID.fullmatch(task_id):
779
+ raise HTTPException(status_code=404, detail="memory record not found")
780
+ content = memory.read_summary(settings.MEMORY_ROOT, task_id)
781
+ if content is None:
782
+ raise HTTPException(status_code=404, detail="memory record not found")
783
+ task = request.app.state.store.get_task(task_id)
784
+ return {"task_id": task_id, "content": bounded_text(content, 100000), "state": task.state if task else None}
785
+
786
+
787
+ def _usage_card(store, label: str, since: datetime | None = None, model: str | None = None, limit_tokens: int | None = None, observed: dict | None = None) -> dict:
788
+ since_iso = _iso(since) if since else None
789
+ totals = store.usage_totals(since_iso=since_iso, model=model)
790
+ total_tokens = totals["input_tokens"] + totals["output_tokens"] + totals["cache_read_tokens"] + totals["cache_write_tokens"]
791
+ return {"label": label, "since": since_iso, "totals": totals, "by_model": store.usage_by_model(since_iso=since_iso, model=model), "total_tokens": total_tokens, "limit_tokens": limit_tokens, "observed": observed}
792
+
793
+
794
+ @router.get("/api/usage")
795
+ async def usage(request: Request, viewer: Viewer = Depends(require_viewer)) -> dict:
796
+ store = request.app.state.store
797
+ now = _now()
798
+ fable_model = str((((request.app.state.config.raw.get("models") or {}).get("fable") or {}).get("id") or "claude-fable-5"))
799
+ limits = request.app.state.config.raw.get("usage_limits") or {}
800
+ windows = store.rate_limit_windows()
801
+ now_epoch = int(now.timestamp())
802
+
803
+ def window_for(card: str) -> dict | None:
804
+ if card == "five_hour":
805
+ candidates = (row for row in windows if row["rate_limit_type"] == "five_hour")
806
+ elif card == "weekly":
807
+ candidates = (row for row in windows if row["rate_limit_type"] == "seven_day")
808
+ else:
809
+ candidates = (row for row in windows if "fable" in row["rate_limit_type"].lower())
810
+ row = next(candidates, None)
811
+ if row is None or row["utilization"] is None or row["resets_at"] is None or row["resets_at"] <= now_epoch:
812
+ return None
813
+ return {key: row[key] for key in ("utilization", "resets_at", "status", "observed_at")}
814
+
815
+ def configured_limit(key: str) -> int | None:
816
+ try:
817
+ value = int(limits.get(key) or 0)
818
+ except (TypeError, ValueError):
819
+ return None
820
+ return value if value > 0 else None
821
+
822
+ return {
823
+ "generated_at": _iso(now),
824
+ "fable_model": fable_model,
825
+ "cards": {
826
+ "five_hour": _usage_card(store, "Rolling 5 hours", now - timedelta(hours=5), limit_tokens=configured_limit("five_hour_tokens"), observed=window_for("five_hour")),
827
+ "weekly": _usage_card(store, "Rolling 7 days", now - timedelta(days=7), limit_tokens=configured_limit("weekly_tokens"), observed=window_for("weekly")),
828
+ "fable": _usage_card(store, "Fable — rolling 7 days", now - timedelta(days=7), model=fable_model, limit_tokens=configured_limit("fable_weekly_tokens"), observed=window_for("fable")),
829
+ },
830
+ "timeseries": store.usage_timeseries(_iso(now - timedelta(days=7))),
831
+ }
832
+
833
+
834
+ @router.get("/api/config")
835
+ async def config_view(request: Request, viewer: Viewer = Depends(require_viewer)) -> dict:
836
+ config = request.app.state.config
837
+ app_secrets = request.app.state.secrets
838
+ runtime = {
839
+ "environment": settings.ENVIRONMENT,
840
+ "database_path": settings.DB_PATH,
841
+ "config_path": settings.CONFIG_PATH,
842
+ "workspaces_root": settings.WORKSPACES_ROOT,
843
+ "repositories_root": settings.REPOS_ROOT,
844
+ "memory_root": settings.MEMORY_ROOT,
845
+ "skills_root": settings.SKILLS_ROOT,
846
+ "aws_region": settings.REGION,
847
+ "secrets_bundle_name": settings.SECRETS_NAME,
848
+ }
849
+ secret_presence = {name: bool(getattr(app_secrets, name)) for name in app_secrets.__dataclass_fields__} if app_secrets else {}
850
+ services_dir = Path(settings.CONFIG_PATH).parent / "services"
851
+ return {
852
+ "runtime": redact_value(runtime),
853
+ "policy": redact_value(config.raw),
854
+ "dashboard": redact_value(asdict(config.dashboard)),
855
+ "secret_presence": secret_presence,
856
+ "skills": skills.available(settings.SKILLS_ROOT),
857
+ # editable = the section lives in its own services/<name>.yaml file (legacy inline sections are edited via config.yaml)
858
+ "services": {name: {"enabled": config.service_enabled(name), "editable": (services_dir / f"{name}.yaml").is_file()} for name in KNOWN_SERVICES},
859
+ }
860
+
861
+
862
+ @router.get("/api/admin-events")
863
+ async def admin_events(request: Request, viewer: Viewer = Depends(require_admin)) -> dict:
864
+ return {"events": [redact_bounded_value(row) for row in request.app.state.store.admin_events(100)]}
865
+
866
+
867
+ @router.get("/api/manage/{kind}")
868
+ async def manage_read(request: Request, kind: str, name: str | None = None, viewer: Viewer = Depends(require_admin)) -> dict:
869
+ if kind not in EDITABLE_KINDS:
870
+ raise HTTPException(status_code=404, detail="editable target not found")
871
+ target, repo_path, title = target_for(request.app.state.config, kind, name)
872
+ content = target.read_text() if target.exists() else ""
873
+ if contains_secret_submission(kind, content):
874
+ request.app.state.store.add_admin_event(viewer.email, "edit", str(target), "blocked", {"reason": "existing file contains secret-looking values"})
875
+ raise HTTPException(status_code=409, detail="this file contains secret-looking values and cannot be edited here")
876
+ return {"kind": kind, "name": name, "title": title, "content": content, "base_hash": content_hash(content), "repo_path": repo_path, "auto_commit": request.app.state.config.dashboard.auto_commit_enabled}
877
+
878
+
879
+ @router.post("/api/manage/{kind}")
880
+ async def manage_write(request: Request, kind: str, viewer: Viewer = Depends(require_admin)) -> dict:
881
+ if kind not in EDITABLE_KINDS:
882
+ raise HTTPException(status_code=404, detail="editable target not found")
883
+ store = request.app.state.store
884
+ config = request.app.state.config
885
+ try:
886
+ body = await request.json()
887
+ except json.JSONDecodeError:
888
+ raise HTTPException(status_code=400, detail="json body required")
889
+ name = body.get("name") or None
890
+ content = str(body.get("content") or "")
891
+ base_hash = str(body.get("base_hash") or "")
892
+ action = str(body.get("action") or "preview")
893
+ if len(content) > 2_000_000:
894
+ raise HTTPException(status_code=413, detail="submitted content is too large")
895
+ target, repo_path, title = target_for(config, kind, name)
896
+ previous = target.read_text() if target.exists() else ""
897
+ if content_hash(previous) != base_hash:
898
+ store.add_admin_event(viewer.email, "edit", str(target), "stale", {"previous_hash": content_hash(previous), "submitted_base_hash": base_hash})
899
+ raise HTTPException(status_code=409, detail="this file changed since you loaded it — reload before saving")
900
+ try:
901
+ validate(kind, name, content, target)
902
+ except (ConfigError, SkillError, ValueError, OSError, UnicodeError) as e:
903
+ store.add_admin_event(viewer.email, "edit", str(target), "rejected", {"reason": safe_text(str(e))})
904
+ raise HTTPException(status_code=422, detail=safe_text(str(e)))
905
+ diff = unified_diff(previous, content, title)
906
+ if action != "confirm":
907
+ return {"saved": False, "diff": safe_text(diff), "title": title}
908
+ try:
909
+ atomic_write(target, content)
910
+ except OSError as e:
911
+ store.add_admin_event(viewer.email, "edit", str(target), "failed", {"reason": type(e).__name__})
912
+ raise HTTPException(status_code=500, detail="the validated file could not be written")
913
+ store.add_admin_event(viewer.email, "edit", str(target), "success", {"previous_hash": content_hash(previous), "new_hash": content_hash(content), "diff": safe_text(diff)})
914
+ commit_result = None
915
+ commit_error = None
916
+ token = getattr(request.app.state.secrets, "dashboard_github_token", "") if request.app.state.secrets else ""
917
+ if config.dashboard.auto_commit_enabled and token:
918
+ try:
919
+ commit_result = await gitops.commit_file(
920
+ token, config.dashboard.commit_repo, config.dashboard.commit_branch, repo_path, content, f"mission control: {viewer.email} edited {repo_path}", viewer.email, committer_name=config.dashboard.committer_name, committer_email=config.dashboard.committer_email
921
+ )
922
+ store.add_admin_event(viewer.email, "commit", repo_path, "success" if not commit_result.get("unchanged") else "unchanged", {"sha": commit_result.get("commit_sha", "")})
923
+ except (gitops.GitOpsError, OSError) as e:
924
+ commit_error = safe_text(str(e))
925
+ store.add_admin_event(viewer.email, "commit", repo_path, "failed", {"reason": commit_error})
926
+ elif config.dashboard.auto_commit_enabled:
927
+ commit_error = "auto-commit is configured but dashboard_github_token is not set"
928
+ if kind in ("config", "service"):
929
+ message = "Saved. Configuration changes apply after a service restart."
930
+ else:
931
+ message = "Saved and live immediately."
932
+ return {"saved": True, "message": message, "diff": safe_text(diff), "commit": commit_result, "commit_error": commit_error}
933
+
934
+
935
+ @router.get("/api/stream")
936
+ async def stream(request: Request, viewer: Viewer = Depends(require_viewer)) -> StreamingResponse:
937
+ store = request.app.state.store
938
+
939
+ async def events():
940
+ after = request.query_params.get("after", "")
941
+ last_id = int(after) if after.isdigit() else store.latest_event_id()
942
+ last_counts: dict = {}
943
+ idle = 0.0
944
+ yield f"event: hello\ndata: {json.dumps({'last_event_id': last_id})}\n\n"
945
+ while True:
946
+ if await request.is_disconnected():
947
+ return
948
+ rows = store.events_after(last_id, limit=500)
949
+ for row in rows:
950
+ yield f"event: task_event\ndata: {json.dumps(redact_bounded_value(row))}\n\n"
951
+ if rows:
952
+ last_id = rows[-1]["id"]
953
+ counts = store.task_counts()
954
+ if counts != last_counts:
955
+ last_counts = counts
956
+ yield f"event: counts\ndata: {json.dumps(counts)}\n\n"
957
+ idle += 2
958
+ if idle >= 20:
959
+ idle = 0
960
+ yield ": keep-alive\n\n"
961
+ await asyncio.sleep(2)
962
+
963
+ return StreamingResponse(events(), media_type="text/event-stream", headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"})