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/dashboard/api.py
ADDED
|
@@ -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"})
|