codex-autorunner 1.2.1__py3-none-any.whl → 1.3.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/bootstrap.py +26 -5
- codex_autorunner/core/config.py +176 -59
- codex_autorunner/core/filesystem.py +24 -0
- codex_autorunner/core/flows/controller.py +50 -12
- codex_autorunner/core/flows/runtime.py +8 -3
- codex_autorunner/core/hub.py +293 -16
- codex_autorunner/core/lifecycle_events.py +44 -5
- codex_autorunner/core/pma_delivery.py +81 -0
- codex_autorunner/core/pma_dispatches.py +224 -0
- codex_autorunner/core/pma_lane_worker.py +122 -0
- codex_autorunner/core/pma_queue.py +167 -18
- codex_autorunner/core/pma_reactive.py +91 -0
- codex_autorunner/core/pma_safety.py +58 -0
- codex_autorunner/core/pma_sink.py +104 -0
- codex_autorunner/core/pma_transcripts.py +183 -0
- codex_autorunner/core/safe_paths.py +117 -0
- codex_autorunner/housekeeping.py +77 -23
- codex_autorunner/integrations/agents/codex_backend.py +18 -12
- codex_autorunner/integrations/agents/wiring.py +2 -0
- codex_autorunner/integrations/app_server/client.py +31 -0
- codex_autorunner/integrations/app_server/supervisor.py +3 -0
- codex_autorunner/integrations/telegram/constants.py +1 -1
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +16 -15
- codex_autorunner/integrations/telegram/handlers/commands/files.py +5 -8
- codex_autorunner/integrations/telegram/handlers/commands/github.py +10 -6
- codex_autorunner/integrations/telegram/handlers/commands/shared.py +9 -8
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +85 -2
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +29 -8
- codex_autorunner/integrations/telegram/helpers.py +30 -2
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +54 -3
- codex_autorunner/static/docChatCore.js +2 -0
- codex_autorunner/static/hub.js +59 -0
- codex_autorunner/static/index.html +70 -54
- codex_autorunner/static/notificationBell.js +173 -0
- codex_autorunner/static/notifications.js +154 -36
- codex_autorunner/static/pma.js +96 -35
- codex_autorunner/static/styles.css +415 -4
- codex_autorunner/static/utils.js +5 -1
- codex_autorunner/surfaces/cli/cli.py +206 -129
- codex_autorunner/surfaces/cli/template_repos.py +157 -0
- codex_autorunner/surfaces/web/app.py +193 -5
- codex_autorunner/surfaces/web/routes/file_chat.py +109 -61
- codex_autorunner/surfaces/web/routes/flows.py +125 -67
- codex_autorunner/surfaces/web/routes/pma.py +638 -57
- codex_autorunner/tickets/agent_pool.py +6 -1
- codex_autorunner/tickets/outbox.py +27 -14
- codex_autorunner/tickets/replies.py +4 -10
- codex_autorunner/tickets/runner.py +1 -0
- codex_autorunner/workspace/paths.py +8 -3
- {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/METADATA +1 -1
- {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/RECORD +55 -45
- {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/top_level.txt +0 -0
|
@@ -8,6 +8,7 @@ import asyncio
|
|
|
8
8
|
import hashlib
|
|
9
9
|
import json
|
|
10
10
|
import logging
|
|
11
|
+
import uuid
|
|
11
12
|
from datetime import datetime, timezone
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
from typing import Any, Optional
|
|
@@ -38,12 +39,26 @@ from ....core.pma_context import (
|
|
|
38
39
|
format_pma_prompt,
|
|
39
40
|
load_pma_prompt,
|
|
40
41
|
)
|
|
42
|
+
from ....core.pma_delivery import deliver_pma_output_to_active_sink
|
|
43
|
+
from ....core.pma_dispatches import (
|
|
44
|
+
find_pma_dispatch_path,
|
|
45
|
+
list_pma_dispatches,
|
|
46
|
+
list_pma_dispatches_for_turn,
|
|
47
|
+
resolve_pma_dispatch,
|
|
48
|
+
)
|
|
49
|
+
from ....core.pma_lane_worker import PmaLaneWorker
|
|
41
50
|
from ....core.pma_lifecycle import PmaLifecycleRouter
|
|
42
51
|
from ....core.pma_queue import PmaQueue, QueueItemState
|
|
43
52
|
from ....core.pma_safety import PmaSafetyChecker, PmaSafetyConfig
|
|
53
|
+
from ....core.pma_sink import PmaActiveSinkStore
|
|
44
54
|
from ....core.pma_state import PmaStateStore
|
|
55
|
+
from ....core.pma_transcripts import PmaTranscriptStore
|
|
45
56
|
from ....core.time_utils import now_iso
|
|
46
57
|
from ....core.utils import atomic_write
|
|
58
|
+
from ....integrations.telegram.adapter import chunk_message
|
|
59
|
+
from ....integrations.telegram.config import DEFAULT_STATE_FILE
|
|
60
|
+
from ....integrations.telegram.constants import TELEGRAM_MAX_MESSAGE_LENGTH
|
|
61
|
+
from ....integrations.telegram.state import OutboxRecord, TelegramStateStore
|
|
47
62
|
from .agents import _available_agents, _serialize_model_catalog
|
|
48
63
|
from .shared import SSE_HEADERS
|
|
49
64
|
|
|
@@ -69,8 +84,7 @@ def build_pma_routes() -> APIRouter:
|
|
|
69
84
|
pma_audit_log: Optional[PmaAuditLog] = None
|
|
70
85
|
pma_queue: Optional[PmaQueue] = None
|
|
71
86
|
pma_queue_root: Optional[Path] = None
|
|
72
|
-
lane_workers: dict[str,
|
|
73
|
-
lane_cancel_events: dict[str, asyncio.Event] = {}
|
|
87
|
+
lane_workers: dict[str, PmaLaneWorker] = {}
|
|
74
88
|
item_futures: dict[str, asyncio.Future[dict[str, Any]]] = {}
|
|
75
89
|
|
|
76
90
|
def _normalize_optional_text(value: Any) -> Optional[str]:
|
|
@@ -127,6 +141,12 @@ def build_pma_routes() -> APIRouter:
|
|
|
127
141
|
def _get_safety_checker(request: Request) -> PmaSafetyChecker:
|
|
128
142
|
nonlocal pma_safety_checker, pma_safety_root, pma_audit_log
|
|
129
143
|
hub_root = request.app.state.config.root
|
|
144
|
+
supervisor = getattr(request.app.state, "hub_supervisor", None)
|
|
145
|
+
if supervisor is not None:
|
|
146
|
+
try:
|
|
147
|
+
return supervisor.get_pma_safety_checker()
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
130
150
|
if pma_safety_checker is None or pma_safety_root != hub_root:
|
|
131
151
|
raw = getattr(request.app.state.config, "raw", {})
|
|
132
152
|
pma_config = raw.get("pma", {}) if isinstance(raw, dict) else {}
|
|
@@ -160,6 +180,116 @@ def build_pma_routes() -> APIRouter:
|
|
|
160
180
|
pma_queue_root = hub_root
|
|
161
181
|
return pma_queue
|
|
162
182
|
|
|
183
|
+
def _resolve_telegram_state_path(request: Request) -> Path:
|
|
184
|
+
hub_root = request.app.state.config.root
|
|
185
|
+
raw = getattr(request.app.state.config, "raw", {})
|
|
186
|
+
telegram_cfg = raw.get("telegram_bot") if isinstance(raw, dict) else {}
|
|
187
|
+
if not isinstance(telegram_cfg, dict):
|
|
188
|
+
telegram_cfg = {}
|
|
189
|
+
state_file = telegram_cfg.get("state_file")
|
|
190
|
+
if not isinstance(state_file, str) or not state_file.strip():
|
|
191
|
+
state_file = DEFAULT_STATE_FILE
|
|
192
|
+
state_path = Path(state_file)
|
|
193
|
+
if not state_path.is_absolute():
|
|
194
|
+
state_path = (hub_root / state_path).resolve()
|
|
195
|
+
return state_path
|
|
196
|
+
|
|
197
|
+
async def _deliver_to_active_sink(
|
|
198
|
+
*,
|
|
199
|
+
request: Request,
|
|
200
|
+
result: dict[str, Any],
|
|
201
|
+
current: dict[str, Any],
|
|
202
|
+
lifecycle_event: Optional[dict[str, Any]],
|
|
203
|
+
turn_id: Optional[str] = None,
|
|
204
|
+
) -> None:
|
|
205
|
+
if not lifecycle_event:
|
|
206
|
+
return
|
|
207
|
+
status = result.get("status") or "error"
|
|
208
|
+
if status != "ok":
|
|
209
|
+
return
|
|
210
|
+
assistant_text = _resolve_transcript_text(result)
|
|
211
|
+
if not assistant_text.strip():
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
hub_root = request.app.state.config.root
|
|
215
|
+
if not isinstance(turn_id, str) or not turn_id:
|
|
216
|
+
turn_id = _resolve_transcript_turn_id(result, current)
|
|
217
|
+
state_path = _resolve_telegram_state_path(request)
|
|
218
|
+
await deliver_pma_output_to_active_sink(
|
|
219
|
+
hub_root=hub_root,
|
|
220
|
+
assistant_text=assistant_text,
|
|
221
|
+
turn_id=turn_id,
|
|
222
|
+
lifecycle_event=lifecycle_event,
|
|
223
|
+
telegram_state_path=state_path,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
async def _deliver_dispatches_to_active_sink(
|
|
227
|
+
*,
|
|
228
|
+
request: Request,
|
|
229
|
+
turn_id: Optional[str],
|
|
230
|
+
) -> None:
|
|
231
|
+
if not isinstance(turn_id, str) or not turn_id:
|
|
232
|
+
return
|
|
233
|
+
hub_root = request.app.state.config.root
|
|
234
|
+
dispatches = list_pma_dispatches_for_turn(hub_root, turn_id)
|
|
235
|
+
if not dispatches:
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
sink_store = PmaActiveSinkStore(hub_root)
|
|
239
|
+
sink = sink_store.load()
|
|
240
|
+
if not isinstance(sink, dict) or sink.get("kind") != "telegram":
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
chat_id = sink.get("chat_id")
|
|
244
|
+
thread_id = sink.get("thread_id")
|
|
245
|
+
if not isinstance(chat_id, int):
|
|
246
|
+
return
|
|
247
|
+
if thread_id is not None and not isinstance(thread_id, int):
|
|
248
|
+
thread_id = None
|
|
249
|
+
|
|
250
|
+
state_path = _resolve_telegram_state_path(request)
|
|
251
|
+
store = TelegramStateStore(state_path)
|
|
252
|
+
try:
|
|
253
|
+
for dispatch in dispatches:
|
|
254
|
+
title = dispatch.title or "PMA dispatch"
|
|
255
|
+
priority = dispatch.priority or "info"
|
|
256
|
+
header = f"**PMA dispatch** ({priority})\n{title}"
|
|
257
|
+
body = dispatch.body.strip()
|
|
258
|
+
link_lines = []
|
|
259
|
+
for link in dispatch.links:
|
|
260
|
+
label = link.get("label", "")
|
|
261
|
+
href = link.get("href", "")
|
|
262
|
+
if label and href:
|
|
263
|
+
link_lines.append(f"- {label}: {href}")
|
|
264
|
+
details = "\n".join(
|
|
265
|
+
line for line in [body, "\n".join(link_lines)] if line
|
|
266
|
+
).strip()
|
|
267
|
+
message = header
|
|
268
|
+
if details:
|
|
269
|
+
message = f"{header}\n\n{details}"
|
|
270
|
+
|
|
271
|
+
chunks = chunk_message(
|
|
272
|
+
message, max_len=TELEGRAM_MAX_MESSAGE_LENGTH, with_numbering=True
|
|
273
|
+
)
|
|
274
|
+
for idx, chunk in enumerate(chunks, 1):
|
|
275
|
+
record_id = f"pma-dispatch:{dispatch.dispatch_id}:{idx}"
|
|
276
|
+
record = OutboxRecord(
|
|
277
|
+
record_id=record_id,
|
|
278
|
+
chat_id=chat_id,
|
|
279
|
+
thread_id=thread_id,
|
|
280
|
+
reply_to_message_id=None,
|
|
281
|
+
placeholder_message_id=None,
|
|
282
|
+
text=chunk,
|
|
283
|
+
created_at=now_iso(),
|
|
284
|
+
operation="send",
|
|
285
|
+
outbox_key=record_id,
|
|
286
|
+
)
|
|
287
|
+
await store.enqueue_outbox(record)
|
|
288
|
+
except Exception:
|
|
289
|
+
logger.exception("Failed to enqueue PMA dispatch to Telegram outbox")
|
|
290
|
+
finally:
|
|
291
|
+
await store.close()
|
|
292
|
+
|
|
163
293
|
async def _persist_state(store: Optional[PmaStateStore]) -> None:
|
|
164
294
|
if store is None:
|
|
165
295
|
return
|
|
@@ -207,6 +337,104 @@ def build_pma_routes() -> APIRouter:
|
|
|
207
337
|
"finished_at": now_iso(),
|
|
208
338
|
}
|
|
209
339
|
|
|
340
|
+
def _resolve_transcript_turn_id(
|
|
341
|
+
result: dict[str, Any], current: dict[str, Any]
|
|
342
|
+
) -> str:
|
|
343
|
+
for candidate in (
|
|
344
|
+
result.get("turn_id"),
|
|
345
|
+
current.get("turn_id"),
|
|
346
|
+
current.get("client_turn_id"),
|
|
347
|
+
):
|
|
348
|
+
if isinstance(candidate, str) and candidate.strip():
|
|
349
|
+
return candidate.strip()
|
|
350
|
+
return f"local-{uuid.uuid4()}"
|
|
351
|
+
|
|
352
|
+
def _resolve_transcript_text(result: dict[str, Any]) -> str:
|
|
353
|
+
message = result.get("message")
|
|
354
|
+
if isinstance(message, str) and message.strip():
|
|
355
|
+
return message
|
|
356
|
+
detail = result.get("detail")
|
|
357
|
+
if isinstance(detail, str) and detail.strip():
|
|
358
|
+
return detail
|
|
359
|
+
return ""
|
|
360
|
+
|
|
361
|
+
def _build_transcript_metadata(
|
|
362
|
+
*,
|
|
363
|
+
result: dict[str, Any],
|
|
364
|
+
current: dict[str, Any],
|
|
365
|
+
prompt_message: Optional[str],
|
|
366
|
+
lifecycle_event: Optional[dict[str, Any]],
|
|
367
|
+
model: Optional[str],
|
|
368
|
+
reasoning: Optional[str],
|
|
369
|
+
duration_ms: Optional[int],
|
|
370
|
+
finished_at: str,
|
|
371
|
+
) -> dict[str, Any]:
|
|
372
|
+
trigger = "lifecycle_event" if lifecycle_event else "user_prompt"
|
|
373
|
+
metadata: dict[str, Any] = {
|
|
374
|
+
"status": result.get("status") or "error",
|
|
375
|
+
"agent": current.get("agent"),
|
|
376
|
+
"thread_id": result.get("thread_id") or current.get("thread_id"),
|
|
377
|
+
"turn_id": _resolve_transcript_turn_id(result, current),
|
|
378
|
+
"client_turn_id": current.get("client_turn_id") or "",
|
|
379
|
+
"lane_id": current.get("lane_id") or "",
|
|
380
|
+
"trigger": trigger,
|
|
381
|
+
"model": model,
|
|
382
|
+
"reasoning": reasoning,
|
|
383
|
+
"started_at": current.get("started_at"),
|
|
384
|
+
"finished_at": finished_at,
|
|
385
|
+
"duration_ms": duration_ms,
|
|
386
|
+
"user_prompt": prompt_message or "",
|
|
387
|
+
}
|
|
388
|
+
if lifecycle_event:
|
|
389
|
+
metadata["lifecycle_event"] = dict(lifecycle_event)
|
|
390
|
+
metadata["event_id"] = lifecycle_event.get("event_id")
|
|
391
|
+
metadata["event_type"] = lifecycle_event.get("event_type")
|
|
392
|
+
metadata["repo_id"] = lifecycle_event.get("repo_id")
|
|
393
|
+
metadata["run_id"] = lifecycle_event.get("run_id")
|
|
394
|
+
metadata["event_timestamp"] = lifecycle_event.get("timestamp")
|
|
395
|
+
return metadata
|
|
396
|
+
|
|
397
|
+
async def _persist_transcript(
|
|
398
|
+
*,
|
|
399
|
+
request: Request,
|
|
400
|
+
result: dict[str, Any],
|
|
401
|
+
current: dict[str, Any],
|
|
402
|
+
prompt_message: Optional[str],
|
|
403
|
+
lifecycle_event: Optional[dict[str, Any]],
|
|
404
|
+
model: Optional[str],
|
|
405
|
+
reasoning: Optional[str],
|
|
406
|
+
duration_ms: Optional[int],
|
|
407
|
+
finished_at: str,
|
|
408
|
+
) -> Optional[dict[str, Any]]:
|
|
409
|
+
hub_root = request.app.state.config.root
|
|
410
|
+
store = PmaTranscriptStore(hub_root)
|
|
411
|
+
assistant_text = _resolve_transcript_text(result)
|
|
412
|
+
metadata = _build_transcript_metadata(
|
|
413
|
+
result=result,
|
|
414
|
+
current=current,
|
|
415
|
+
prompt_message=prompt_message,
|
|
416
|
+
lifecycle_event=lifecycle_event,
|
|
417
|
+
model=model,
|
|
418
|
+
reasoning=reasoning,
|
|
419
|
+
duration_ms=duration_ms,
|
|
420
|
+
finished_at=finished_at,
|
|
421
|
+
)
|
|
422
|
+
try:
|
|
423
|
+
pointer = store.write_transcript(
|
|
424
|
+
turn_id=metadata["turn_id"],
|
|
425
|
+
metadata=metadata,
|
|
426
|
+
assistant_text=assistant_text,
|
|
427
|
+
)
|
|
428
|
+
except Exception:
|
|
429
|
+
logger.exception("Failed to write PMA transcript")
|
|
430
|
+
return None
|
|
431
|
+
return {
|
|
432
|
+
"turn_id": pointer.turn_id,
|
|
433
|
+
"metadata_path": pointer.metadata_path,
|
|
434
|
+
"content_path": pointer.content_path,
|
|
435
|
+
"created_at": pointer.created_at,
|
|
436
|
+
}
|
|
437
|
+
|
|
210
438
|
async def _get_interrupt_event() -> asyncio.Event:
|
|
211
439
|
nonlocal pma_event
|
|
212
440
|
async with pma_lock:
|
|
@@ -265,6 +493,10 @@ def build_pma_routes() -> APIRouter:
|
|
|
265
493
|
*,
|
|
266
494
|
request: Request,
|
|
267
495
|
store: Optional[PmaStateStore] = None,
|
|
496
|
+
prompt_message: Optional[str] = None,
|
|
497
|
+
lifecycle_event: Optional[dict[str, Any]] = None,
|
|
498
|
+
model: Optional[str] = None,
|
|
499
|
+
reasoning: Optional[str] = None,
|
|
268
500
|
) -> None:
|
|
269
501
|
nonlocal pma_current, pma_last_result, pma_active, pma_event
|
|
270
502
|
async with pma_lock:
|
|
@@ -277,6 +509,7 @@ def build_pma_routes() -> APIRouter:
|
|
|
277
509
|
status = result.get("status") or "error"
|
|
278
510
|
started_at = current_snapshot.get("started_at")
|
|
279
511
|
duration_ms = None
|
|
512
|
+
finished_at = now_iso()
|
|
280
513
|
if started_at:
|
|
281
514
|
try:
|
|
282
515
|
start_dt = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
|
@@ -286,6 +519,23 @@ def build_pma_routes() -> APIRouter:
|
|
|
286
519
|
except Exception:
|
|
287
520
|
pass
|
|
288
521
|
|
|
522
|
+
transcript_pointer = await _persist_transcript(
|
|
523
|
+
request=request,
|
|
524
|
+
result=result,
|
|
525
|
+
current=current_snapshot,
|
|
526
|
+
prompt_message=prompt_message,
|
|
527
|
+
lifecycle_event=lifecycle_event,
|
|
528
|
+
model=model,
|
|
529
|
+
reasoning=reasoning,
|
|
530
|
+
duration_ms=duration_ms,
|
|
531
|
+
finished_at=finished_at,
|
|
532
|
+
)
|
|
533
|
+
if transcript_pointer is not None:
|
|
534
|
+
pma_last_result = dict(pma_last_result or {})
|
|
535
|
+
pma_last_result["transcript"] = transcript_pointer
|
|
536
|
+
if not pma_last_result.get("turn_id"):
|
|
537
|
+
pma_last_result["turn_id"] = transcript_pointer.get("turn_id")
|
|
538
|
+
|
|
289
539
|
log_event(
|
|
290
540
|
logger,
|
|
291
541
|
logging.INFO,
|
|
@@ -316,11 +566,33 @@ def build_pma_routes() -> APIRouter:
|
|
|
316
566
|
status=status,
|
|
317
567
|
error=result.get("detail") if status == "error" else None,
|
|
318
568
|
)
|
|
569
|
+
|
|
570
|
+
delivery_turn_id = None
|
|
571
|
+
if isinstance(pma_last_result, dict):
|
|
572
|
+
candidate = pma_last_result.get("turn_id")
|
|
573
|
+
if isinstance(candidate, str) and candidate:
|
|
574
|
+
delivery_turn_id = candidate
|
|
575
|
+
await _deliver_to_active_sink(
|
|
576
|
+
request=request,
|
|
577
|
+
result=result,
|
|
578
|
+
current=current_snapshot,
|
|
579
|
+
lifecycle_event=lifecycle_event,
|
|
580
|
+
turn_id=delivery_turn_id,
|
|
581
|
+
)
|
|
582
|
+
await _deliver_dispatches_to_active_sink(
|
|
583
|
+
request=request,
|
|
584
|
+
turn_id=delivery_turn_id,
|
|
585
|
+
)
|
|
319
586
|
_get_safety_checker(request).record_chat_result(
|
|
320
587
|
agent=current_snapshot.get("agent") or "",
|
|
321
588
|
status=status,
|
|
322
589
|
error=result.get("detail") if status == "error" else None,
|
|
323
590
|
)
|
|
591
|
+
if lifecycle_event:
|
|
592
|
+
_get_safety_checker(request).record_reactive_result(
|
|
593
|
+
status=status,
|
|
594
|
+
error=result.get("detail") if status == "error" else None,
|
|
595
|
+
)
|
|
324
596
|
|
|
325
597
|
await _persist_state(store)
|
|
326
598
|
|
|
@@ -376,43 +648,45 @@ def build_pma_routes() -> APIRouter:
|
|
|
376
648
|
}
|
|
377
649
|
|
|
378
650
|
async def _ensure_lane_worker(lane_id: str, request: Request) -> None:
|
|
379
|
-
nonlocal lane_workers
|
|
380
|
-
|
|
651
|
+
nonlocal lane_workers
|
|
652
|
+
existing = lane_workers.get(lane_id)
|
|
653
|
+
if existing is not None and existing.is_running:
|
|
381
654
|
return
|
|
382
655
|
|
|
383
|
-
|
|
384
|
-
|
|
656
|
+
async def _on_result(item, result: dict[str, Any]) -> None:
|
|
657
|
+
result_future = item_futures.get(item.item_id)
|
|
658
|
+
if result_future and not result_future.done():
|
|
659
|
+
result_future.set_result(result)
|
|
660
|
+
item_futures.pop(item.item_id, None)
|
|
385
661
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
662
|
+
queue = _get_pma_queue(request)
|
|
663
|
+
worker = PmaLaneWorker(
|
|
664
|
+
lane_id,
|
|
665
|
+
queue,
|
|
666
|
+
lambda item: _execute_queue_item(item, request),
|
|
667
|
+
log=logger,
|
|
668
|
+
on_result=_on_result,
|
|
669
|
+
)
|
|
670
|
+
lane_workers[lane_id] = worker
|
|
671
|
+
await worker.start()
|
|
394
672
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
673
|
+
async def _stop_lane_worker(lane_id: str) -> None:
|
|
674
|
+
worker = lane_workers.get(lane_id)
|
|
675
|
+
if worker is None:
|
|
676
|
+
return
|
|
677
|
+
await worker.stop()
|
|
678
|
+
lane_workers.pop(lane_id, None)
|
|
398
679
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
680
|
+
class _AppRequest:
|
|
681
|
+
def __init__(self, app: Any) -> None:
|
|
682
|
+
self.app = app
|
|
683
|
+
|
|
684
|
+
async def _ensure_lane_worker_for_app(app: Any, lane_id: str) -> None:
|
|
685
|
+
await _ensure_lane_worker(lane_id, _AppRequest(app))
|
|
686
|
+
|
|
687
|
+
async def _stop_lane_worker_for_app(app: Any, lane_id: str) -> None:
|
|
688
|
+
_ = app
|
|
689
|
+
await _stop_lane_worker(lane_id)
|
|
416
690
|
|
|
417
691
|
async def _execute_queue_item(item: Any, request: Request) -> dict[str, Any]:
|
|
418
692
|
hub_root = request.app.state.config.root
|
|
@@ -423,6 +697,9 @@ def build_pma_routes() -> APIRouter:
|
|
|
423
697
|
agent = payload.get("agent")
|
|
424
698
|
model = _normalize_optional_text(payload.get("model"))
|
|
425
699
|
reasoning = _normalize_optional_text(payload.get("reasoning"))
|
|
700
|
+
lifecycle_event = payload.get("lifecycle_event")
|
|
701
|
+
if not isinstance(lifecycle_event, dict):
|
|
702
|
+
lifecycle_event = None
|
|
426
703
|
|
|
427
704
|
store = _get_state_store(request)
|
|
428
705
|
agents, available_default = _available_agents(request)
|
|
@@ -479,14 +756,30 @@ def build_pma_routes() -> APIRouter:
|
|
|
479
756
|
"client_turn_id": client_turn_id or "",
|
|
480
757
|
}
|
|
481
758
|
if started:
|
|
482
|
-
await _finalize_result(
|
|
759
|
+
await _finalize_result(
|
|
760
|
+
error_result,
|
|
761
|
+
request=request,
|
|
762
|
+
store=store,
|
|
763
|
+
prompt_message=message,
|
|
764
|
+
lifecycle_event=lifecycle_event,
|
|
765
|
+
model=model,
|
|
766
|
+
reasoning=reasoning,
|
|
767
|
+
)
|
|
483
768
|
return error_result
|
|
484
769
|
|
|
485
770
|
interrupt_event = await _get_interrupt_event()
|
|
486
771
|
if interrupt_event.is_set():
|
|
487
772
|
result = {"status": "interrupted", "detail": "PMA chat interrupted"}
|
|
488
773
|
if started:
|
|
489
|
-
await _finalize_result(
|
|
774
|
+
await _finalize_result(
|
|
775
|
+
result,
|
|
776
|
+
request=request,
|
|
777
|
+
store=store,
|
|
778
|
+
prompt_message=message,
|
|
779
|
+
lifecycle_event=lifecycle_event,
|
|
780
|
+
model=model,
|
|
781
|
+
reasoning=reasoning,
|
|
782
|
+
)
|
|
490
783
|
return result
|
|
491
784
|
|
|
492
785
|
meta_future: asyncio.Future[tuple[str, str]] = (
|
|
@@ -541,7 +834,15 @@ def build_pma_routes() -> APIRouter:
|
|
|
541
834
|
if opencode is None:
|
|
542
835
|
result = {"status": "error", "detail": "OpenCode unavailable"}
|
|
543
836
|
if started:
|
|
544
|
-
await _finalize_result(
|
|
837
|
+
await _finalize_result(
|
|
838
|
+
result,
|
|
839
|
+
request=request,
|
|
840
|
+
store=store,
|
|
841
|
+
prompt_message=message,
|
|
842
|
+
lifecycle_event=lifecycle_event,
|
|
843
|
+
model=model,
|
|
844
|
+
reasoning=reasoning,
|
|
845
|
+
)
|
|
545
846
|
return result
|
|
546
847
|
result = await _execute_opencode(
|
|
547
848
|
opencode,
|
|
@@ -559,7 +860,15 @@ def build_pma_routes() -> APIRouter:
|
|
|
559
860
|
if supervisor is None or events is None:
|
|
560
861
|
result = {"status": "error", "detail": "App-server unavailable"}
|
|
561
862
|
if started:
|
|
562
|
-
await _finalize_result(
|
|
863
|
+
await _finalize_result(
|
|
864
|
+
result,
|
|
865
|
+
request=request,
|
|
866
|
+
store=store,
|
|
867
|
+
prompt_message=message,
|
|
868
|
+
lifecycle_event=lifecycle_event,
|
|
869
|
+
model=model,
|
|
870
|
+
reasoning=reasoning,
|
|
871
|
+
)
|
|
563
872
|
return result
|
|
564
873
|
result = await _execute_app_server(
|
|
565
874
|
supervisor,
|
|
@@ -580,12 +889,28 @@ def build_pma_routes() -> APIRouter:
|
|
|
580
889
|
"detail": str(exc),
|
|
581
890
|
"client_turn_id": client_turn_id or "",
|
|
582
891
|
}
|
|
583
|
-
await _finalize_result(
|
|
892
|
+
await _finalize_result(
|
|
893
|
+
error_result,
|
|
894
|
+
request=request,
|
|
895
|
+
store=store,
|
|
896
|
+
prompt_message=message,
|
|
897
|
+
lifecycle_event=lifecycle_event,
|
|
898
|
+
model=model,
|
|
899
|
+
reasoning=reasoning,
|
|
900
|
+
)
|
|
584
901
|
raise
|
|
585
902
|
|
|
586
903
|
result = dict(result or {})
|
|
587
904
|
result["client_turn_id"] = client_turn_id or ""
|
|
588
|
-
await _finalize_result(
|
|
905
|
+
await _finalize_result(
|
|
906
|
+
result,
|
|
907
|
+
request=request,
|
|
908
|
+
store=store,
|
|
909
|
+
prompt_message=message,
|
|
910
|
+
lifecycle_event=lifecycle_event,
|
|
911
|
+
model=model,
|
|
912
|
+
reasoning=reasoning,
|
|
913
|
+
)
|
|
589
914
|
return result
|
|
590
915
|
|
|
591
916
|
@router.get("/active")
|
|
@@ -626,6 +951,28 @@ def build_pma_routes() -> APIRouter:
|
|
|
626
951
|
current = {}
|
|
627
952
|
return {"active": active, "current": current, "last_result": last_result}
|
|
628
953
|
|
|
954
|
+
@router.get("/history")
|
|
955
|
+
def list_pma_history(request: Request, limit: int = 50) -> dict[str, Any]:
|
|
956
|
+
pma_config = _get_pma_config(request)
|
|
957
|
+
if not pma_config.get("enabled", True):
|
|
958
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
959
|
+
hub_root = request.app.state.config.root
|
|
960
|
+
store = PmaTranscriptStore(hub_root)
|
|
961
|
+
entries = store.list_recent(limit=limit)
|
|
962
|
+
return {"entries": entries}
|
|
963
|
+
|
|
964
|
+
@router.get("/history/{turn_id}")
|
|
965
|
+
def get_pma_history(turn_id: str, request: Request) -> dict[str, Any]:
|
|
966
|
+
pma_config = _get_pma_config(request)
|
|
967
|
+
if not pma_config.get("enabled", True):
|
|
968
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
969
|
+
hub_root = request.app.state.config.root
|
|
970
|
+
store = PmaTranscriptStore(hub_root)
|
|
971
|
+
transcript = store.read_transcript(turn_id)
|
|
972
|
+
if not transcript:
|
|
973
|
+
raise HTTPException(status_code=404, detail="Transcript not found")
|
|
974
|
+
return transcript
|
|
975
|
+
|
|
629
976
|
@router.get("/agents")
|
|
630
977
|
def list_pma_agents(request: Request) -> dict[str, Any]:
|
|
631
978
|
pma_config = _get_pma_config(request)
|
|
@@ -969,6 +1316,10 @@ def build_pma_routes() -> APIRouter:
|
|
|
969
1316
|
)
|
|
970
1317
|
|
|
971
1318
|
hub_root = request.app.state.config.root
|
|
1319
|
+
try:
|
|
1320
|
+
PmaActiveSinkStore(hub_root).set_web()
|
|
1321
|
+
except Exception:
|
|
1322
|
+
logger.exception("Failed to update PMA active sink for web")
|
|
972
1323
|
queue = _get_pma_queue(request)
|
|
973
1324
|
|
|
974
1325
|
lane_id = "pma:default"
|
|
@@ -1045,8 +1396,7 @@ def build_pma_routes() -> APIRouter:
|
|
|
1045
1396
|
if result.status != "ok":
|
|
1046
1397
|
raise HTTPException(status_code=500, detail=result.error)
|
|
1047
1398
|
|
|
1048
|
-
|
|
1049
|
-
lane_cancel_events[lane_id].set()
|
|
1399
|
+
await _stop_lane_worker(lane_id)
|
|
1050
1400
|
|
|
1051
1401
|
await _interrupt_active(request, reason="Lane stopped", source="user_request")
|
|
1052
1402
|
|
|
@@ -1464,11 +1814,18 @@ def build_pma_routes() -> APIRouter:
|
|
|
1464
1814
|
reset = bool(body.get("reset", False))
|
|
1465
1815
|
|
|
1466
1816
|
pma_dir = hub_root / ".codex-autorunner" / "pma"
|
|
1467
|
-
|
|
1468
|
-
|
|
1817
|
+
docs_dir = _pma_docs_dir(hub_root)
|
|
1818
|
+
docs_dir.mkdir(parents=True, exist_ok=True)
|
|
1819
|
+
active_context_path = docs_dir / "active_context.md"
|
|
1820
|
+
context_log_path = docs_dir / "context_log.md"
|
|
1821
|
+
legacy_active_context_path = pma_dir / "active_context.md"
|
|
1822
|
+
legacy_context_log_path = pma_dir / "context_log.md"
|
|
1469
1823
|
|
|
1470
1824
|
try:
|
|
1471
|
-
|
|
1825
|
+
if active_context_path.exists():
|
|
1826
|
+
active_content = active_context_path.read_text(encoding="utf-8")
|
|
1827
|
+
else:
|
|
1828
|
+
active_content = legacy_active_context_path.read_text(encoding="utf-8")
|
|
1472
1829
|
except Exception as exc:
|
|
1473
1830
|
raise HTTPException(
|
|
1474
1831
|
status_code=500, detail=f"Failed to read active_context.md: {exc}"
|
|
@@ -1490,6 +1847,9 @@ def build_pma_routes() -> APIRouter:
|
|
|
1490
1847
|
try:
|
|
1491
1848
|
with context_log_path.open("a", encoding="utf-8") as f:
|
|
1492
1849
|
f.write(snapshot_content)
|
|
1850
|
+
if legacy_context_log_path.exists():
|
|
1851
|
+
with legacy_context_log_path.open("a", encoding="utf-8") as f:
|
|
1852
|
+
f.write(snapshot_content)
|
|
1493
1853
|
except Exception as exc:
|
|
1494
1854
|
raise HTTPException(
|
|
1495
1855
|
status_code=500, detail=f"Failed to append context_log.md: {exc}"
|
|
@@ -1498,6 +1858,10 @@ def build_pma_routes() -> APIRouter:
|
|
|
1498
1858
|
if reset:
|
|
1499
1859
|
try:
|
|
1500
1860
|
atomic_write(active_context_path, pma_active_context_content())
|
|
1861
|
+
if legacy_active_context_path.exists():
|
|
1862
|
+
atomic_write(
|
|
1863
|
+
legacy_active_context_path, pma_active_context_content()
|
|
1864
|
+
)
|
|
1501
1865
|
except Exception as exc:
|
|
1502
1866
|
raise HTTPException(
|
|
1503
1867
|
status_code=500, detail=f"Failed to reset active_context.md: {exc}"
|
|
@@ -1539,6 +1903,55 @@ def build_pma_routes() -> APIRouter:
|
|
|
1539
1903
|
"prompt.md": pma_prompt_content,
|
|
1540
1904
|
}
|
|
1541
1905
|
|
|
1906
|
+
def _pma_docs_dir(hub_root: Path) -> Path:
|
|
1907
|
+
return hub_root / ".codex-autorunner" / "pma" / "docs"
|
|
1908
|
+
|
|
1909
|
+
def _pma_legacy_doc_path(hub_root: Path, name: str) -> Path:
|
|
1910
|
+
return hub_root / ".codex-autorunner" / "pma" / name
|
|
1911
|
+
|
|
1912
|
+
def _normalize_doc_name(name: str) -> str:
|
|
1913
|
+
try:
|
|
1914
|
+
return sanitize_filename(name)
|
|
1915
|
+
except ValueError as exc:
|
|
1916
|
+
raise HTTPException(
|
|
1917
|
+
status_code=400, detail=f"Invalid doc name: {name}"
|
|
1918
|
+
) from exc
|
|
1919
|
+
|
|
1920
|
+
def _sorted_doc_names(docs_dir: Path) -> list[str]:
|
|
1921
|
+
names: set[str] = set()
|
|
1922
|
+
if docs_dir.exists():
|
|
1923
|
+
try:
|
|
1924
|
+
for path in docs_dir.iterdir():
|
|
1925
|
+
if not path.is_file():
|
|
1926
|
+
continue
|
|
1927
|
+
if path.name.startswith("."):
|
|
1928
|
+
continue
|
|
1929
|
+
names.add(path.name)
|
|
1930
|
+
except OSError:
|
|
1931
|
+
pass
|
|
1932
|
+
ordered: list[str] = []
|
|
1933
|
+
for doc_name in PMA_DOC_ORDER:
|
|
1934
|
+
if doc_name in names:
|
|
1935
|
+
ordered.append(doc_name)
|
|
1936
|
+
remaining = sorted(name for name in names if name not in ordered)
|
|
1937
|
+
ordered.extend(remaining)
|
|
1938
|
+
return ordered
|
|
1939
|
+
|
|
1940
|
+
def _write_doc_history(
|
|
1941
|
+
hub_root: Path, doc_name: str, content: str
|
|
1942
|
+
) -> Optional[Path]:
|
|
1943
|
+
docs_dir = _pma_docs_dir(hub_root)
|
|
1944
|
+
history_root = docs_dir / "_history" / doc_name
|
|
1945
|
+
try:
|
|
1946
|
+
history_root.mkdir(parents=True, exist_ok=True)
|
|
1947
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
|
1948
|
+
history_path = history_root / f"{timestamp}.md"
|
|
1949
|
+
atomic_write(history_path, content)
|
|
1950
|
+
return history_path
|
|
1951
|
+
except Exception:
|
|
1952
|
+
logger.exception("Failed to write PMA doc history for %s", doc_name)
|
|
1953
|
+
return None
|
|
1954
|
+
|
|
1542
1955
|
@router.get("/docs/default/{name}")
|
|
1543
1956
|
def get_pma_doc_default(name: str, request: Request) -> dict[str, str]:
|
|
1544
1957
|
pma_config = _get_pma_config(request)
|
|
@@ -1557,10 +1970,16 @@ def build_pma_routes() -> APIRouter:
|
|
|
1557
1970
|
if not pma_config.get("enabled", True):
|
|
1558
1971
|
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1559
1972
|
hub_root = request.app.state.config.root
|
|
1560
|
-
|
|
1973
|
+
try:
|
|
1974
|
+
ensure_pma_docs(hub_root)
|
|
1975
|
+
except Exception as exc:
|
|
1976
|
+
raise HTTPException(
|
|
1977
|
+
status_code=500, detail=f"Failed to ensure PMA docs: {exc}"
|
|
1978
|
+
) from exc
|
|
1979
|
+
docs_dir = _pma_docs_dir(hub_root)
|
|
1561
1980
|
result: list[dict[str, Any]] = []
|
|
1562
|
-
for doc_name in
|
|
1563
|
-
doc_path =
|
|
1981
|
+
for doc_name in _sorted_doc_names(docs_dir):
|
|
1982
|
+
doc_path = docs_dir / doc_name
|
|
1564
1983
|
entry: dict[str, Any] = {"name": doc_name}
|
|
1565
1984
|
if doc_path.exists():
|
|
1566
1985
|
entry["exists"] = True
|
|
@@ -1591,13 +2010,16 @@ def build_pma_routes() -> APIRouter:
|
|
|
1591
2010
|
pma_config = _get_pma_config(request)
|
|
1592
2011
|
if not pma_config.get("enabled", True):
|
|
1593
2012
|
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1594
|
-
|
|
1595
|
-
raise HTTPException(status_code=400, detail=f"Unknown doc name: {name}")
|
|
2013
|
+
name = _normalize_doc_name(name)
|
|
1596
2014
|
hub_root = request.app.state.config.root
|
|
1597
|
-
|
|
1598
|
-
doc_path =
|
|
2015
|
+
docs_dir = _pma_docs_dir(hub_root)
|
|
2016
|
+
doc_path = docs_dir / name
|
|
1599
2017
|
if not doc_path.exists():
|
|
1600
|
-
|
|
2018
|
+
legacy_path = _pma_legacy_doc_path(hub_root, name)
|
|
2019
|
+
if legacy_path.exists():
|
|
2020
|
+
doc_path = legacy_path
|
|
2021
|
+
else:
|
|
2022
|
+
raise HTTPException(status_code=404, detail=f"Doc not found: {name}")
|
|
1601
2023
|
try:
|
|
1602
2024
|
content = doc_path.read_text(encoding="utf-8")
|
|
1603
2025
|
except Exception as exc:
|
|
@@ -1613,7 +2035,15 @@ def build_pma_routes() -> APIRouter:
|
|
|
1613
2035
|
pma_config = _get_pma_config(request)
|
|
1614
2036
|
if not pma_config.get("enabled", True):
|
|
1615
2037
|
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
1616
|
-
|
|
2038
|
+
name = _normalize_doc_name(name)
|
|
2039
|
+
hub_root = request.app.state.config.root
|
|
2040
|
+
pma_dir = hub_root / ".codex-autorunner" / "pma"
|
|
2041
|
+
docs_dir = _pma_docs_dir(hub_root)
|
|
2042
|
+
if (
|
|
2043
|
+
name not in PMA_DOC_SET
|
|
2044
|
+
and not (docs_dir / name).exists()
|
|
2045
|
+
and not (pma_dir / name).exists()
|
|
2046
|
+
):
|
|
1617
2047
|
raise HTTPException(status_code=400, detail=f"Unknown doc name: {name}")
|
|
1618
2048
|
content = body.get("content", "")
|
|
1619
2049
|
if not isinstance(content, str):
|
|
@@ -1623,16 +2053,21 @@ def build_pma_routes() -> APIRouter:
|
|
|
1623
2053
|
raise HTTPException(
|
|
1624
2054
|
status_code=413, detail=f"Content too large (max {MAX_DOC_SIZE} bytes)"
|
|
1625
2055
|
)
|
|
1626
|
-
hub_root = request.app.state.config.root
|
|
1627
|
-
pma_dir = hub_root / ".codex-autorunner" / "pma"
|
|
1628
2056
|
pma_dir.mkdir(parents=True, exist_ok=True)
|
|
1629
|
-
|
|
2057
|
+
docs_dir.mkdir(parents=True, exist_ok=True)
|
|
2058
|
+
doc_path = docs_dir / name
|
|
1630
2059
|
try:
|
|
1631
2060
|
atomic_write(doc_path, content)
|
|
1632
2061
|
except Exception as exc:
|
|
1633
2062
|
raise HTTPException(
|
|
1634
2063
|
status_code=500, detail=f"Failed to write doc: {exc}"
|
|
1635
2064
|
) from exc
|
|
2065
|
+
try:
|
|
2066
|
+
if name in PMA_DOC_SET or (pma_dir / name).exists():
|
|
2067
|
+
atomic_write(pma_dir / name, content)
|
|
2068
|
+
except Exception:
|
|
2069
|
+
logger.exception("Failed to update legacy PMA doc %s", name)
|
|
2070
|
+
_write_doc_history(hub_root, name, content)
|
|
1636
2071
|
details = {
|
|
1637
2072
|
"name": name,
|
|
1638
2073
|
"size": len(content.encode("utf-8")),
|
|
@@ -1646,6 +2081,152 @@ def build_pma_routes() -> APIRouter:
|
|
|
1646
2081
|
)
|
|
1647
2082
|
return {"name": name, "status": "ok"}
|
|
1648
2083
|
|
|
2084
|
+
@router.get("/docs/history/{name}")
|
|
2085
|
+
def list_pma_doc_history(
|
|
2086
|
+
name: str, request: Request, limit: int = 50
|
|
2087
|
+
) -> dict[str, Any]:
|
|
2088
|
+
pma_config = _get_pma_config(request)
|
|
2089
|
+
if not pma_config.get("enabled", True):
|
|
2090
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
2091
|
+
name = _normalize_doc_name(name)
|
|
2092
|
+
hub_root = request.app.state.config.root
|
|
2093
|
+
docs_dir = _pma_docs_dir(hub_root)
|
|
2094
|
+
history_dir = docs_dir / "_history" / name
|
|
2095
|
+
entries: list[dict[str, Any]] = []
|
|
2096
|
+
if history_dir.exists():
|
|
2097
|
+
try:
|
|
2098
|
+
for path in sorted(
|
|
2099
|
+
(p for p in history_dir.iterdir() if p.is_file()),
|
|
2100
|
+
key=lambda p: p.name,
|
|
2101
|
+
reverse=True,
|
|
2102
|
+
):
|
|
2103
|
+
if len(entries) >= limit:
|
|
2104
|
+
break
|
|
2105
|
+
try:
|
|
2106
|
+
stat = path.stat()
|
|
2107
|
+
entries.append(
|
|
2108
|
+
{
|
|
2109
|
+
"id": path.name,
|
|
2110
|
+
"size": stat.st_size,
|
|
2111
|
+
"mtime": datetime.fromtimestamp(
|
|
2112
|
+
stat.st_mtime, tz=timezone.utc
|
|
2113
|
+
).isoformat(),
|
|
2114
|
+
}
|
|
2115
|
+
)
|
|
2116
|
+
except OSError:
|
|
2117
|
+
continue
|
|
2118
|
+
except OSError:
|
|
2119
|
+
pass
|
|
2120
|
+
return {"name": name, "entries": entries}
|
|
2121
|
+
|
|
2122
|
+
@router.get("/docs/history/{name}/{version_id}")
|
|
2123
|
+
def get_pma_doc_history(
|
|
2124
|
+
name: str, version_id: str, request: Request
|
|
2125
|
+
) -> dict[str, str]:
|
|
2126
|
+
pma_config = _get_pma_config(request)
|
|
2127
|
+
if not pma_config.get("enabled", True):
|
|
2128
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
2129
|
+
name = _normalize_doc_name(name)
|
|
2130
|
+
version_id = _normalize_doc_name(version_id)
|
|
2131
|
+
hub_root = request.app.state.config.root
|
|
2132
|
+
docs_dir = _pma_docs_dir(hub_root)
|
|
2133
|
+
history_path = docs_dir / "_history" / name / version_id
|
|
2134
|
+
if not history_path.exists():
|
|
2135
|
+
raise HTTPException(status_code=404, detail="History entry not found")
|
|
2136
|
+
try:
|
|
2137
|
+
content = history_path.read_text(encoding="utf-8")
|
|
2138
|
+
except Exception as exc:
|
|
2139
|
+
raise HTTPException(
|
|
2140
|
+
status_code=500, detail=f"Failed to read history entry: {exc}"
|
|
2141
|
+
) from exc
|
|
2142
|
+
return {"name": name, "version_id": version_id, "content": content}
|
|
2143
|
+
|
|
2144
|
+
@router.get("/dispatches")
|
|
2145
|
+
def list_pma_dispatches_endpoint(
|
|
2146
|
+
request: Request, include_resolved: bool = False, limit: int = 100
|
|
2147
|
+
) -> dict[str, Any]:
|
|
2148
|
+
pma_config = _get_pma_config(request)
|
|
2149
|
+
if not pma_config.get("enabled", True):
|
|
2150
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
2151
|
+
hub_root = request.app.state.config.root
|
|
2152
|
+
dispatches = list_pma_dispatches(
|
|
2153
|
+
hub_root, include_resolved=include_resolved, limit=limit
|
|
2154
|
+
)
|
|
2155
|
+
return {
|
|
2156
|
+
"items": [
|
|
2157
|
+
{
|
|
2158
|
+
"id": item.dispatch_id,
|
|
2159
|
+
"title": item.title,
|
|
2160
|
+
"body": item.body,
|
|
2161
|
+
"priority": item.priority,
|
|
2162
|
+
"links": item.links,
|
|
2163
|
+
"created_at": item.created_at,
|
|
2164
|
+
"resolved_at": item.resolved_at,
|
|
2165
|
+
"source_turn_id": item.source_turn_id,
|
|
2166
|
+
}
|
|
2167
|
+
for item in dispatches
|
|
2168
|
+
]
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
@router.get("/dispatches/{dispatch_id}")
|
|
2172
|
+
def get_pma_dispatch(dispatch_id: str, request: Request) -> dict[str, Any]:
|
|
2173
|
+
pma_config = _get_pma_config(request)
|
|
2174
|
+
if not pma_config.get("enabled", True):
|
|
2175
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
2176
|
+
hub_root = request.app.state.config.root
|
|
2177
|
+
path = find_pma_dispatch_path(hub_root, dispatch_id)
|
|
2178
|
+
if not path:
|
|
2179
|
+
raise HTTPException(status_code=404, detail="Dispatch not found")
|
|
2180
|
+
# Use list helper to normalize output
|
|
2181
|
+
items = list_pma_dispatches(hub_root, include_resolved=True)
|
|
2182
|
+
match = next((item for item in items if item.dispatch_id == dispatch_id), None)
|
|
2183
|
+
if not match:
|
|
2184
|
+
raise HTTPException(status_code=404, detail="Dispatch not found")
|
|
2185
|
+
return {
|
|
2186
|
+
"dispatch": {
|
|
2187
|
+
"id": match.dispatch_id,
|
|
2188
|
+
"title": match.title,
|
|
2189
|
+
"body": match.body,
|
|
2190
|
+
"priority": match.priority,
|
|
2191
|
+
"links": match.links,
|
|
2192
|
+
"created_at": match.created_at,
|
|
2193
|
+
"resolved_at": match.resolved_at,
|
|
2194
|
+
"source_turn_id": match.source_turn_id,
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
@router.post("/dispatches/{dispatch_id}/resolve")
|
|
2199
|
+
def resolve_pma_dispatch_endpoint(
|
|
2200
|
+
dispatch_id: str, request: Request
|
|
2201
|
+
) -> dict[str, Any]:
|
|
2202
|
+
pma_config = _get_pma_config(request)
|
|
2203
|
+
if not pma_config.get("enabled", True):
|
|
2204
|
+
raise HTTPException(status_code=404, detail="PMA is disabled")
|
|
2205
|
+
hub_root = request.app.state.config.root
|
|
2206
|
+
path = find_pma_dispatch_path(hub_root, dispatch_id)
|
|
2207
|
+
if not path:
|
|
2208
|
+
raise HTTPException(status_code=404, detail="Dispatch not found")
|
|
2209
|
+
dispatch, errors = resolve_pma_dispatch(path)
|
|
2210
|
+
if errors or dispatch is None:
|
|
2211
|
+
raise HTTPException(
|
|
2212
|
+
status_code=500,
|
|
2213
|
+
detail="Failed to resolve dispatch: " + "; ".join(errors),
|
|
2214
|
+
)
|
|
2215
|
+
return {
|
|
2216
|
+
"dispatch": {
|
|
2217
|
+
"id": dispatch.dispatch_id,
|
|
2218
|
+
"title": dispatch.title,
|
|
2219
|
+
"body": dispatch.body,
|
|
2220
|
+
"priority": dispatch.priority,
|
|
2221
|
+
"links": dispatch.links,
|
|
2222
|
+
"created_at": dispatch.created_at,
|
|
2223
|
+
"resolved_at": dispatch.resolved_at,
|
|
2224
|
+
"source_turn_id": dispatch.source_turn_id,
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
router._pma_start_lane_worker = _ensure_lane_worker_for_app
|
|
2229
|
+
router._pma_stop_lane_worker = _stop_lane_worker_for_app
|
|
1649
2230
|
return router
|
|
1650
2231
|
|
|
1651
2232
|
|