codex-autorunner 1.1.0__py3-none-any.whl → 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codex_autorunner/agents/opencode/client.py +113 -4
- codex_autorunner/agents/opencode/supervisor.py +4 -0
- codex_autorunner/agents/registry.py +17 -7
- codex_autorunner/bootstrap.py +219 -1
- codex_autorunner/core/__init__.py +17 -1
- codex_autorunner/core/about_car.py +114 -1
- codex_autorunner/core/app_server_threads.py +6 -0
- codex_autorunner/core/config.py +236 -1
- codex_autorunner/core/context_awareness.py +38 -0
- codex_autorunner/core/docs.py +0 -122
- codex_autorunner/core/filebox.py +265 -0
- codex_autorunner/core/flows/controller.py +71 -1
- codex_autorunner/core/flows/reconciler.py +4 -1
- codex_autorunner/core/flows/runtime.py +22 -0
- codex_autorunner/core/flows/store.py +61 -9
- codex_autorunner/core/flows/transition.py +23 -16
- codex_autorunner/core/flows/ux_helpers.py +18 -3
- codex_autorunner/core/flows/worker_process.py +32 -6
- codex_autorunner/core/hub.py +198 -41
- codex_autorunner/core/lifecycle_events.py +253 -0
- codex_autorunner/core/path_utils.py +2 -1
- codex_autorunner/core/pma_audit.py +224 -0
- codex_autorunner/core/pma_context.py +496 -0
- codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
- codex_autorunner/core/pma_lifecycle.py +527 -0
- codex_autorunner/core/pma_queue.py +367 -0
- codex_autorunner/core/pma_safety.py +221 -0
- codex_autorunner/core/pma_state.py +115 -0
- codex_autorunner/core/ports/agent_backend.py +2 -5
- codex_autorunner/core/ports/run_event.py +1 -4
- codex_autorunner/core/prompt.py +0 -80
- codex_autorunner/core/prompts.py +56 -172
- codex_autorunner/core/redaction.py +0 -4
- codex_autorunner/core/review_context.py +11 -9
- codex_autorunner/core/runner_controller.py +35 -33
- codex_autorunner/core/runner_state.py +147 -0
- codex_autorunner/core/runtime.py +829 -0
- codex_autorunner/core/sqlite_utils.py +13 -4
- codex_autorunner/core/state.py +7 -10
- codex_autorunner/core/state_roots.py +5 -0
- codex_autorunner/core/templates/__init__.py +39 -0
- codex_autorunner/core/templates/git_mirror.py +234 -0
- codex_autorunner/core/templates/provenance.py +56 -0
- codex_autorunner/core/templates/scan_cache.py +120 -0
- codex_autorunner/core/ticket_linter_cli.py +17 -0
- codex_autorunner/core/ticket_manager_cli.py +154 -92
- codex_autorunner/core/time_utils.py +11 -0
- codex_autorunner/core/types.py +18 -0
- codex_autorunner/core/utils.py +34 -6
- codex_autorunner/flows/review/service.py +23 -25
- codex_autorunner/flows/ticket_flow/definition.py +43 -1
- codex_autorunner/integrations/agents/__init__.py +2 -0
- codex_autorunner/integrations/agents/backend_orchestrator.py +18 -0
- codex_autorunner/integrations/agents/codex_backend.py +19 -8
- codex_autorunner/integrations/agents/runner.py +3 -8
- codex_autorunner/integrations/agents/wiring.py +8 -0
- codex_autorunner/integrations/telegram/doctor.py +228 -6
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
- codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +346 -58
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
- codex_autorunner/integrations/telegram/handlers/messages.py +26 -1
- codex_autorunner/integrations/telegram/helpers.py +1 -3
- codex_autorunner/integrations/telegram/runtime.py +9 -4
- codex_autorunner/integrations/telegram/service.py +30 -0
- codex_autorunner/integrations/telegram/state.py +38 -0
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
- codex_autorunner/integrations/telegram/transport.py +10 -3
- codex_autorunner/integrations/templates/__init__.py +27 -0
- codex_autorunner/integrations/templates/scan_agent.py +312 -0
- codex_autorunner/server.py +2 -2
- codex_autorunner/static/agentControls.js +21 -5
- codex_autorunner/static/app.js +115 -11
- codex_autorunner/static/chatUploads.js +137 -0
- codex_autorunner/static/docChatCore.js +185 -13
- codex_autorunner/static/fileChat.js +68 -40
- codex_autorunner/static/fileboxUi.js +159 -0
- codex_autorunner/static/hub.js +46 -81
- codex_autorunner/static/index.html +303 -24
- codex_autorunner/static/messages.js +82 -4
- codex_autorunner/static/notifications.js +255 -0
- codex_autorunner/static/pma.js +1167 -0
- codex_autorunner/static/settings.js +3 -0
- codex_autorunner/static/streamUtils.js +57 -0
- codex_autorunner/static/styles.css +9125 -6742
- codex_autorunner/static/templateReposSettings.js +225 -0
- codex_autorunner/static/ticketChatActions.js +165 -3
- codex_autorunner/static/ticketChatStream.js +17 -119
- codex_autorunner/static/ticketEditor.js +41 -13
- codex_autorunner/static/ticketTemplates.js +798 -0
- codex_autorunner/static/tickets.js +69 -19
- codex_autorunner/static/turnEvents.js +27 -0
- codex_autorunner/static/turnResume.js +33 -0
- codex_autorunner/static/utils.js +28 -0
- codex_autorunner/static/workspace.js +258 -44
- codex_autorunner/static/workspaceFileBrowser.js +6 -4
- codex_autorunner/surfaces/cli/cli.py +1465 -155
- codex_autorunner/surfaces/cli/pma_cli.py +817 -0
- codex_autorunner/surfaces/web/app.py +253 -49
- codex_autorunner/surfaces/web/routes/__init__.py +4 -0
- codex_autorunner/surfaces/web/routes/analytics.py +29 -22
- codex_autorunner/surfaces/web/routes/file_chat.py +317 -36
- codex_autorunner/surfaces/web/routes/filebox.py +227 -0
- codex_autorunner/surfaces/web/routes/flows.py +219 -29
- codex_autorunner/surfaces/web/routes/messages.py +70 -39
- codex_autorunner/surfaces/web/routes/pma.py +1652 -0
- codex_autorunner/surfaces/web/routes/repos.py +1 -1
- codex_autorunner/surfaces/web/routes/shared.py +0 -3
- codex_autorunner/surfaces/web/routes/templates.py +634 -0
- codex_autorunner/surfaces/web/runner_manager.py +2 -2
- codex_autorunner/surfaces/web/schemas.py +70 -18
- codex_autorunner/tickets/agent_pool.py +27 -0
- codex_autorunner/tickets/files.py +33 -16
- codex_autorunner/tickets/lint.py +50 -0
- codex_autorunner/tickets/models.py +3 -0
- codex_autorunner/tickets/outbox.py +41 -5
- codex_autorunner/tickets/runner.py +350 -69
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/METADATA +15 -19
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/RECORD +125 -94
- codex_autorunner/core/adapter_utils.py +0 -21
- codex_autorunner/core/engine.py +0 -3302
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1652 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hub-level PMA routes (chat + models + events).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
16
|
+
from fastapi.responses import FileResponse, StreamingResponse
|
|
17
|
+
from starlette.datastructures import UploadFile
|
|
18
|
+
|
|
19
|
+
from ....agents.codex.harness import CodexHarness
|
|
20
|
+
from ....agents.opencode.harness import OpenCodeHarness
|
|
21
|
+
from ....agents.opencode.supervisor import OpenCodeSupervisorError
|
|
22
|
+
from ....agents.registry import validate_agent_id
|
|
23
|
+
from ....bootstrap import (
|
|
24
|
+
ensure_pma_docs,
|
|
25
|
+
pma_about_content,
|
|
26
|
+
pma_active_context_content,
|
|
27
|
+
pma_agents_content,
|
|
28
|
+
pma_context_log_content,
|
|
29
|
+
pma_prompt_content,
|
|
30
|
+
)
|
|
31
|
+
from ....core.app_server_threads import PMA_KEY, PMA_OPENCODE_KEY
|
|
32
|
+
from ....core.filebox import sanitize_filename
|
|
33
|
+
from ....core.logging_utils import log_event
|
|
34
|
+
from ....core.pma_audit import PmaActionType, PmaAuditLog
|
|
35
|
+
from ....core.pma_context import (
|
|
36
|
+
PMA_MAX_TEXT,
|
|
37
|
+
build_hub_snapshot,
|
|
38
|
+
format_pma_prompt,
|
|
39
|
+
load_pma_prompt,
|
|
40
|
+
)
|
|
41
|
+
from ....core.pma_lifecycle import PmaLifecycleRouter
|
|
42
|
+
from ....core.pma_queue import PmaQueue, QueueItemState
|
|
43
|
+
from ....core.pma_safety import PmaSafetyChecker, PmaSafetyConfig
|
|
44
|
+
from ....core.pma_state import PmaStateStore
|
|
45
|
+
from ....core.time_utils import now_iso
|
|
46
|
+
from ....core.utils import atomic_write
|
|
47
|
+
from .agents import _available_agents, _serialize_model_catalog
|
|
48
|
+
from .shared import SSE_HEADERS
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
PMA_TIMEOUT_SECONDS = 28800
|
|
53
|
+
PMA_CONTEXT_SNAPSHOT_MAX_BYTES = 200_000
|
|
54
|
+
PMA_CONTEXT_LOG_SOFT_LIMIT_BYTES = 5_000_000
|
|
55
|
+
PMA_BULK_DELETE_SAMPLE_LIMIT = 10
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_pma_routes() -> APIRouter:
|
|
59
|
+
router = APIRouter(prefix="/hub/pma")
|
|
60
|
+
pma_lock = asyncio.Lock()
|
|
61
|
+
pma_event: Optional[asyncio.Event] = None
|
|
62
|
+
pma_active = False
|
|
63
|
+
pma_current: Optional[dict[str, Any]] = None
|
|
64
|
+
pma_last_result: Optional[dict[str, Any]] = None
|
|
65
|
+
pma_state_store: Optional[PmaStateStore] = None
|
|
66
|
+
pma_state_root: Optional[Path] = None
|
|
67
|
+
pma_safety_checker: Optional[PmaSafetyChecker] = None
|
|
68
|
+
pma_safety_root: Optional[Path] = None
|
|
69
|
+
pma_audit_log: Optional[PmaAuditLog] = None
|
|
70
|
+
pma_queue: Optional[PmaQueue] = None
|
|
71
|
+
pma_queue_root: Optional[Path] = None
|
|
72
|
+
lane_workers: dict[str, asyncio.Task] = {}
|
|
73
|
+
lane_cancel_events: dict[str, asyncio.Event] = {}
|
|
74
|
+
item_futures: dict[str, asyncio.Future[dict[str, Any]]] = {}
|
|
75
|
+
|
|
76
|
+
def _normalize_optional_text(value: Any) -> Optional[str]:
|
|
77
|
+
if not isinstance(value, str):
|
|
78
|
+
return None
|
|
79
|
+
value = value.strip()
|
|
80
|
+
return value or None
|
|
81
|
+
|
|
82
|
+
def _get_pma_config(request: Request) -> dict[str, Any]:
|
|
83
|
+
raw = getattr(request.app.state.config, "raw", {})
|
|
84
|
+
pma_config = raw.get("pma", {}) if isinstance(raw, dict) else {}
|
|
85
|
+
if not isinstance(pma_config, dict):
|
|
86
|
+
pma_config = {}
|
|
87
|
+
return {
|
|
88
|
+
"enabled": bool(pma_config.get("enabled", True)),
|
|
89
|
+
"default_agent": _normalize_optional_text(pma_config.get("default_agent")),
|
|
90
|
+
"model": _normalize_optional_text(pma_config.get("model")),
|
|
91
|
+
"reasoning": _normalize_optional_text(pma_config.get("reasoning")),
|
|
92
|
+
"active_context_max_lines": int(
|
|
93
|
+
pma_config.get("active_context_max_lines", 200)
|
|
94
|
+
),
|
|
95
|
+
"max_text_chars": int(pma_config.get("max_text_chars", 800)),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
def _build_idempotency_key(
|
|
99
|
+
*,
|
|
100
|
+
lane_id: str,
|
|
101
|
+
agent: Optional[str],
|
|
102
|
+
model: Optional[str],
|
|
103
|
+
reasoning: Optional[str],
|
|
104
|
+
client_turn_id: Optional[str],
|
|
105
|
+
message: str,
|
|
106
|
+
) -> str:
|
|
107
|
+
payload = {
|
|
108
|
+
"lane_id": lane_id,
|
|
109
|
+
"agent": agent,
|
|
110
|
+
"model": model,
|
|
111
|
+
"reasoning": reasoning,
|
|
112
|
+
"client_turn_id": client_turn_id,
|
|
113
|
+
"message": message,
|
|
114
|
+
}
|
|
115
|
+
raw = json.dumps(payload, sort_keys=True, default=str, ensure_ascii=True)
|
|
116
|
+
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
117
|
+
return f"pma:{digest}"
|
|
118
|
+
|
|
119
|
+
def _get_state_store(request: Request) -> PmaStateStore:
|
|
120
|
+
nonlocal pma_state_store, pma_state_root
|
|
121
|
+
hub_root = request.app.state.config.root
|
|
122
|
+
if pma_state_store is None or pma_state_root != hub_root:
|
|
123
|
+
pma_state_store = PmaStateStore(hub_root)
|
|
124
|
+
pma_state_root = hub_root
|
|
125
|
+
return pma_state_store
|
|
126
|
+
|
|
127
|
+
def _get_safety_checker(request: Request) -> PmaSafetyChecker:
|
|
128
|
+
nonlocal pma_safety_checker, pma_safety_root, pma_audit_log
|
|
129
|
+
hub_root = request.app.state.config.root
|
|
130
|
+
if pma_safety_checker is None or pma_safety_root != hub_root:
|
|
131
|
+
raw = getattr(request.app.state.config, "raw", {})
|
|
132
|
+
pma_config = raw.get("pma", {}) if isinstance(raw, dict) else {}
|
|
133
|
+
safety_config = PmaSafetyConfig(
|
|
134
|
+
dedup_window_seconds=pma_config.get("dedup_window_seconds", 300),
|
|
135
|
+
max_duplicate_actions=pma_config.get("max_duplicate_actions", 3),
|
|
136
|
+
rate_limit_window_seconds=pma_config.get(
|
|
137
|
+
"rate_limit_window_seconds", 60
|
|
138
|
+
),
|
|
139
|
+
max_actions_per_window=pma_config.get("max_actions_per_window", 20),
|
|
140
|
+
circuit_breaker_threshold=pma_config.get(
|
|
141
|
+
"circuit_breaker_threshold", 5
|
|
142
|
+
),
|
|
143
|
+
circuit_breaker_cooldown_seconds=pma_config.get(
|
|
144
|
+
"circuit_breaker_cooldown_seconds", 600
|
|
145
|
+
),
|
|
146
|
+
enable_dedup=pma_config.get("enable_dedup", True),
|
|
147
|
+
enable_rate_limit=pma_config.get("enable_rate_limit", True),
|
|
148
|
+
enable_circuit_breaker=pma_config.get("enable_circuit_breaker", True),
|
|
149
|
+
)
|
|
150
|
+
pma_audit_log = PmaAuditLog(hub_root)
|
|
151
|
+
pma_safety_checker = PmaSafetyChecker(hub_root, config=safety_config)
|
|
152
|
+
pma_safety_root = hub_root
|
|
153
|
+
return pma_safety_checker
|
|
154
|
+
|
|
155
|
+
def _get_pma_queue(request: Request) -> PmaQueue:
|
|
156
|
+
nonlocal pma_queue, pma_queue_root
|
|
157
|
+
hub_root = request.app.state.config.root
|
|
158
|
+
if pma_queue is None or pma_queue_root != hub_root:
|
|
159
|
+
pma_queue = PmaQueue(hub_root)
|
|
160
|
+
pma_queue_root = hub_root
|
|
161
|
+
return pma_queue
|
|
162
|
+
|
|
163
|
+
async def _persist_state(store: Optional[PmaStateStore]) -> None:
|
|
164
|
+
if store is None:
|
|
165
|
+
return
|
|
166
|
+
async with pma_lock:
|
|
167
|
+
state = {
|
|
168
|
+
"version": 1,
|
|
169
|
+
"active": bool(pma_active),
|
|
170
|
+
"current": dict(pma_current or {}),
|
|
171
|
+
"last_result": dict(pma_last_result or {}),
|
|
172
|
+
"updated_at": now_iso(),
|
|
173
|
+
}
|
|
174
|
+
try:
|
|
175
|
+
store.save(state)
|
|
176
|
+
except Exception:
|
|
177
|
+
logger.exception("Failed to persist PMA state")
|
|
178
|
+
|
|
179
|
+
def _truncate_text(value: Any, limit: int) -> str:
|
|
180
|
+
if not isinstance(value, str):
|
|
181
|
+
value = "" if value is None else str(value)
|
|
182
|
+
if len(value) <= limit:
|
|
183
|
+
return value
|
|
184
|
+
return value[: max(0, limit - 3)] + "..."
|
|
185
|
+
|
|
186
|
+
def _format_last_result(
|
|
187
|
+
result: dict[str, Any], current: dict[str, Any]
|
|
188
|
+
) -> dict[str, Any]:
|
|
189
|
+
status = result.get("status") or "error"
|
|
190
|
+
message = result.get("message")
|
|
191
|
+
detail = result.get("detail")
|
|
192
|
+
text = message if isinstance(message, str) and message else detail
|
|
193
|
+
summary = _truncate_text(text or "", PMA_MAX_TEXT)
|
|
194
|
+
return {
|
|
195
|
+
"status": status,
|
|
196
|
+
"message": summary,
|
|
197
|
+
"detail": (
|
|
198
|
+
_truncate_text(detail or "", PMA_MAX_TEXT)
|
|
199
|
+
if isinstance(detail, str)
|
|
200
|
+
else None
|
|
201
|
+
),
|
|
202
|
+
"client_turn_id": result.get("client_turn_id") or "",
|
|
203
|
+
"agent": current.get("agent"),
|
|
204
|
+
"thread_id": result.get("thread_id") or current.get("thread_id"),
|
|
205
|
+
"turn_id": result.get("turn_id") or current.get("turn_id"),
|
|
206
|
+
"started_at": current.get("started_at"),
|
|
207
|
+
"finished_at": now_iso(),
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async def _get_interrupt_event() -> asyncio.Event:
|
|
211
|
+
nonlocal pma_event
|
|
212
|
+
async with pma_lock:
|
|
213
|
+
if pma_event is None or pma_event.is_set():
|
|
214
|
+
pma_event = asyncio.Event()
|
|
215
|
+
return pma_event
|
|
216
|
+
|
|
217
|
+
async def _set_active(
|
|
218
|
+
active: bool, *, store: Optional[PmaStateStore] = None
|
|
219
|
+
) -> None:
|
|
220
|
+
nonlocal pma_active
|
|
221
|
+
async with pma_lock:
|
|
222
|
+
pma_active = active
|
|
223
|
+
await _persist_state(store)
|
|
224
|
+
|
|
225
|
+
async def _begin_turn(
|
|
226
|
+
client_turn_id: Optional[str],
|
|
227
|
+
*,
|
|
228
|
+
store: Optional[PmaStateStore] = None,
|
|
229
|
+
lane_id: Optional[str] = None,
|
|
230
|
+
) -> bool:
|
|
231
|
+
nonlocal pma_active, pma_current
|
|
232
|
+
async with pma_lock:
|
|
233
|
+
if pma_active:
|
|
234
|
+
return False
|
|
235
|
+
pma_active = True
|
|
236
|
+
pma_current = {
|
|
237
|
+
"client_turn_id": client_turn_id or "",
|
|
238
|
+
"status": "starting",
|
|
239
|
+
"agent": None,
|
|
240
|
+
"thread_id": None,
|
|
241
|
+
"turn_id": None,
|
|
242
|
+
"lane_id": lane_id or "",
|
|
243
|
+
"started_at": now_iso(),
|
|
244
|
+
}
|
|
245
|
+
await _persist_state(store)
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
async def _clear_interrupt_event() -> None:
|
|
249
|
+
nonlocal pma_event
|
|
250
|
+
async with pma_lock:
|
|
251
|
+
pma_event = None
|
|
252
|
+
|
|
253
|
+
async def _update_current(
|
|
254
|
+
*, store: Optional[PmaStateStore] = None, **updates: Any
|
|
255
|
+
) -> None:
|
|
256
|
+
nonlocal pma_current
|
|
257
|
+
async with pma_lock:
|
|
258
|
+
if pma_current is None:
|
|
259
|
+
pma_current = {}
|
|
260
|
+
pma_current.update(updates)
|
|
261
|
+
await _persist_state(store)
|
|
262
|
+
|
|
263
|
+
async def _finalize_result(
|
|
264
|
+
result: dict[str, Any],
|
|
265
|
+
*,
|
|
266
|
+
request: Request,
|
|
267
|
+
store: Optional[PmaStateStore] = None,
|
|
268
|
+
) -> None:
|
|
269
|
+
nonlocal pma_current, pma_last_result, pma_active, pma_event
|
|
270
|
+
async with pma_lock:
|
|
271
|
+
current_snapshot = dict(pma_current or {})
|
|
272
|
+
pma_last_result = _format_last_result(result or {}, current_snapshot)
|
|
273
|
+
pma_current = None
|
|
274
|
+
pma_active = False
|
|
275
|
+
pma_event = None
|
|
276
|
+
|
|
277
|
+
status = result.get("status") or "error"
|
|
278
|
+
started_at = current_snapshot.get("started_at")
|
|
279
|
+
duration_ms = None
|
|
280
|
+
if started_at:
|
|
281
|
+
try:
|
|
282
|
+
start_dt = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
|
283
|
+
duration_ms = int(
|
|
284
|
+
(datetime.now(timezone.utc) - start_dt).total_seconds() * 1000
|
|
285
|
+
)
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
log_event(
|
|
290
|
+
logger,
|
|
291
|
+
logging.INFO,
|
|
292
|
+
"pma.turn.completed",
|
|
293
|
+
status=status,
|
|
294
|
+
duration_ms=duration_ms,
|
|
295
|
+
agent=current_snapshot.get("agent"),
|
|
296
|
+
client_turn_id=current_snapshot.get("client_turn_id"),
|
|
297
|
+
thread_id=pma_last_result.get("thread_id"),
|
|
298
|
+
turn_id=pma_last_result.get("turn_id"),
|
|
299
|
+
error=result.get("detail") if status == "error" else None,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
if status == "ok":
|
|
303
|
+
action_type = PmaActionType.CHAT_COMPLETED
|
|
304
|
+
elif status == "interrupted":
|
|
305
|
+
action_type = PmaActionType.CHAT_INTERRUPTED
|
|
306
|
+
else:
|
|
307
|
+
action_type = PmaActionType.CHAT_FAILED
|
|
308
|
+
|
|
309
|
+
_get_safety_checker(request).record_action(
|
|
310
|
+
action_type=action_type,
|
|
311
|
+
agent=current_snapshot.get("agent"),
|
|
312
|
+
thread_id=pma_last_result.get("thread_id"),
|
|
313
|
+
turn_id=pma_last_result.get("turn_id"),
|
|
314
|
+
client_turn_id=current_snapshot.get("client_turn_id"),
|
|
315
|
+
details={"status": status, "duration_ms": duration_ms},
|
|
316
|
+
status=status,
|
|
317
|
+
error=result.get("detail") if status == "error" else None,
|
|
318
|
+
)
|
|
319
|
+
_get_safety_checker(request).record_chat_result(
|
|
320
|
+
agent=current_snapshot.get("agent") or "",
|
|
321
|
+
status=status,
|
|
322
|
+
error=result.get("detail") if status == "error" else None,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
await _persist_state(store)
|
|
326
|
+
|
|
327
|
+
async def _get_current_snapshot() -> dict[str, Any]:
|
|
328
|
+
async with pma_lock:
|
|
329
|
+
return dict(pma_current or {})
|
|
330
|
+
|
|
331
|
+
async def _interrupt_active(
|
|
332
|
+
request: Request, *, reason: str, source: str = "unknown"
|
|
333
|
+
) -> dict[str, Any]:
|
|
334
|
+
event = await _get_interrupt_event()
|
|
335
|
+
event.set()
|
|
336
|
+
current = await _get_current_snapshot()
|
|
337
|
+
agent_id = (current.get("agent") or "").strip().lower()
|
|
338
|
+
thread_id = current.get("thread_id")
|
|
339
|
+
turn_id = current.get("turn_id")
|
|
340
|
+
client_turn_id = current.get("client_turn_id")
|
|
341
|
+
hub_root = request.app.state.config.root
|
|
342
|
+
|
|
343
|
+
log_event(
|
|
344
|
+
logger,
|
|
345
|
+
logging.INFO,
|
|
346
|
+
"pma.turn.interrupted",
|
|
347
|
+
agent=agent_id or None,
|
|
348
|
+
client_turn_id=client_turn_id or None,
|
|
349
|
+
thread_id=thread_id,
|
|
350
|
+
turn_id=turn_id,
|
|
351
|
+
reason=reason,
|
|
352
|
+
source=source,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if agent_id == "opencode":
|
|
356
|
+
supervisor = getattr(request.app.state, "opencode_supervisor", None)
|
|
357
|
+
if supervisor is not None and thread_id:
|
|
358
|
+
harness = OpenCodeHarness(supervisor)
|
|
359
|
+
await harness.interrupt(hub_root, thread_id, turn_id)
|
|
360
|
+
else:
|
|
361
|
+
supervisor = getattr(request.app.state, "app_server_supervisor", None)
|
|
362
|
+
events = getattr(request.app.state, "app_server_events", None)
|
|
363
|
+
if supervisor is not None and events is not None and thread_id and turn_id:
|
|
364
|
+
harness = CodexHarness(supervisor, events)
|
|
365
|
+
try:
|
|
366
|
+
await harness.interrupt(hub_root, thread_id, turn_id)
|
|
367
|
+
except Exception:
|
|
368
|
+
logger.exception("Failed to interrupt Codex turn")
|
|
369
|
+
return {
|
|
370
|
+
"status": "ok",
|
|
371
|
+
"interrupted": bool(event.is_set()),
|
|
372
|
+
"detail": reason,
|
|
373
|
+
"agent": agent_id or None,
|
|
374
|
+
"thread_id": thread_id,
|
|
375
|
+
"turn_id": turn_id,
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async def _ensure_lane_worker(lane_id: str, request: Request) -> None:
|
|
379
|
+
nonlocal lane_workers, lane_cancel_events
|
|
380
|
+
if lane_id in lane_workers and not lane_workers[lane_id].done():
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
cancel_event = asyncio.Event()
|
|
384
|
+
lane_cancel_events[lane_id] = cancel_event
|
|
385
|
+
|
|
386
|
+
async def lane_worker():
|
|
387
|
+
queue = _get_pma_queue(request)
|
|
388
|
+
await queue.replay_pending(lane_id)
|
|
389
|
+
while not cancel_event.is_set():
|
|
390
|
+
item = await queue.dequeue(lane_id)
|
|
391
|
+
if item is None:
|
|
392
|
+
await queue.wait_for_lane_item(lane_id, cancel_event)
|
|
393
|
+
continue
|
|
394
|
+
|
|
395
|
+
if cancel_event.is_set():
|
|
396
|
+
await queue.fail_item(item, "cancelled by lane stop")
|
|
397
|
+
continue
|
|
398
|
+
|
|
399
|
+
result_future = item_futures.get(item.item_id)
|
|
400
|
+
try:
|
|
401
|
+
result = await _execute_queue_item(item, request)
|
|
402
|
+
await queue.complete_item(item, result)
|
|
403
|
+
if result_future and not result_future.done():
|
|
404
|
+
result_future.set_result(result)
|
|
405
|
+
except Exception as exc:
|
|
406
|
+
logger.exception("Failed to process queue item %s", item.item_id)
|
|
407
|
+
error_result = {"status": "error", "detail": str(exc)}
|
|
408
|
+
await queue.fail_item(item, str(exc))
|
|
409
|
+
if result_future and not result_future.done():
|
|
410
|
+
result_future.set_result(error_result)
|
|
411
|
+
finally:
|
|
412
|
+
item_futures.pop(item.item_id, None)
|
|
413
|
+
|
|
414
|
+
task = asyncio.create_task(lane_worker())
|
|
415
|
+
lane_workers[lane_id] = task
|
|
416
|
+
|
|
417
|
+
async def _execute_queue_item(item: Any, request: Request) -> dict[str, Any]:
|
|
418
|
+
hub_root = request.app.state.config.root
|
|
419
|
+
payload = item.payload
|
|
420
|
+
|
|
421
|
+
client_turn_id = payload.get("client_turn_id")
|
|
422
|
+
message = payload.get("message", "")
|
|
423
|
+
agent = payload.get("agent")
|
|
424
|
+
model = _normalize_optional_text(payload.get("model"))
|
|
425
|
+
reasoning = _normalize_optional_text(payload.get("reasoning"))
|
|
426
|
+
|
|
427
|
+
store = _get_state_store(request)
|
|
428
|
+
agents, available_default = _available_agents(request)
|
|
429
|
+
available_ids = {entry.get("id") for entry in agents if isinstance(entry, dict)}
|
|
430
|
+
defaults = _get_pma_config(request)
|
|
431
|
+
|
|
432
|
+
def _resolve_default_agent() -> str:
|
|
433
|
+
configured_default = defaults.get("default_agent")
|
|
434
|
+
try:
|
|
435
|
+
candidate = validate_agent_id(configured_default or "")
|
|
436
|
+
except ValueError:
|
|
437
|
+
candidate = None
|
|
438
|
+
if candidate and candidate in available_ids:
|
|
439
|
+
return candidate
|
|
440
|
+
return available_default
|
|
441
|
+
|
|
442
|
+
try:
|
|
443
|
+
agent_id = validate_agent_id(agent or "")
|
|
444
|
+
except ValueError:
|
|
445
|
+
agent_id = _resolve_default_agent()
|
|
446
|
+
|
|
447
|
+
safety_checker = _get_safety_checker(request)
|
|
448
|
+
safety_check = safety_checker.check_chat_start(
|
|
449
|
+
agent_id, message, client_turn_id
|
|
450
|
+
)
|
|
451
|
+
if not safety_check.allowed:
|
|
452
|
+
detail = safety_check.reason or "PMA action blocked by safety check"
|
|
453
|
+
if safety_check.details:
|
|
454
|
+
detail = f"{detail}: {safety_check.details}"
|
|
455
|
+
return {"status": "error", "detail": detail}
|
|
456
|
+
|
|
457
|
+
started = await _begin_turn(
|
|
458
|
+
client_turn_id, store=store, lane_id=getattr(item, "lane_id", None)
|
|
459
|
+
)
|
|
460
|
+
if not started:
|
|
461
|
+
logger.warning("PMA turn started while another was active")
|
|
462
|
+
|
|
463
|
+
if not model and defaults.get("model"):
|
|
464
|
+
model = defaults["model"]
|
|
465
|
+
if not reasoning and defaults.get("reasoning"):
|
|
466
|
+
reasoning = defaults["reasoning"]
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
prompt_base = load_pma_prompt(hub_root)
|
|
470
|
+
supervisor = getattr(request.app.state, "hub_supervisor", None)
|
|
471
|
+
snapshot = await build_hub_snapshot(supervisor, hub_root=hub_root)
|
|
472
|
+
prompt = format_pma_prompt(
|
|
473
|
+
prompt_base, snapshot, message, hub_root=hub_root
|
|
474
|
+
)
|
|
475
|
+
except Exception as exc:
|
|
476
|
+
error_result = {
|
|
477
|
+
"status": "error",
|
|
478
|
+
"detail": str(exc),
|
|
479
|
+
"client_turn_id": client_turn_id or "",
|
|
480
|
+
}
|
|
481
|
+
if started:
|
|
482
|
+
await _finalize_result(error_result, request=request, store=store)
|
|
483
|
+
return error_result
|
|
484
|
+
|
|
485
|
+
interrupt_event = await _get_interrupt_event()
|
|
486
|
+
if interrupt_event.is_set():
|
|
487
|
+
result = {"status": "interrupted", "detail": "PMA chat interrupted"}
|
|
488
|
+
if started:
|
|
489
|
+
await _finalize_result(result, request=request, store=store)
|
|
490
|
+
return result
|
|
491
|
+
|
|
492
|
+
meta_future: asyncio.Future[tuple[str, str]] = (
|
|
493
|
+
asyncio.get_running_loop().create_future()
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
async def _meta(thread_id: str, turn_id: str) -> None:
|
|
497
|
+
await _update_current(
|
|
498
|
+
store=store,
|
|
499
|
+
client_turn_id=client_turn_id or "",
|
|
500
|
+
status="running",
|
|
501
|
+
agent=agent_id,
|
|
502
|
+
thread_id=thread_id,
|
|
503
|
+
turn_id=turn_id,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
safety_checker.record_action(
|
|
507
|
+
action_type=PmaActionType.CHAT_STARTED,
|
|
508
|
+
agent=agent_id,
|
|
509
|
+
thread_id=thread_id,
|
|
510
|
+
turn_id=turn_id,
|
|
511
|
+
client_turn_id=client_turn_id,
|
|
512
|
+
details={"message": message[:200]},
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
log_event(
|
|
516
|
+
logger,
|
|
517
|
+
logging.INFO,
|
|
518
|
+
"pma.turn.started",
|
|
519
|
+
agent=agent_id,
|
|
520
|
+
client_turn_id=client_turn_id or None,
|
|
521
|
+
thread_id=thread_id,
|
|
522
|
+
turn_id=turn_id,
|
|
523
|
+
)
|
|
524
|
+
if not meta_future.done():
|
|
525
|
+
meta_future.set_result((thread_id, turn_id))
|
|
526
|
+
|
|
527
|
+
supervisor = getattr(request.app.state, "app_server_supervisor", None)
|
|
528
|
+
events = getattr(request.app.state, "app_server_events", None)
|
|
529
|
+
opencode = getattr(request.app.state, "opencode_supervisor", None)
|
|
530
|
+
registry = getattr(request.app.state, "app_server_threads", None)
|
|
531
|
+
stall_timeout_seconds = None
|
|
532
|
+
try:
|
|
533
|
+
stall_timeout_seconds = (
|
|
534
|
+
request.app.state.config.opencode.session_stall_timeout_seconds
|
|
535
|
+
)
|
|
536
|
+
except Exception:
|
|
537
|
+
stall_timeout_seconds = None
|
|
538
|
+
|
|
539
|
+
try:
|
|
540
|
+
if agent_id == "opencode":
|
|
541
|
+
if opencode is None:
|
|
542
|
+
result = {"status": "error", "detail": "OpenCode unavailable"}
|
|
543
|
+
if started:
|
|
544
|
+
await _finalize_result(result, request=request, store=store)
|
|
545
|
+
return result
|
|
546
|
+
result = await _execute_opencode(
|
|
547
|
+
opencode,
|
|
548
|
+
hub_root,
|
|
549
|
+
prompt,
|
|
550
|
+
interrupt_event,
|
|
551
|
+
model=model,
|
|
552
|
+
reasoning=reasoning,
|
|
553
|
+
thread_registry=registry,
|
|
554
|
+
thread_key=PMA_OPENCODE_KEY,
|
|
555
|
+
stall_timeout_seconds=stall_timeout_seconds,
|
|
556
|
+
on_meta=_meta,
|
|
557
|
+
)
|
|
558
|
+
else:
|
|
559
|
+
if supervisor is None or events is None:
|
|
560
|
+
result = {"status": "error", "detail": "App-server unavailable"}
|
|
561
|
+
if started:
|
|
562
|
+
await _finalize_result(result, request=request, store=store)
|
|
563
|
+
return result
|
|
564
|
+
result = await _execute_app_server(
|
|
565
|
+
supervisor,
|
|
566
|
+
events,
|
|
567
|
+
hub_root,
|
|
568
|
+
prompt,
|
|
569
|
+
interrupt_event,
|
|
570
|
+
model=model,
|
|
571
|
+
reasoning=reasoning,
|
|
572
|
+
thread_registry=registry,
|
|
573
|
+
thread_key=PMA_KEY,
|
|
574
|
+
on_meta=_meta,
|
|
575
|
+
)
|
|
576
|
+
except Exception as exc:
|
|
577
|
+
if started:
|
|
578
|
+
error_result = {
|
|
579
|
+
"status": "error",
|
|
580
|
+
"detail": str(exc),
|
|
581
|
+
"client_turn_id": client_turn_id or "",
|
|
582
|
+
}
|
|
583
|
+
await _finalize_result(error_result, request=request, store=store)
|
|
584
|
+
raise
|
|
585
|
+
|
|
586
|
+
result = dict(result or {})
|
|
587
|
+
result["client_turn_id"] = client_turn_id or ""
|
|
588
|
+
await _finalize_result(result, request=request, store=store)
|
|
589
|
+
return result
|
|
590
|
+
|
|
591
|
+
@router.get("/active")
|
|
592
|
+
async def pma_active_status(
|
|
593
|
+
request: Request, client_turn_id: Optional[str] = None
|
|
594
|
+
) -> dict[str, Any]:
|
|
595
|
+
pma_config = _get_pma_config(request)
|
|
596
|
+
if not pma_config.get("enabled", True):
|
|
597
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
598
|
+
async with pma_lock:
|
|
599
|
+
current = dict(pma_current or {})
|
|
600
|
+
last_result = dict(pma_last_result or {})
|
|
601
|
+
active = bool(pma_active)
|
|
602
|
+
store = _get_state_store(request)
|
|
603
|
+
disk_state = store.load(ensure_exists=True)
|
|
604
|
+
if isinstance(disk_state, dict):
|
|
605
|
+
disk_current = (
|
|
606
|
+
disk_state.get("current")
|
|
607
|
+
if isinstance(disk_state.get("current"), dict)
|
|
608
|
+
else {}
|
|
609
|
+
)
|
|
610
|
+
disk_last = (
|
|
611
|
+
disk_state.get("last_result")
|
|
612
|
+
if isinstance(disk_state.get("last_result"), dict)
|
|
613
|
+
else {}
|
|
614
|
+
)
|
|
615
|
+
if not current and disk_current:
|
|
616
|
+
current = dict(disk_current)
|
|
617
|
+
if not last_result and disk_last:
|
|
618
|
+
last_result = dict(disk_last)
|
|
619
|
+
if not active and disk_state.get("active"):
|
|
620
|
+
active = True
|
|
621
|
+
if client_turn_id:
|
|
622
|
+
# If caller is asking about a specific client turn id, only return the matching last result.
|
|
623
|
+
if last_result.get("client_turn_id") != client_turn_id:
|
|
624
|
+
last_result = {}
|
|
625
|
+
if current.get("client_turn_id") != client_turn_id:
|
|
626
|
+
current = {}
|
|
627
|
+
return {"active": active, "current": current, "last_result": last_result}
|
|
628
|
+
|
|
629
|
+
@router.get("/agents")
|
|
630
|
+
def list_pma_agents(request: Request) -> dict[str, Any]:
|
|
631
|
+
pma_config = _get_pma_config(request)
|
|
632
|
+
if not pma_config.get("enabled", True):
|
|
633
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
634
|
+
if (
|
|
635
|
+
getattr(request.app.state, "app_server_supervisor", None) is None
|
|
636
|
+
and getattr(request.app.state, "opencode_supervisor", None) is None
|
|
637
|
+
):
|
|
638
|
+
raise HTTPException(status_code=404, detail="PMA unavailable")
|
|
639
|
+
agents, default_agent = _available_agents(request)
|
|
640
|
+
defaults = _get_pma_config(request)
|
|
641
|
+
payload: dict[str, Any] = {"agents": agents, "default": default_agent}
|
|
642
|
+
if defaults.get("model") or defaults.get("reasoning"):
|
|
643
|
+
payload["defaults"] = {
|
|
644
|
+
key: value
|
|
645
|
+
for key, value in {
|
|
646
|
+
"model": defaults.get("model"),
|
|
647
|
+
"reasoning": defaults.get("reasoning"),
|
|
648
|
+
}.items()
|
|
649
|
+
if value
|
|
650
|
+
}
|
|
651
|
+
return payload
|
|
652
|
+
|
|
653
|
+
@router.get("/audit/recent")
|
|
654
|
+
def get_pma_audit_log(request: Request, limit: int = 100):
|
|
655
|
+
pma_config = _get_pma_config(request)
|
|
656
|
+
if not pma_config.get("enabled", True):
|
|
657
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
658
|
+
safety_checker = _get_safety_checker(request)
|
|
659
|
+
entries = safety_checker._audit_log.list_recent(limit=limit)
|
|
660
|
+
return {
|
|
661
|
+
"entries": [
|
|
662
|
+
{
|
|
663
|
+
"entry_id": e.entry_id,
|
|
664
|
+
"action_type": e.action_type.value,
|
|
665
|
+
"timestamp": e.timestamp,
|
|
666
|
+
"agent": e.agent,
|
|
667
|
+
"thread_id": e.thread_id,
|
|
668
|
+
"turn_id": e.turn_id,
|
|
669
|
+
"client_turn_id": e.client_turn_id,
|
|
670
|
+
"details": e.details,
|
|
671
|
+
"status": e.status,
|
|
672
|
+
"error": e.error,
|
|
673
|
+
"fingerprint": e.fingerprint,
|
|
674
|
+
}
|
|
675
|
+
for e in entries
|
|
676
|
+
]
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
@router.get("/safety/stats")
|
|
680
|
+
def get_pma_safety_stats(request: Request):
|
|
681
|
+
pma_config = _get_pma_config(request)
|
|
682
|
+
if not pma_config.get("enabled", True):
|
|
683
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
684
|
+
safety_checker = _get_safety_checker(request)
|
|
685
|
+
return safety_checker.get_stats()
|
|
686
|
+
|
|
687
|
+
@router.get("/agents/{agent}/models")
|
|
688
|
+
async def list_pma_agent_models(agent: str, request: Request):
|
|
689
|
+
pma_config = _get_pma_config(request)
|
|
690
|
+
if not pma_config.get("enabled", True):
|
|
691
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
692
|
+
agent_id = (agent or "").strip().lower()
|
|
693
|
+
hub_root = request.app.state.config.root
|
|
694
|
+
if agent_id == "codex":
|
|
695
|
+
supervisor = request.app.state.app_server_supervisor
|
|
696
|
+
events = request.app.state.app_server_events
|
|
697
|
+
if supervisor is None:
|
|
698
|
+
raise HTTPException(status_code=404, detail="Codex harness unavailable")
|
|
699
|
+
codex_harness = CodexHarness(supervisor, events)
|
|
700
|
+
catalog = await codex_harness.model_catalog(hub_root)
|
|
701
|
+
return _serialize_model_catalog(catalog)
|
|
702
|
+
if agent_id == "opencode":
|
|
703
|
+
supervisor = getattr(request.app.state, "opencode_supervisor", None)
|
|
704
|
+
if supervisor is None:
|
|
705
|
+
raise HTTPException(
|
|
706
|
+
status_code=404, detail="OpenCode harness unavailable"
|
|
707
|
+
)
|
|
708
|
+
try:
|
|
709
|
+
opencode_harness = OpenCodeHarness(supervisor)
|
|
710
|
+
catalog = await opencode_harness.model_catalog(hub_root)
|
|
711
|
+
return _serialize_model_catalog(catalog)
|
|
712
|
+
except OpenCodeSupervisorError as exc:
|
|
713
|
+
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
714
|
+
except Exception as exc:
|
|
715
|
+
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
716
|
+
raise HTTPException(status_code=404, detail="Unknown agent")
|
|
717
|
+
|
|
718
|
+
async def _execute_app_server(
|
|
719
|
+
supervisor: Any,
|
|
720
|
+
events: Any,
|
|
721
|
+
hub_root: Path,
|
|
722
|
+
prompt: str,
|
|
723
|
+
interrupt_event: asyncio.Event,
|
|
724
|
+
*,
|
|
725
|
+
model: Optional[str] = None,
|
|
726
|
+
reasoning: Optional[str] = None,
|
|
727
|
+
thread_registry: Optional[Any] = None,
|
|
728
|
+
thread_key: Optional[str] = None,
|
|
729
|
+
on_meta: Optional[Any] = None,
|
|
730
|
+
) -> dict[str, Any]:
|
|
731
|
+
client = await supervisor.get_client(hub_root)
|
|
732
|
+
|
|
733
|
+
thread_id = None
|
|
734
|
+
if thread_registry is not None and thread_key:
|
|
735
|
+
thread_id = thread_registry.get_thread_id(thread_key)
|
|
736
|
+
if thread_id:
|
|
737
|
+
try:
|
|
738
|
+
await client.thread_resume(thread_id)
|
|
739
|
+
except Exception:
|
|
740
|
+
thread_id = None
|
|
741
|
+
|
|
742
|
+
if not thread_id:
|
|
743
|
+
thread = await client.thread_start(str(hub_root))
|
|
744
|
+
thread_id = thread.get("id")
|
|
745
|
+
if not isinstance(thread_id, str) or not thread_id:
|
|
746
|
+
raise HTTPException(
|
|
747
|
+
status_code=502, detail="App-server did not return a thread id"
|
|
748
|
+
)
|
|
749
|
+
if thread_registry is not None and thread_key:
|
|
750
|
+
thread_registry.set_thread_id(thread_key, thread_id)
|
|
751
|
+
|
|
752
|
+
turn_kwargs: dict[str, Any] = {}
|
|
753
|
+
if model:
|
|
754
|
+
turn_kwargs["model"] = model
|
|
755
|
+
if reasoning:
|
|
756
|
+
turn_kwargs["effort"] = reasoning
|
|
757
|
+
|
|
758
|
+
handle = await client.turn_start(
|
|
759
|
+
thread_id,
|
|
760
|
+
prompt,
|
|
761
|
+
approval_policy="on-request",
|
|
762
|
+
sandbox_policy="dangerFullAccess",
|
|
763
|
+
**turn_kwargs,
|
|
764
|
+
)
|
|
765
|
+
codex_harness = CodexHarness(supervisor, events)
|
|
766
|
+
if on_meta is not None:
|
|
767
|
+
try:
|
|
768
|
+
maybe = on_meta(thread_id, handle.turn_id)
|
|
769
|
+
if asyncio.iscoroutine(maybe):
|
|
770
|
+
await maybe
|
|
771
|
+
except Exception:
|
|
772
|
+
logger.exception("pma meta callback failed")
|
|
773
|
+
|
|
774
|
+
if interrupt_event.is_set():
|
|
775
|
+
try:
|
|
776
|
+
await codex_harness.interrupt(hub_root, thread_id, handle.turn_id)
|
|
777
|
+
except Exception:
|
|
778
|
+
logger.exception("Failed to interrupt Codex turn")
|
|
779
|
+
return {"status": "interrupted", "detail": "PMA chat interrupted"}
|
|
780
|
+
|
|
781
|
+
turn_task = asyncio.create_task(handle.wait(timeout=None))
|
|
782
|
+
timeout_task = asyncio.create_task(asyncio.sleep(PMA_TIMEOUT_SECONDS))
|
|
783
|
+
interrupt_task = asyncio.create_task(interrupt_event.wait())
|
|
784
|
+
try:
|
|
785
|
+
done, _ = await asyncio.wait(
|
|
786
|
+
{turn_task, timeout_task, interrupt_task},
|
|
787
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
788
|
+
)
|
|
789
|
+
if timeout_task in done:
|
|
790
|
+
try:
|
|
791
|
+
await codex_harness.interrupt(hub_root, thread_id, handle.turn_id)
|
|
792
|
+
except Exception:
|
|
793
|
+
logger.exception("Failed to interrupt Codex turn")
|
|
794
|
+
turn_task.cancel()
|
|
795
|
+
return {"status": "error", "detail": "PMA chat timed out"}
|
|
796
|
+
if interrupt_task in done:
|
|
797
|
+
try:
|
|
798
|
+
await codex_harness.interrupt(hub_root, thread_id, handle.turn_id)
|
|
799
|
+
except Exception:
|
|
800
|
+
logger.exception("Failed to interrupt Codex turn")
|
|
801
|
+
turn_task.cancel()
|
|
802
|
+
return {"status": "interrupted", "detail": "PMA chat interrupted"}
|
|
803
|
+
turn_result = await turn_task
|
|
804
|
+
finally:
|
|
805
|
+
timeout_task.cancel()
|
|
806
|
+
interrupt_task.cancel()
|
|
807
|
+
|
|
808
|
+
if getattr(turn_result, "errors", None):
|
|
809
|
+
errors = turn_result.errors
|
|
810
|
+
raise HTTPException(status_code=502, detail=errors[-1] if errors else "")
|
|
811
|
+
|
|
812
|
+
output = "\n".join(getattr(turn_result, "agent_messages", []) or []).strip()
|
|
813
|
+
raw_events = getattr(turn_result, "raw_events", []) or []
|
|
814
|
+
return {
|
|
815
|
+
"status": "ok",
|
|
816
|
+
"message": output,
|
|
817
|
+
"thread_id": thread_id,
|
|
818
|
+
"turn_id": handle.turn_id,
|
|
819
|
+
"raw_events": raw_events,
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async def _execute_opencode(
|
|
823
|
+
supervisor: Any,
|
|
824
|
+
hub_root: Path,
|
|
825
|
+
prompt: str,
|
|
826
|
+
interrupt_event: asyncio.Event,
|
|
827
|
+
*,
|
|
828
|
+
model: Optional[str] = None,
|
|
829
|
+
reasoning: Optional[str] = None,
|
|
830
|
+
thread_registry: Optional[Any] = None,
|
|
831
|
+
thread_key: Optional[str] = None,
|
|
832
|
+
stall_timeout_seconds: Optional[float] = None,
|
|
833
|
+
on_meta: Optional[Any] = None,
|
|
834
|
+
part_handler: Optional[Any] = None,
|
|
835
|
+
) -> dict[str, Any]:
|
|
836
|
+
from ....agents.opencode.runtime import (
|
|
837
|
+
PERMISSION_ALLOW,
|
|
838
|
+
build_turn_id,
|
|
839
|
+
collect_opencode_output,
|
|
840
|
+
extract_session_id,
|
|
841
|
+
parse_message_response,
|
|
842
|
+
split_model_id,
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
client = await supervisor.get_client(hub_root)
|
|
846
|
+
session_id = None
|
|
847
|
+
if thread_registry is not None and thread_key:
|
|
848
|
+
session_id = thread_registry.get_thread_id(thread_key)
|
|
849
|
+
if not session_id:
|
|
850
|
+
session = await client.create_session(directory=str(hub_root))
|
|
851
|
+
session_id = extract_session_id(session, allow_fallback_id=True)
|
|
852
|
+
if not isinstance(session_id, str) or not session_id:
|
|
853
|
+
raise HTTPException(
|
|
854
|
+
status_code=502, detail="OpenCode did not return a session id"
|
|
855
|
+
)
|
|
856
|
+
if thread_registry is not None and thread_key:
|
|
857
|
+
thread_registry.set_thread_id(thread_key, session_id)
|
|
858
|
+
if on_meta is not None:
|
|
859
|
+
try:
|
|
860
|
+
maybe = on_meta(session_id, build_turn_id(session_id))
|
|
861
|
+
if asyncio.iscoroutine(maybe):
|
|
862
|
+
await maybe
|
|
863
|
+
except Exception:
|
|
864
|
+
logger.exception("pma meta callback failed")
|
|
865
|
+
|
|
866
|
+
opencode_harness = OpenCodeHarness(supervisor)
|
|
867
|
+
if interrupt_event.is_set():
|
|
868
|
+
await opencode_harness.interrupt(hub_root, session_id, None)
|
|
869
|
+
return {"status": "interrupted", "detail": "PMA chat interrupted"}
|
|
870
|
+
|
|
871
|
+
model_payload = split_model_id(model)
|
|
872
|
+
await supervisor.mark_turn_started(hub_root)
|
|
873
|
+
|
|
874
|
+
ready_event = asyncio.Event()
|
|
875
|
+
output_task = asyncio.create_task(
|
|
876
|
+
collect_opencode_output(
|
|
877
|
+
client,
|
|
878
|
+
session_id=session_id,
|
|
879
|
+
workspace_path=str(hub_root),
|
|
880
|
+
model_payload=model_payload,
|
|
881
|
+
permission_policy=PERMISSION_ALLOW,
|
|
882
|
+
question_policy="auto_first_option",
|
|
883
|
+
should_stop=interrupt_event.is_set,
|
|
884
|
+
ready_event=ready_event,
|
|
885
|
+
part_handler=part_handler,
|
|
886
|
+
stall_timeout_seconds=stall_timeout_seconds,
|
|
887
|
+
)
|
|
888
|
+
)
|
|
889
|
+
try:
|
|
890
|
+
await asyncio.wait_for(ready_event.wait(), timeout=2.0)
|
|
891
|
+
except asyncio.TimeoutError:
|
|
892
|
+
pass
|
|
893
|
+
|
|
894
|
+
prompt_task = asyncio.create_task(
|
|
895
|
+
client.prompt_async(
|
|
896
|
+
session_id,
|
|
897
|
+
message=prompt,
|
|
898
|
+
model=model_payload,
|
|
899
|
+
variant=reasoning,
|
|
900
|
+
)
|
|
901
|
+
)
|
|
902
|
+
timeout_task = asyncio.create_task(asyncio.sleep(PMA_TIMEOUT_SECONDS))
|
|
903
|
+
interrupt_task = asyncio.create_task(interrupt_event.wait())
|
|
904
|
+
try:
|
|
905
|
+
prompt_response = None
|
|
906
|
+
try:
|
|
907
|
+
prompt_response = await prompt_task
|
|
908
|
+
except Exception as exc:
|
|
909
|
+
interrupt_event.set()
|
|
910
|
+
output_task.cancel()
|
|
911
|
+
await opencode_harness.interrupt(hub_root, session_id, None)
|
|
912
|
+
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
913
|
+
|
|
914
|
+
done, _ = await asyncio.wait(
|
|
915
|
+
{output_task, timeout_task, interrupt_task},
|
|
916
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
917
|
+
)
|
|
918
|
+
if timeout_task in done:
|
|
919
|
+
output_task.cancel()
|
|
920
|
+
await opencode_harness.interrupt(hub_root, session_id, None)
|
|
921
|
+
return {"status": "error", "detail": "PMA chat timed out"}
|
|
922
|
+
if interrupt_task in done:
|
|
923
|
+
output_task.cancel()
|
|
924
|
+
await opencode_harness.interrupt(hub_root, session_id, None)
|
|
925
|
+
return {"status": "interrupted", "detail": "PMA chat interrupted"}
|
|
926
|
+
output_result = await output_task
|
|
927
|
+
if (not output_result.text) and prompt_response is not None:
|
|
928
|
+
fallback = parse_message_response(prompt_response)
|
|
929
|
+
if fallback.text:
|
|
930
|
+
output_result = type(output_result)(
|
|
931
|
+
text=fallback.text, error=fallback.error
|
|
932
|
+
)
|
|
933
|
+
finally:
|
|
934
|
+
timeout_task.cancel()
|
|
935
|
+
interrupt_task.cancel()
|
|
936
|
+
await supervisor.mark_turn_finished(hub_root)
|
|
937
|
+
|
|
938
|
+
if output_result.error:
|
|
939
|
+
raise HTTPException(status_code=502, detail=output_result.error)
|
|
940
|
+
return {
|
|
941
|
+
"status": "ok",
|
|
942
|
+
"message": output_result.text,
|
|
943
|
+
"thread_id": session_id,
|
|
944
|
+
"turn_id": build_turn_id(session_id),
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
@router.post("/chat")
|
|
948
|
+
async def pma_chat(request: Request):
|
|
949
|
+
pma_config = _get_pma_config(request)
|
|
950
|
+
if not pma_config.get("enabled", True):
|
|
951
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
952
|
+
body = await request.json()
|
|
953
|
+
message = (body.get("message") or "").strip()
|
|
954
|
+
stream = bool(body.get("stream", False))
|
|
955
|
+
agent = _normalize_optional_text(body.get("agent"))
|
|
956
|
+
model = _normalize_optional_text(body.get("model"))
|
|
957
|
+
reasoning = _normalize_optional_text(body.get("reasoning"))
|
|
958
|
+
client_turn_id = (body.get("client_turn_id") or "").strip() or None
|
|
959
|
+
|
|
960
|
+
if not message:
|
|
961
|
+
raise HTTPException(status_code=400, detail="message is required")
|
|
962
|
+
max_text_chars = int(pma_config.get("max_text_chars", 0) or 0)
|
|
963
|
+
if max_text_chars > 0 and len(message) > max_text_chars:
|
|
964
|
+
raise HTTPException(
|
|
965
|
+
status_code=400,
|
|
966
|
+
detail=(
|
|
967
|
+
"message exceeds max_text_chars " f"({max_text_chars} characters)"
|
|
968
|
+
),
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
hub_root = request.app.state.config.root
|
|
972
|
+
queue = _get_pma_queue(request)
|
|
973
|
+
|
|
974
|
+
lane_id = "pma:default"
|
|
975
|
+
idempotency_key = _build_idempotency_key(
|
|
976
|
+
lane_id=lane_id,
|
|
977
|
+
agent=agent,
|
|
978
|
+
model=model,
|
|
979
|
+
reasoning=reasoning,
|
|
980
|
+
client_turn_id=client_turn_id,
|
|
981
|
+
message=message,
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
payload = {
|
|
985
|
+
"message": message,
|
|
986
|
+
"agent": agent,
|
|
987
|
+
"model": model,
|
|
988
|
+
"reasoning": reasoning,
|
|
989
|
+
"client_turn_id": client_turn_id,
|
|
990
|
+
"stream": stream,
|
|
991
|
+
"hub_root": str(hub_root),
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
item, dupe_reason = await queue.enqueue(lane_id, idempotency_key, payload)
|
|
995
|
+
if dupe_reason:
|
|
996
|
+
logger.info("Duplicate PMA turn: %s", dupe_reason)
|
|
997
|
+
|
|
998
|
+
if item.state == QueueItemState.DEDUPED:
|
|
999
|
+
return {
|
|
1000
|
+
"status": "ok",
|
|
1001
|
+
"message": "Duplicate request - already processing",
|
|
1002
|
+
"deduped": True,
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
result_future = asyncio.get_running_loop().create_future()
|
|
1006
|
+
item_futures[item.item_id] = result_future
|
|
1007
|
+
|
|
1008
|
+
await _ensure_lane_worker(lane_id, request)
|
|
1009
|
+
|
|
1010
|
+
try:
|
|
1011
|
+
result = await asyncio.wait_for(result_future, timeout=PMA_TIMEOUT_SECONDS)
|
|
1012
|
+
except asyncio.TimeoutError:
|
|
1013
|
+
return {"status": "error", "detail": "PMA chat timed out"}
|
|
1014
|
+
except Exception:
|
|
1015
|
+
logger.exception("PMA chat error")
|
|
1016
|
+
return {
|
|
1017
|
+
"status": "error",
|
|
1018
|
+
"detail": "An error occurred processing your request",
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return result
|
|
1022
|
+
|
|
1023
|
+
@router.post("/interrupt")
|
|
1024
|
+
async def pma_interrupt(request: Request) -> dict[str, Any]:
|
|
1025
|
+
pma_config = _get_pma_config(request)
|
|
1026
|
+
if not pma_config.get("enabled", True):
|
|
1027
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1028
|
+
return await _interrupt_active(
|
|
1029
|
+
request, reason="PMA chat interrupted", source="user_request"
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
@router.post("/stop")
|
|
1033
|
+
async def pma_stop(request: Request) -> dict[str, Any]:
|
|
1034
|
+
pma_config = _get_pma_config(request)
|
|
1035
|
+
if not pma_config.get("enabled", True):
|
|
1036
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1037
|
+
|
|
1038
|
+
body = await request.json() if request.headers.get("content-type") else {}
|
|
1039
|
+
lane_id = (body.get("lane_id") or "pma:default").strip()
|
|
1040
|
+
hub_root = request.app.state.config.root
|
|
1041
|
+
lifecycle_router = PmaLifecycleRouter(hub_root)
|
|
1042
|
+
|
|
1043
|
+
result = await lifecycle_router.stop(lane_id=lane_id)
|
|
1044
|
+
|
|
1045
|
+
if result.status != "ok":
|
|
1046
|
+
raise HTTPException(status_code=500, detail=result.error)
|
|
1047
|
+
|
|
1048
|
+
if lane_id in lane_cancel_events:
|
|
1049
|
+
lane_cancel_events[lane_id].set()
|
|
1050
|
+
|
|
1051
|
+
await _interrupt_active(request, reason="Lane stopped", source="user_request")
|
|
1052
|
+
|
|
1053
|
+
return {
|
|
1054
|
+
"status": result.status,
|
|
1055
|
+
"message": result.message,
|
|
1056
|
+
"artifact_path": (
|
|
1057
|
+
str(result.artifact_path) if result.artifact_path else None
|
|
1058
|
+
),
|
|
1059
|
+
"details": result.details,
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
@router.post("/new")
|
|
1063
|
+
async def new_pma_session(request: Request) -> dict[str, Any]:
|
|
1064
|
+
pma_config = _get_pma_config(request)
|
|
1065
|
+
if not pma_config.get("enabled", True):
|
|
1066
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1067
|
+
|
|
1068
|
+
body = await request.json()
|
|
1069
|
+
agent = _normalize_optional_text(body.get("agent"))
|
|
1070
|
+
lane_id = (body.get("lane_id") or "pma:default").strip()
|
|
1071
|
+
|
|
1072
|
+
hub_root = request.app.state.config.root
|
|
1073
|
+
lifecycle_router = PmaLifecycleRouter(hub_root)
|
|
1074
|
+
|
|
1075
|
+
result = await lifecycle_router.new(agent=agent, lane_id=lane_id)
|
|
1076
|
+
|
|
1077
|
+
if result.status != "ok":
|
|
1078
|
+
raise HTTPException(status_code=500, detail=result.error)
|
|
1079
|
+
|
|
1080
|
+
return {
|
|
1081
|
+
"status": result.status,
|
|
1082
|
+
"message": result.message,
|
|
1083
|
+
"artifact_path": (
|
|
1084
|
+
str(result.artifact_path) if result.artifact_path else None
|
|
1085
|
+
),
|
|
1086
|
+
"details": result.details,
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
@router.post("/reset")
|
|
1090
|
+
async def reset_pma_session(request: Request) -> dict[str, Any]:
|
|
1091
|
+
pma_config = _get_pma_config(request)
|
|
1092
|
+
if not pma_config.get("enabled", True):
|
|
1093
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1094
|
+
|
|
1095
|
+
body = await request.json() if request.headers.get("content-type") else {}
|
|
1096
|
+
raw_agent = (body.get("agent") or "").strip().lower()
|
|
1097
|
+
agent = raw_agent or None
|
|
1098
|
+
|
|
1099
|
+
hub_root = request.app.state.config.root
|
|
1100
|
+
lifecycle_router = PmaLifecycleRouter(hub_root)
|
|
1101
|
+
|
|
1102
|
+
result = await lifecycle_router.reset(agent=agent)
|
|
1103
|
+
|
|
1104
|
+
if result.status != "ok":
|
|
1105
|
+
raise HTTPException(status_code=500, detail=result.error)
|
|
1106
|
+
|
|
1107
|
+
return {
|
|
1108
|
+
"status": result.status,
|
|
1109
|
+
"message": result.message,
|
|
1110
|
+
"artifact_path": (
|
|
1111
|
+
str(result.artifact_path) if result.artifact_path else None
|
|
1112
|
+
),
|
|
1113
|
+
"details": result.details,
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
@router.post("/compact")
|
|
1117
|
+
async def compact_pma_history(request: Request) -> dict[str, Any]:
|
|
1118
|
+
pma_config = _get_pma_config(request)
|
|
1119
|
+
if not pma_config.get("enabled", True):
|
|
1120
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1121
|
+
|
|
1122
|
+
body = await request.json()
|
|
1123
|
+
summary = (body.get("summary") or "").strip()
|
|
1124
|
+
agent = _normalize_optional_text(body.get("agent"))
|
|
1125
|
+
thread_id = _normalize_optional_text(body.get("thread_id"))
|
|
1126
|
+
|
|
1127
|
+
if not summary:
|
|
1128
|
+
raise HTTPException(status_code=400, detail="summary is required")
|
|
1129
|
+
|
|
1130
|
+
hub_root = request.app.state.config.root
|
|
1131
|
+
lifecycle_router = PmaLifecycleRouter(hub_root)
|
|
1132
|
+
|
|
1133
|
+
result = await lifecycle_router.compact(
|
|
1134
|
+
summary=summary, agent=agent, thread_id=thread_id
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
if result.status != "ok":
|
|
1138
|
+
raise HTTPException(status_code=500, detail=result.error)
|
|
1139
|
+
|
|
1140
|
+
return {
|
|
1141
|
+
"status": result.status,
|
|
1142
|
+
"message": result.message,
|
|
1143
|
+
"artifact_path": (
|
|
1144
|
+
str(result.artifact_path) if result.artifact_path else None
|
|
1145
|
+
),
|
|
1146
|
+
"details": result.details,
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
@router.post("/thread/reset")
|
|
1150
|
+
async def reset_pma_thread(request: Request) -> dict[str, Any]:
|
|
1151
|
+
pma_config = _get_pma_config(request)
|
|
1152
|
+
if not pma_config.get("enabled", True):
|
|
1153
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1154
|
+
body = await request.json()
|
|
1155
|
+
raw_agent = (body.get("agent") or "").strip().lower()
|
|
1156
|
+
agent = raw_agent or None
|
|
1157
|
+
|
|
1158
|
+
hub_root = request.app.state.config.root
|
|
1159
|
+
lifecycle_router = PmaLifecycleRouter(hub_root)
|
|
1160
|
+
|
|
1161
|
+
result = await lifecycle_router.reset(agent=agent)
|
|
1162
|
+
|
|
1163
|
+
if result.status != "ok":
|
|
1164
|
+
raise HTTPException(status_code=500, detail=result.error)
|
|
1165
|
+
|
|
1166
|
+
return {
|
|
1167
|
+
"status": result.status,
|
|
1168
|
+
"cleared": result.details.get("cleared_threads", []),
|
|
1169
|
+
"artifact_path": (
|
|
1170
|
+
str(result.artifact_path) if result.artifact_path else None
|
|
1171
|
+
),
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
@router.get("/turns/{turn_id}/events")
|
|
1175
|
+
async def stream_pma_turn_events(
|
|
1176
|
+
turn_id: str, request: Request, thread_id: str, agent: str = "codex"
|
|
1177
|
+
):
|
|
1178
|
+
pma_config = _get_pma_config(request)
|
|
1179
|
+
if not pma_config.get("enabled", True):
|
|
1180
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1181
|
+
agent_id = (agent or "").strip().lower()
|
|
1182
|
+
if agent_id == "codex":
|
|
1183
|
+
events = getattr(request.app.state, "app_server_events", None)
|
|
1184
|
+
if events is None:
|
|
1185
|
+
raise HTTPException(status_code=404, detail="Codex events unavailable")
|
|
1186
|
+
if not thread_id:
|
|
1187
|
+
raise HTTPException(status_code=400, detail="thread_id is required")
|
|
1188
|
+
return StreamingResponse(
|
|
1189
|
+
events.stream(thread_id, turn_id),
|
|
1190
|
+
media_type="text/event-stream",
|
|
1191
|
+
headers=SSE_HEADERS,
|
|
1192
|
+
)
|
|
1193
|
+
if agent_id == "opencode":
|
|
1194
|
+
if not thread_id:
|
|
1195
|
+
raise HTTPException(status_code=400, detail="thread_id is required")
|
|
1196
|
+
supervisor = getattr(request.app.state, "opencode_supervisor", None)
|
|
1197
|
+
if supervisor is None:
|
|
1198
|
+
raise HTTPException(status_code=404, detail="OpenCode unavailable")
|
|
1199
|
+
harness = OpenCodeHarness(supervisor)
|
|
1200
|
+
return StreamingResponse(
|
|
1201
|
+
harness.stream_events(
|
|
1202
|
+
request.app.state.config.root, thread_id, turn_id
|
|
1203
|
+
),
|
|
1204
|
+
media_type="text/event-stream",
|
|
1205
|
+
headers=SSE_HEADERS,
|
|
1206
|
+
)
|
|
1207
|
+
raise HTTPException(status_code=404, detail="Unknown agent")
|
|
1208
|
+
|
|
1209
|
+
def _serialize_pma_entry(
|
|
1210
|
+
entry: dict[str, Any], *, request: Request
|
|
1211
|
+
) -> dict[str, Any]:
|
|
1212
|
+
base = request.scope.get("root_path", "") or ""
|
|
1213
|
+
box = entry.get("box", "inbox")
|
|
1214
|
+
filename = entry.get("name", "")
|
|
1215
|
+
download = f"{base}/hub/pma/files/{box}/{filename}"
|
|
1216
|
+
return {
|
|
1217
|
+
"name": filename,
|
|
1218
|
+
"box": box,
|
|
1219
|
+
"size": entry.get("size"),
|
|
1220
|
+
"modified_at": entry.get("modified_at"),
|
|
1221
|
+
"source": "pma",
|
|
1222
|
+
"url": download,
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
@router.get("/files")
|
|
1226
|
+
def list_pma_files(request: Request) -> dict[str, list[dict[str, Any]]]:
|
|
1227
|
+
pma_config = _get_pma_config(request)
|
|
1228
|
+
if not pma_config.get("enabled", True):
|
|
1229
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1230
|
+
hub_root = request.app.state.config.root
|
|
1231
|
+
pma_dir = hub_root / ".codex-autorunner" / "pma"
|
|
1232
|
+
result: dict[str, list[dict[str, Any]]] = {"inbox": [], "outbox": []}
|
|
1233
|
+
for box in ["inbox", "outbox"]:
|
|
1234
|
+
box_dir = pma_dir / box
|
|
1235
|
+
if box_dir.exists():
|
|
1236
|
+
files = [
|
|
1237
|
+
{
|
|
1238
|
+
"name": f.name,
|
|
1239
|
+
"box": box,
|
|
1240
|
+
"size": f.stat().st_size if f.is_file() else None,
|
|
1241
|
+
"modified_at": (
|
|
1242
|
+
datetime.fromtimestamp(
|
|
1243
|
+
f.stat().st_mtime, tz=timezone.utc
|
|
1244
|
+
).isoformat()
|
|
1245
|
+
if f.is_file()
|
|
1246
|
+
else None
|
|
1247
|
+
),
|
|
1248
|
+
}
|
|
1249
|
+
for f in box_dir.iterdir()
|
|
1250
|
+
if f.is_file() and not f.name.startswith(".")
|
|
1251
|
+
]
|
|
1252
|
+
result[box] = [
|
|
1253
|
+
_serialize_pma_entry(f, request=request)
|
|
1254
|
+
for f in sorted(files, key=lambda x: x["name"])
|
|
1255
|
+
]
|
|
1256
|
+
return result
|
|
1257
|
+
|
|
1258
|
+
@router.get("/queue")
|
|
1259
|
+
async def pma_queue_status(request: Request) -> dict[str, Any]:
|
|
1260
|
+
pma_config = _get_pma_config(request)
|
|
1261
|
+
if not pma_config.get("enabled", True):
|
|
1262
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1263
|
+
|
|
1264
|
+
queue = _get_pma_queue(request)
|
|
1265
|
+
summary = await queue.get_queue_summary()
|
|
1266
|
+
return summary
|
|
1267
|
+
|
|
1268
|
+
@router.get("/queue/{lane_id:path}")
|
|
1269
|
+
async def pma_lane_queue_status(request: Request, lane_id: str) -> dict[str, Any]:
|
|
1270
|
+
pma_config = _get_pma_config(request)
|
|
1271
|
+
if not pma_config.get("enabled", True):
|
|
1272
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1273
|
+
|
|
1274
|
+
queue = _get_pma_queue(request)
|
|
1275
|
+
items = await queue.list_items(lane_id)
|
|
1276
|
+
return {
|
|
1277
|
+
"lane_id": lane_id,
|
|
1278
|
+
"items": [
|
|
1279
|
+
{
|
|
1280
|
+
"item_id": item.item_id,
|
|
1281
|
+
"state": item.state.value,
|
|
1282
|
+
"enqueued_at": item.enqueued_at,
|
|
1283
|
+
"started_at": item.started_at,
|
|
1284
|
+
"finished_at": item.finished_at,
|
|
1285
|
+
"error": item.error,
|
|
1286
|
+
"dedupe_reason": item.dedupe_reason,
|
|
1287
|
+
}
|
|
1288
|
+
for item in items
|
|
1289
|
+
],
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
@router.post("/files/{box}")
|
|
1293
|
+
async def upload_pma_file(box: str, request: Request):
|
|
1294
|
+
pma_config = _get_pma_config(request)
|
|
1295
|
+
if not pma_config.get("enabled", True):
|
|
1296
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1297
|
+
if box not in ("inbox", "outbox"):
|
|
1298
|
+
raise HTTPException(status_code=400, detail="Invalid box")
|
|
1299
|
+
hub_root = request.app.state.config.root
|
|
1300
|
+
max_upload_bytes = request.app.state.config.pma.max_upload_bytes
|
|
1301
|
+
|
|
1302
|
+
form = await request.form()
|
|
1303
|
+
saved = []
|
|
1304
|
+
for _form_field_name, file in form.items():
|
|
1305
|
+
try:
|
|
1306
|
+
if isinstance(file, UploadFile):
|
|
1307
|
+
content = await file.read()
|
|
1308
|
+
filename = file.filename or ""
|
|
1309
|
+
else:
|
|
1310
|
+
content = file if isinstance(file, bytes) else str(file).encode()
|
|
1311
|
+
filename = ""
|
|
1312
|
+
except Exception as exc:
|
|
1313
|
+
logger.warning("Failed to read PMA upload: %s", exc)
|
|
1314
|
+
raise HTTPException(
|
|
1315
|
+
status_code=400, detail="Failed to read file"
|
|
1316
|
+
) from exc
|
|
1317
|
+
if len(content) > max_upload_bytes:
|
|
1318
|
+
logger.warning(
|
|
1319
|
+
"File too large for PMA upload: %s (%d bytes)",
|
|
1320
|
+
filename,
|
|
1321
|
+
len(content),
|
|
1322
|
+
)
|
|
1323
|
+
raise HTTPException(
|
|
1324
|
+
status_code=400,
|
|
1325
|
+
detail=f"File too large (max {max_upload_bytes} bytes)",
|
|
1326
|
+
)
|
|
1327
|
+
try:
|
|
1328
|
+
target_path = _pma_target_path(hub_root, box, filename)
|
|
1329
|
+
except HTTPException:
|
|
1330
|
+
logger.warning("Invalid filename in PMA upload: %s", filename)
|
|
1331
|
+
raise
|
|
1332
|
+
try:
|
|
1333
|
+
target_path.write_bytes(content)
|
|
1334
|
+
saved.append(target_path.name)
|
|
1335
|
+
_get_safety_checker(request).record_action(
|
|
1336
|
+
action_type=PmaActionType.FILE_UPLOADED,
|
|
1337
|
+
details={
|
|
1338
|
+
"box": box,
|
|
1339
|
+
"filename": target_path.name,
|
|
1340
|
+
"size": len(content),
|
|
1341
|
+
},
|
|
1342
|
+
)
|
|
1343
|
+
except Exception as exc:
|
|
1344
|
+
logger.warning("Failed to write PMA file: %s", exc)
|
|
1345
|
+
raise HTTPException(
|
|
1346
|
+
status_code=500, detail="Failed to save file"
|
|
1347
|
+
) from exc
|
|
1348
|
+
return {"status": "ok", "saved": saved}
|
|
1349
|
+
|
|
1350
|
+
def _pma_target_path(hub_root: Path, box: str, filename: str) -> Path:
|
|
1351
|
+
"""Return a resolved path within the PMA box folder, rejecting traversal attempts."""
|
|
1352
|
+
box_dir = hub_root / ".codex-autorunner" / "pma" / box
|
|
1353
|
+
box_dir.mkdir(parents=True, exist_ok=True)
|
|
1354
|
+
try:
|
|
1355
|
+
safe_name = sanitize_filename(filename)
|
|
1356
|
+
except ValueError as exc:
|
|
1357
|
+
raise HTTPException(status_code=400, detail="Invalid filename") from exc
|
|
1358
|
+
root = box_dir.resolve()
|
|
1359
|
+
candidate = (root / safe_name).resolve()
|
|
1360
|
+
try:
|
|
1361
|
+
candidate.relative_to(root)
|
|
1362
|
+
except ValueError as exc:
|
|
1363
|
+
raise HTTPException(status_code=400, detail="Invalid filename") from exc
|
|
1364
|
+
if candidate.parent != root:
|
|
1365
|
+
raise HTTPException(status_code=400, detail="Invalid filename")
|
|
1366
|
+
return candidate
|
|
1367
|
+
|
|
1368
|
+
@router.get("/files/{box}/{filename}")
|
|
1369
|
+
def download_pma_file(box: str, filename: str, request: Request):
|
|
1370
|
+
pma_config = _get_pma_config(request)
|
|
1371
|
+
if not pma_config.get("enabled", True):
|
|
1372
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1373
|
+
if box not in ("inbox", "outbox"):
|
|
1374
|
+
raise HTTPException(status_code=400, detail="Invalid box")
|
|
1375
|
+
hub_root = request.app.state.config.root
|
|
1376
|
+
try:
|
|
1377
|
+
file_path = _pma_target_path(hub_root, box, filename)
|
|
1378
|
+
except HTTPException:
|
|
1379
|
+
logger.warning("Invalid filename in PMA download: %s", filename)
|
|
1380
|
+
raise
|
|
1381
|
+
if not file_path.exists() or not file_path.is_file():
|
|
1382
|
+
logger.warning("File not found in PMA download: %s", filename)
|
|
1383
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
1384
|
+
_get_safety_checker(request).record_action(
|
|
1385
|
+
action_type=PmaActionType.FILE_DOWNLOADED,
|
|
1386
|
+
details={
|
|
1387
|
+
"box": box,
|
|
1388
|
+
"filename": file_path.name,
|
|
1389
|
+
"size": file_path.stat().st_size,
|
|
1390
|
+
},
|
|
1391
|
+
)
|
|
1392
|
+
return FileResponse(file_path, filename=file_path.name)
|
|
1393
|
+
|
|
1394
|
+
@router.delete("/files/{box}/{filename}")
|
|
1395
|
+
def delete_pma_file(box: str, filename: str, request: Request):
|
|
1396
|
+
pma_config = _get_pma_config(request)
|
|
1397
|
+
if not pma_config.get("enabled", True):
|
|
1398
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1399
|
+
if box not in ("inbox", "outbox"):
|
|
1400
|
+
raise HTTPException(status_code=400, detail="Invalid box")
|
|
1401
|
+
hub_root = request.app.state.config.root
|
|
1402
|
+
try:
|
|
1403
|
+
file_path = _pma_target_path(hub_root, box, filename)
|
|
1404
|
+
except HTTPException:
|
|
1405
|
+
logger.warning("Invalid filename in PMA delete: %s", filename)
|
|
1406
|
+
raise
|
|
1407
|
+
if not file_path.exists() or not file_path.is_file():
|
|
1408
|
+
logger.warning("File not found in PMA delete: %s", filename)
|
|
1409
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
1410
|
+
try:
|
|
1411
|
+
file_size = file_path.stat().st_size
|
|
1412
|
+
file_path.unlink()
|
|
1413
|
+
_get_safety_checker(request).record_action(
|
|
1414
|
+
action_type=PmaActionType.FILE_DELETED,
|
|
1415
|
+
details={"box": box, "filename": file_path.name, "size": file_size},
|
|
1416
|
+
)
|
|
1417
|
+
except Exception as exc:
|
|
1418
|
+
logger.warning("Failed to delete PMA file: %s", exc)
|
|
1419
|
+
raise HTTPException(
|
|
1420
|
+
status_code=500, detail="Failed to delete file"
|
|
1421
|
+
) from exc
|
|
1422
|
+
return {"status": "ok"}
|
|
1423
|
+
|
|
1424
|
+
@router.delete("/files/{box}")
|
|
1425
|
+
def delete_pma_box(box: str, request: Request):
|
|
1426
|
+
pma_config = _get_pma_config(request)
|
|
1427
|
+
if not pma_config.get("enabled", True):
|
|
1428
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1429
|
+
if box not in ("inbox", "outbox"):
|
|
1430
|
+
raise HTTPException(status_code=400, detail="Invalid box")
|
|
1431
|
+
hub_root = request.app.state.config.root
|
|
1432
|
+
box_dir = hub_root / ".codex-autorunner" / "pma" / box
|
|
1433
|
+
deleted_files: list[str] = []
|
|
1434
|
+
if box_dir.exists():
|
|
1435
|
+
for f in box_dir.iterdir():
|
|
1436
|
+
if f.is_file() and not f.name.startswith("."):
|
|
1437
|
+
deleted_files.append(f.name)
|
|
1438
|
+
f.unlink()
|
|
1439
|
+
_get_safety_checker(request).record_action(
|
|
1440
|
+
action_type=PmaActionType.FILE_BULK_DELETED,
|
|
1441
|
+
details={
|
|
1442
|
+
"box": box,
|
|
1443
|
+
"count": len(deleted_files),
|
|
1444
|
+
"sample": deleted_files[:PMA_BULK_DELETE_SAMPLE_LIMIT],
|
|
1445
|
+
},
|
|
1446
|
+
)
|
|
1447
|
+
return {"status": "ok"}
|
|
1448
|
+
|
|
1449
|
+
@router.post("/context/snapshot")
|
|
1450
|
+
def snapshot_pma_context(request: Request, body: Optional[dict[str, Any]] = None):
|
|
1451
|
+
pma_config = _get_pma_config(request)
|
|
1452
|
+
if not pma_config.get("enabled", True):
|
|
1453
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1454
|
+
hub_root = request.app.state.config.root
|
|
1455
|
+
try:
|
|
1456
|
+
ensure_pma_docs(hub_root)
|
|
1457
|
+
except Exception as exc:
|
|
1458
|
+
raise HTTPException(
|
|
1459
|
+
status_code=500, detail=f"Failed to ensure PMA docs: {exc}"
|
|
1460
|
+
) from exc
|
|
1461
|
+
|
|
1462
|
+
reset = False
|
|
1463
|
+
if isinstance(body, dict):
|
|
1464
|
+
reset = bool(body.get("reset", False))
|
|
1465
|
+
|
|
1466
|
+
pma_dir = hub_root / ".codex-autorunner" / "pma"
|
|
1467
|
+
active_context_path = pma_dir / "active_context.md"
|
|
1468
|
+
context_log_path = pma_dir / "context_log.md"
|
|
1469
|
+
|
|
1470
|
+
try:
|
|
1471
|
+
active_content = active_context_path.read_text(encoding="utf-8")
|
|
1472
|
+
except Exception as exc:
|
|
1473
|
+
raise HTTPException(
|
|
1474
|
+
status_code=500, detail=f"Failed to read active_context.md: {exc}"
|
|
1475
|
+
) from exc
|
|
1476
|
+
|
|
1477
|
+
timestamp = now_iso()
|
|
1478
|
+
snapshot_header = f"\n\n## Snapshot: {timestamp}\n\n"
|
|
1479
|
+
snapshot_content = snapshot_header + active_content
|
|
1480
|
+
snapshot_bytes = len(snapshot_content.encode("utf-8"))
|
|
1481
|
+
if snapshot_bytes > PMA_CONTEXT_SNAPSHOT_MAX_BYTES:
|
|
1482
|
+
raise HTTPException(
|
|
1483
|
+
status_code=413,
|
|
1484
|
+
detail=(
|
|
1485
|
+
"Snapshot too large "
|
|
1486
|
+
f"(max {PMA_CONTEXT_SNAPSHOT_MAX_BYTES} bytes)"
|
|
1487
|
+
),
|
|
1488
|
+
)
|
|
1489
|
+
|
|
1490
|
+
try:
|
|
1491
|
+
with context_log_path.open("a", encoding="utf-8") as f:
|
|
1492
|
+
f.write(snapshot_content)
|
|
1493
|
+
except Exception as exc:
|
|
1494
|
+
raise HTTPException(
|
|
1495
|
+
status_code=500, detail=f"Failed to append context_log.md: {exc}"
|
|
1496
|
+
) from exc
|
|
1497
|
+
|
|
1498
|
+
if reset:
|
|
1499
|
+
try:
|
|
1500
|
+
atomic_write(active_context_path, pma_active_context_content())
|
|
1501
|
+
except Exception as exc:
|
|
1502
|
+
raise HTTPException(
|
|
1503
|
+
status_code=500, detail=f"Failed to reset active_context.md: {exc}"
|
|
1504
|
+
) from exc
|
|
1505
|
+
|
|
1506
|
+
line_count = len(active_content.splitlines())
|
|
1507
|
+
response: dict[str, Any] = {
|
|
1508
|
+
"status": "ok",
|
|
1509
|
+
"timestamp": timestamp,
|
|
1510
|
+
"active_context_line_count": line_count,
|
|
1511
|
+
"reset": reset,
|
|
1512
|
+
}
|
|
1513
|
+
try:
|
|
1514
|
+
context_log_bytes = context_log_path.stat().st_size
|
|
1515
|
+
response["context_log_bytes"] = context_log_bytes
|
|
1516
|
+
if context_log_bytes > PMA_CONTEXT_LOG_SOFT_LIMIT_BYTES:
|
|
1517
|
+
response["warning"] = (
|
|
1518
|
+
"context_log.md is large "
|
|
1519
|
+
f"({context_log_bytes} bytes); consider pruning"
|
|
1520
|
+
)
|
|
1521
|
+
except Exception:
|
|
1522
|
+
pass
|
|
1523
|
+
|
|
1524
|
+
return response
|
|
1525
|
+
|
|
1526
|
+
PMA_DOC_ORDER = (
|
|
1527
|
+
"AGENTS.md",
|
|
1528
|
+
"active_context.md",
|
|
1529
|
+
"context_log.md",
|
|
1530
|
+
"ABOUT_CAR.md",
|
|
1531
|
+
"prompt.md",
|
|
1532
|
+
)
|
|
1533
|
+
PMA_DOC_SET = set(PMA_DOC_ORDER)
|
|
1534
|
+
PMA_DOC_DEFAULTS = {
|
|
1535
|
+
"AGENTS.md": pma_agents_content,
|
|
1536
|
+
"active_context.md": pma_active_context_content,
|
|
1537
|
+
"context_log.md": pma_context_log_content,
|
|
1538
|
+
"ABOUT_CAR.md": pma_about_content,
|
|
1539
|
+
"prompt.md": pma_prompt_content,
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
@router.get("/docs/default/{name}")
|
|
1543
|
+
def get_pma_doc_default(name: str, request: Request) -> dict[str, str]:
|
|
1544
|
+
pma_config = _get_pma_config(request)
|
|
1545
|
+
if not pma_config.get("enabled", True):
|
|
1546
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1547
|
+
if name not in PMA_DOC_SET:
|
|
1548
|
+
raise HTTPException(status_code=400, detail=f"Unknown doc name: {name}")
|
|
1549
|
+
content_fn = PMA_DOC_DEFAULTS.get(name)
|
|
1550
|
+
if content_fn is None:
|
|
1551
|
+
raise HTTPException(status_code=404, detail=f"Default not found: {name}")
|
|
1552
|
+
return {"name": name, "content": content_fn()}
|
|
1553
|
+
|
|
1554
|
+
@router.get("/docs")
|
|
1555
|
+
def list_pma_docs(request: Request) -> dict[str, Any]:
|
|
1556
|
+
pma_config = _get_pma_config(request)
|
|
1557
|
+
if not pma_config.get("enabled", True):
|
|
1558
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1559
|
+
hub_root = request.app.state.config.root
|
|
1560
|
+
pma_dir = hub_root / ".codex-autorunner" / "pma"
|
|
1561
|
+
result: list[dict[str, Any]] = []
|
|
1562
|
+
for doc_name in PMA_DOC_ORDER:
|
|
1563
|
+
doc_path = pma_dir / doc_name
|
|
1564
|
+
entry: dict[str, Any] = {"name": doc_name}
|
|
1565
|
+
if doc_path.exists():
|
|
1566
|
+
entry["exists"] = True
|
|
1567
|
+
stat = doc_path.stat()
|
|
1568
|
+
entry["size"] = stat.st_size
|
|
1569
|
+
entry["mtime"] = datetime.fromtimestamp(
|
|
1570
|
+
stat.st_mtime, tz=timezone.utc
|
|
1571
|
+
).isoformat()
|
|
1572
|
+
if doc_name == "active_context.md":
|
|
1573
|
+
try:
|
|
1574
|
+
entry["line_count"] = len(
|
|
1575
|
+
doc_path.read_text(encoding="utf-8").splitlines()
|
|
1576
|
+
)
|
|
1577
|
+
except Exception:
|
|
1578
|
+
entry["line_count"] = 0
|
|
1579
|
+
else:
|
|
1580
|
+
entry["exists"] = False
|
|
1581
|
+
result.append(entry)
|
|
1582
|
+
return {
|
|
1583
|
+
"docs": result,
|
|
1584
|
+
"active_context_max_lines": int(
|
|
1585
|
+
pma_config.get("active_context_max_lines", 200)
|
|
1586
|
+
),
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
@router.get("/docs/{name}")
|
|
1590
|
+
def get_pma_doc(name: str, request: Request) -> dict[str, str]:
|
|
1591
|
+
pma_config = _get_pma_config(request)
|
|
1592
|
+
if not pma_config.get("enabled", True):
|
|
1593
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1594
|
+
if name not in PMA_DOC_SET:
|
|
1595
|
+
raise HTTPException(status_code=400, detail=f"Unknown doc name: {name}")
|
|
1596
|
+
hub_root = request.app.state.config.root
|
|
1597
|
+
pma_dir = hub_root / ".codex-autorunner" / "pma"
|
|
1598
|
+
doc_path = pma_dir / name
|
|
1599
|
+
if not doc_path.exists():
|
|
1600
|
+
raise HTTPException(status_code=404, detail=f"Doc not found: {name}")
|
|
1601
|
+
try:
|
|
1602
|
+
content = doc_path.read_text(encoding="utf-8")
|
|
1603
|
+
except Exception as exc:
|
|
1604
|
+
raise HTTPException(
|
|
1605
|
+
status_code=500, detail=f"Failed to read doc: {exc}"
|
|
1606
|
+
) from exc
|
|
1607
|
+
return {"name": name, "content": content}
|
|
1608
|
+
|
|
1609
|
+
@router.put("/docs/{name}")
|
|
1610
|
+
def update_pma_doc(
|
|
1611
|
+
name: str, request: Request, body: dict[str, str]
|
|
1612
|
+
) -> dict[str, str]:
|
|
1613
|
+
pma_config = _get_pma_config(request)
|
|
1614
|
+
if not pma_config.get("enabled", True):
|
|
1615
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1616
|
+
if name not in PMA_DOC_SET:
|
|
1617
|
+
raise HTTPException(status_code=400, detail=f"Unknown doc name: {name}")
|
|
1618
|
+
content = body.get("content", "")
|
|
1619
|
+
if not isinstance(content, str):
|
|
1620
|
+
raise HTTPException(status_code=400, detail="content must be a string")
|
|
1621
|
+
MAX_DOC_SIZE = 500_000
|
|
1622
|
+
if len(content) > MAX_DOC_SIZE:
|
|
1623
|
+
raise HTTPException(
|
|
1624
|
+
status_code=413, detail=f"Content too large (max {MAX_DOC_SIZE} bytes)"
|
|
1625
|
+
)
|
|
1626
|
+
hub_root = request.app.state.config.root
|
|
1627
|
+
pma_dir = hub_root / ".codex-autorunner" / "pma"
|
|
1628
|
+
pma_dir.mkdir(parents=True, exist_ok=True)
|
|
1629
|
+
doc_path = pma_dir / name
|
|
1630
|
+
try:
|
|
1631
|
+
atomic_write(doc_path, content)
|
|
1632
|
+
except Exception as exc:
|
|
1633
|
+
raise HTTPException(
|
|
1634
|
+
status_code=500, detail=f"Failed to write doc: {exc}"
|
|
1635
|
+
) from exc
|
|
1636
|
+
details = {
|
|
1637
|
+
"name": name,
|
|
1638
|
+
"size": len(content.encode("utf-8")),
|
|
1639
|
+
"source": "web",
|
|
1640
|
+
}
|
|
1641
|
+
if name == "active_context.md":
|
|
1642
|
+
details["line_count"] = len(content.splitlines())
|
|
1643
|
+
_get_safety_checker(request).record_action(
|
|
1644
|
+
action_type=PmaActionType.DOC_UPDATED,
|
|
1645
|
+
details=details,
|
|
1646
|
+
)
|
|
1647
|
+
return {"name": name, "status": "ok"}
|
|
1648
|
+
|
|
1649
|
+
return router
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
__all__ = ["build_pma_routes"]
|