codex-autorunner 0.1.1__py3-none-any.whl → 1.0.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/__main__.py +4 -0
- codex_autorunner/agents/__init__.py +20 -0
- codex_autorunner/agents/base.py +2 -2
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/__init__.py +4 -0
- codex_autorunner/agents/opencode/agent_config.py +104 -0
- codex_autorunner/agents/opencode/client.py +305 -28
- codex_autorunner/agents/opencode/harness.py +71 -20
- codex_autorunner/agents/opencode/logging.py +225 -0
- codex_autorunner/agents/opencode/run_prompt.py +261 -0
- codex_autorunner/agents/opencode/runtime.py +1202 -132
- codex_autorunner/agents/opencode/supervisor.py +194 -68
- codex_autorunner/agents/registry.py +258 -0
- codex_autorunner/agents/types.py +2 -2
- codex_autorunner/api.py +25 -0
- codex_autorunner/bootstrap.py +19 -40
- codex_autorunner/cli.py +234 -151
- codex_autorunner/core/about_car.py +44 -32
- codex_autorunner/core/adapter_utils.py +21 -0
- codex_autorunner/core/app_server_events.py +15 -6
- codex_autorunner/core/app_server_logging.py +55 -15
- codex_autorunner/core/app_server_prompts.py +28 -259
- codex_autorunner/core/app_server_threads.py +15 -26
- codex_autorunner/core/circuit_breaker.py +183 -0
- codex_autorunner/core/codex_runner.py +6 -0
- codex_autorunner/core/config.py +555 -133
- codex_autorunner/core/docs.py +54 -9
- codex_autorunner/core/drafts.py +82 -0
- codex_autorunner/core/engine.py +828 -274
- codex_autorunner/core/exceptions.py +60 -0
- codex_autorunner/core/flows/__init__.py +25 -0
- codex_autorunner/core/flows/controller.py +178 -0
- codex_autorunner/core/flows/definition.py +82 -0
- codex_autorunner/core/flows/models.py +75 -0
- codex_autorunner/core/flows/runtime.py +351 -0
- codex_autorunner/core/flows/store.py +485 -0
- codex_autorunner/core/flows/transition.py +133 -0
- codex_autorunner/core/flows/worker_process.py +242 -0
- codex_autorunner/core/hub.py +21 -13
- codex_autorunner/core/locks.py +118 -1
- codex_autorunner/core/logging_utils.py +9 -6
- codex_autorunner/core/path_utils.py +123 -0
- codex_autorunner/core/prompt.py +15 -7
- codex_autorunner/core/redaction.py +29 -0
- codex_autorunner/core/retry.py +61 -0
- codex_autorunner/core/review.py +888 -0
- codex_autorunner/core/review_context.py +161 -0
- codex_autorunner/core/run_index.py +223 -0
- codex_autorunner/core/runner_controller.py +44 -1
- codex_autorunner/core/runner_process.py +30 -1
- codex_autorunner/core/sqlite_utils.py +32 -0
- codex_autorunner/core/state.py +273 -44
- codex_autorunner/core/static_assets.py +55 -0
- codex_autorunner/core/supervisor_utils.py +67 -0
- codex_autorunner/core/text_delta_coalescer.py +43 -0
- codex_autorunner/core/update.py +20 -11
- codex_autorunner/core/update_runner.py +2 -0
- codex_autorunner/core/usage.py +107 -75
- codex_autorunner/core/utils.py +167 -3
- codex_autorunner/discovery.py +3 -3
- codex_autorunner/flows/ticket_flow/__init__.py +3 -0
- codex_autorunner/flows/ticket_flow/definition.py +91 -0
- codex_autorunner/integrations/agents/__init__.py +27 -0
- codex_autorunner/integrations/agents/agent_backend.py +142 -0
- codex_autorunner/integrations/agents/codex_backend.py +307 -0
- codex_autorunner/integrations/agents/opencode_backend.py +325 -0
- codex_autorunner/integrations/agents/run_event.py +71 -0
- codex_autorunner/integrations/app_server/client.py +708 -153
- codex_autorunner/integrations/app_server/supervisor.py +59 -33
- codex_autorunner/integrations/telegram/adapter.py +474 -185
- codex_autorunner/integrations/telegram/api_schemas.py +120 -0
- codex_autorunner/integrations/telegram/config.py +239 -1
- codex_autorunner/integrations/telegram/constants.py +19 -1
- codex_autorunner/integrations/telegram/dispatch.py +44 -8
- codex_autorunner/integrations/telegram/doctor.py +47 -0
- codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
- codex_autorunner/integrations/telegram/handlers/callbacks.py +15 -1
- codex_autorunner/integrations/telegram/handlers/commands/__init__.py +29 -0
- codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +2595 -0
- codex_autorunner/integrations/telegram/handlers/commands/files.py +1408 -0
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
- codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
- codex_autorunner/integrations/telegram/handlers/commands/github.py +1688 -0
- codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
- codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +954 -5689
- codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +11 -4
- codex_autorunner/integrations/telegram/handlers/messages.py +374 -49
- codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +6 -4
- codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
- codex_autorunner/integrations/telegram/helpers.py +90 -18
- codex_autorunner/integrations/telegram/notifications.py +126 -35
- codex_autorunner/integrations/telegram/outbox.py +214 -43
- codex_autorunner/integrations/telegram/progress_stream.py +42 -19
- codex_autorunner/integrations/telegram/runtime.py +24 -13
- codex_autorunner/integrations/telegram/service.py +500 -129
- codex_autorunner/integrations/telegram/state.py +1278 -330
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
- codex_autorunner/integrations/telegram/transport.py +37 -4
- codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
- codex_autorunner/integrations/telegram/types.py +22 -2
- codex_autorunner/integrations/telegram/voice.py +14 -15
- codex_autorunner/manifest.py +2 -0
- codex_autorunner/plugin_api.py +22 -0
- codex_autorunner/routes/__init__.py +25 -14
- codex_autorunner/routes/agents.py +18 -78
- codex_autorunner/routes/analytics.py +239 -0
- codex_autorunner/routes/base.py +142 -113
- codex_autorunner/routes/file_chat.py +836 -0
- codex_autorunner/routes/flows.py +980 -0
- codex_autorunner/routes/messages.py +459 -0
- codex_autorunner/routes/repos.py +17 -0
- codex_autorunner/routes/review.py +148 -0
- codex_autorunner/routes/sessions.py +16 -8
- codex_autorunner/routes/settings.py +22 -0
- codex_autorunner/routes/shared.py +33 -3
- codex_autorunner/routes/system.py +22 -1
- codex_autorunner/routes/usage.py +87 -0
- codex_autorunner/routes/voice.py +5 -13
- codex_autorunner/routes/workspace.py +271 -0
- codex_autorunner/server.py +2 -1
- codex_autorunner/static/agentControls.js +9 -1
- codex_autorunner/static/agentEvents.js +248 -0
- codex_autorunner/static/app.js +27 -22
- codex_autorunner/static/autoRefresh.js +29 -1
- codex_autorunner/static/bootstrap.js +1 -0
- codex_autorunner/static/bus.js +1 -0
- codex_autorunner/static/cache.js +1 -0
- codex_autorunner/static/constants.js +20 -4
- codex_autorunner/static/dashboard.js +162 -150
- codex_autorunner/static/diffRenderer.js +37 -0
- codex_autorunner/static/docChatCore.js +324 -0
- codex_autorunner/static/docChatStorage.js +65 -0
- codex_autorunner/static/docChatVoice.js +65 -0
- codex_autorunner/static/docEditor.js +133 -0
- codex_autorunner/static/env.js +1 -0
- codex_autorunner/static/eventSummarizer.js +166 -0
- codex_autorunner/static/fileChat.js +182 -0
- codex_autorunner/static/health.js +155 -0
- codex_autorunner/static/hub.js +67 -126
- codex_autorunner/static/index.html +788 -807
- codex_autorunner/static/liveUpdates.js +59 -0
- codex_autorunner/static/loader.js +1 -0
- codex_autorunner/static/messages.js +470 -0
- codex_autorunner/static/mobileCompact.js +2 -1
- codex_autorunner/static/settings.js +24 -205
- codex_autorunner/static/styles.css +7577 -3758
- codex_autorunner/static/tabs.js +28 -5
- codex_autorunner/static/terminal.js +14 -0
- codex_autorunner/static/terminalManager.js +53 -59
- codex_autorunner/static/ticketChatActions.js +333 -0
- codex_autorunner/static/ticketChatEvents.js +16 -0
- codex_autorunner/static/ticketChatStorage.js +16 -0
- codex_autorunner/static/ticketChatStream.js +264 -0
- codex_autorunner/static/ticketEditor.js +750 -0
- codex_autorunner/static/ticketVoice.js +9 -0
- codex_autorunner/static/tickets.js +1315 -0
- codex_autorunner/static/utils.js +32 -3
- codex_autorunner/static/voice.js +21 -7
- codex_autorunner/static/workspace.js +672 -0
- codex_autorunner/static/workspaceApi.js +53 -0
- codex_autorunner/static/workspaceFileBrowser.js +504 -0
- codex_autorunner/tickets/__init__.py +20 -0
- codex_autorunner/tickets/agent_pool.py +377 -0
- codex_autorunner/tickets/files.py +85 -0
- codex_autorunner/tickets/frontmatter.py +55 -0
- codex_autorunner/tickets/lint.py +102 -0
- codex_autorunner/tickets/models.py +95 -0
- codex_autorunner/tickets/outbox.py +232 -0
- codex_autorunner/tickets/replies.py +179 -0
- codex_autorunner/tickets/runner.py +823 -0
- codex_autorunner/tickets/spec_ingest.py +77 -0
- codex_autorunner/voice/capture.py +7 -7
- codex_autorunner/voice/service.py +51 -9
- codex_autorunner/web/app.py +419 -199
- codex_autorunner/web/hub_jobs.py +13 -2
- codex_autorunner/web/middleware.py +47 -13
- codex_autorunner/web/pty_session.py +26 -13
- codex_autorunner/web/schemas.py +114 -109
- codex_autorunner/web/static_assets.py +55 -42
- codex_autorunner/web/static_refresh.py +86 -0
- codex_autorunner/workspace/__init__.py +40 -0
- codex_autorunner/workspace/paths.py +319 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +20 -21
- codex_autorunner-1.0.0.dist-info/RECORD +251 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
- codex_autorunner/core/doc_chat.py +0 -1415
- codex_autorunner/core/snapshot.py +0 -580
- codex_autorunner/integrations/github/chatops.py +0 -268
- codex_autorunner/integrations/github/pr_flow.py +0 -1314
- codex_autorunner/routes/docs.py +0 -381
- codex_autorunner/routes/github.py +0 -327
- codex_autorunner/routes/runs.py +0 -118
- codex_autorunner/spec_ingest.py +0 -788
- codex_autorunner/static/docChatActions.js +0 -279
- codex_autorunner/static/docChatEvents.js +0 -300
- codex_autorunner/static/docChatRender.js +0 -205
- codex_autorunner/static/docChatStream.js +0 -361
- codex_autorunner/static/docs.js +0 -20
- codex_autorunner/static/docsClipboard.js +0 -69
- codex_autorunner/static/docsCrud.js +0 -257
- codex_autorunner/static/docsDocUpdates.js +0 -62
- codex_autorunner/static/docsDrafts.js +0 -16
- codex_autorunner/static/docsElements.js +0 -69
- codex_autorunner/static/docsInit.js +0 -274
- codex_autorunner/static/docsParse.js +0 -160
- codex_autorunner/static/docsSnapshot.js +0 -87
- codex_autorunner/static/docsSpecIngest.js +0 -263
- codex_autorunner/static/docsState.js +0 -127
- codex_autorunner/static/docsThreadRegistry.js +0 -44
- codex_autorunner/static/docsUi.js +0 -153
- codex_autorunner/static/docsVoice.js +0 -56
- codex_autorunner/static/github.js +0 -442
- codex_autorunner/static/logs.js +0 -640
- codex_autorunner/static/runs.js +0 -409
- codex_autorunner/static/snapshot.js +0 -124
- codex_autorunner/static/state.js +0 -86
- codex_autorunner/static/todoPreview.js +0 -27
- codex_autorunner/workspace.py +0 -16
- codex_autorunner-0.1.1.dist-info/RECORD +0 -191
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""Inbox endpoints for agent dispatches and human replies.
|
|
2
|
+
|
|
3
|
+
These endpoints provide a thin wrapper over the durable on-disk ticket_flow
|
|
4
|
+
dispatch history (agent -> human) and reply history (human -> agent).
|
|
5
|
+
|
|
6
|
+
Domain terminology:
|
|
7
|
+
- Dispatch: Agent-to-human communication (mode: "notify" for FYI, "pause" for handoff)
|
|
8
|
+
- Reply: Human-to-agent response
|
|
9
|
+
- Handoff: A dispatch with mode="pause" that requires human action
|
|
10
|
+
|
|
11
|
+
The UI contract is intentionally filesystem-backed:
|
|
12
|
+
* Dispatches come from `.codex-autorunner/runs/<run_id>/dispatch_history/<seq>/`.
|
|
13
|
+
* Human replies are written to USER_REPLY.md + reply/* and immediately archived
|
|
14
|
+
into `.codex-autorunner/runs/<run_id>/reply_history/<seq>/`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Optional
|
|
25
|
+
from urllib.parse import quote
|
|
26
|
+
|
|
27
|
+
import yaml
|
|
28
|
+
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
|
29
|
+
|
|
30
|
+
from ..core.flows.models import FlowRunRecord, FlowRunStatus
|
|
31
|
+
from ..core.flows.store import FlowStore
|
|
32
|
+
from ..core.utils import find_repo_root
|
|
33
|
+
from ..tickets.files import safe_relpath
|
|
34
|
+
from ..tickets.outbox import parse_dispatch, resolve_outbox_paths
|
|
35
|
+
from ..tickets.replies import (
|
|
36
|
+
dispatch_reply,
|
|
37
|
+
ensure_reply_dirs,
|
|
38
|
+
next_reply_seq,
|
|
39
|
+
parse_user_reply,
|
|
40
|
+
resolve_reply_paths,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
_logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _flows_db_path(repo_root: Path) -> Path:
|
|
47
|
+
return repo_root / ".codex-autorunner" / "flows.db"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _load_store_or_404(db_path: Path) -> FlowStore:
|
|
51
|
+
store = FlowStore(db_path)
|
|
52
|
+
try:
|
|
53
|
+
store.initialize()
|
|
54
|
+
return store
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
raise HTTPException(
|
|
57
|
+
status_code=404, detail="Flows database unavailable"
|
|
58
|
+
) from exc
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _timestamp(path: Path) -> Optional[str]:
|
|
62
|
+
try:
|
|
63
|
+
return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).isoformat()
|
|
64
|
+
except OSError:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _safe_attachment_name(name: str) -> str:
|
|
69
|
+
base = os.path.basename(name or "").strip()
|
|
70
|
+
if not base:
|
|
71
|
+
raise ValueError("Missing attachment filename")
|
|
72
|
+
if base.lower() == "user_reply.md":
|
|
73
|
+
raise ValueError("Attachment filename reserved: USER_REPLY.md")
|
|
74
|
+
if not re.fullmatch(r"[A-Za-z0-9._-]+", base):
|
|
75
|
+
raise ValueError(
|
|
76
|
+
"Invalid attachment filename; use only letters, digits, dot, underscore, dash"
|
|
77
|
+
)
|
|
78
|
+
return base
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _iter_seq_dirs(history_dir: Path) -> list[tuple[int, Path]]:
|
|
82
|
+
if not history_dir.exists() or not history_dir.is_dir():
|
|
83
|
+
return []
|
|
84
|
+
out: list[tuple[int, Path]] = []
|
|
85
|
+
try:
|
|
86
|
+
for child in history_dir.iterdir():
|
|
87
|
+
try:
|
|
88
|
+
if not child.is_dir():
|
|
89
|
+
continue
|
|
90
|
+
name = child.name
|
|
91
|
+
if not (len(name) == 4 and name.isdigit()):
|
|
92
|
+
continue
|
|
93
|
+
out.append((int(name), child))
|
|
94
|
+
except OSError:
|
|
95
|
+
continue
|
|
96
|
+
except OSError:
|
|
97
|
+
return []
|
|
98
|
+
out.sort(key=lambda x: x[0])
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _collect_dispatch_history(
|
|
103
|
+
*, repo_root: Path, run_id: str, record_input: dict[str, Any]
|
|
104
|
+
) -> list[dict[str, Any]]:
|
|
105
|
+
"""Collect all dispatches from the dispatch history directory."""
|
|
106
|
+
workspace_root = Path(record_input.get("workspace_root") or repo_root)
|
|
107
|
+
runs_dir = Path(record_input.get("runs_dir") or ".codex-autorunner/runs")
|
|
108
|
+
outbox_paths = resolve_outbox_paths(
|
|
109
|
+
workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
|
|
110
|
+
)
|
|
111
|
+
history: list[dict[str, Any]] = []
|
|
112
|
+
for seq, entry_dir in reversed(_iter_seq_dirs(outbox_paths.dispatch_history_dir)):
|
|
113
|
+
dispatch_path = entry_dir / "DISPATCH.md"
|
|
114
|
+
dispatch, errors = parse_dispatch(dispatch_path)
|
|
115
|
+
files: list[dict[str, str]] = []
|
|
116
|
+
try:
|
|
117
|
+
for child in sorted(entry_dir.iterdir(), key=lambda p: p.name):
|
|
118
|
+
try:
|
|
119
|
+
if child.name.startswith("."):
|
|
120
|
+
continue
|
|
121
|
+
if child.name == "DISPATCH.md":
|
|
122
|
+
continue
|
|
123
|
+
if child.is_dir():
|
|
124
|
+
continue
|
|
125
|
+
rel = child.name
|
|
126
|
+
url = f"api/flows/{run_id}/dispatch_history/{seq:04d}/{quote(rel)}"
|
|
127
|
+
size = None
|
|
128
|
+
try:
|
|
129
|
+
size = child.stat().st_size
|
|
130
|
+
except OSError:
|
|
131
|
+
size = None
|
|
132
|
+
files.append({"name": child.name, "url": url, "size": size})
|
|
133
|
+
except OSError:
|
|
134
|
+
continue
|
|
135
|
+
except OSError:
|
|
136
|
+
files = []
|
|
137
|
+
created_at = _timestamp(dispatch_path) or _timestamp(entry_dir)
|
|
138
|
+
history.append(
|
|
139
|
+
{
|
|
140
|
+
"seq": seq,
|
|
141
|
+
"dir": safe_relpath(entry_dir, workspace_root),
|
|
142
|
+
"created_at": created_at,
|
|
143
|
+
"dispatch": (
|
|
144
|
+
{
|
|
145
|
+
"mode": dispatch.mode,
|
|
146
|
+
"title": dispatch.title,
|
|
147
|
+
"body": dispatch.body,
|
|
148
|
+
"extra": dispatch.extra,
|
|
149
|
+
"is_handoff": dispatch.is_handoff,
|
|
150
|
+
}
|
|
151
|
+
if dispatch
|
|
152
|
+
else None
|
|
153
|
+
),
|
|
154
|
+
"errors": errors,
|
|
155
|
+
"files": files,
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
return history
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _collect_reply_history(
|
|
162
|
+
*, repo_root: Path, run_id: str, record_input: dict[str, Any]
|
|
163
|
+
):
|
|
164
|
+
workspace_root = Path(record_input.get("workspace_root") or repo_root)
|
|
165
|
+
runs_dir = Path(record_input.get("runs_dir") or ".codex-autorunner/runs")
|
|
166
|
+
reply_paths = resolve_reply_paths(
|
|
167
|
+
workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
|
|
168
|
+
)
|
|
169
|
+
history: list[dict[str, Any]] = []
|
|
170
|
+
for seq, entry_dir in reversed(_iter_seq_dirs(reply_paths.reply_history_dir)):
|
|
171
|
+
reply_path = entry_dir / "USER_REPLY.md"
|
|
172
|
+
reply, errors = (
|
|
173
|
+
parse_user_reply(reply_path)
|
|
174
|
+
if reply_path.exists()
|
|
175
|
+
else (None, ["USER_REPLY.md missing"])
|
|
176
|
+
)
|
|
177
|
+
files: list[dict[str, str]] = []
|
|
178
|
+
try:
|
|
179
|
+
for child in sorted(entry_dir.iterdir(), key=lambda p: p.name):
|
|
180
|
+
try:
|
|
181
|
+
if child.name.startswith("."):
|
|
182
|
+
continue
|
|
183
|
+
if child.name == "USER_REPLY.md":
|
|
184
|
+
continue
|
|
185
|
+
if child.is_dir():
|
|
186
|
+
continue
|
|
187
|
+
rel = child.name
|
|
188
|
+
url = f"api/flows/{run_id}/reply_history/{seq:04d}/{quote(rel)}"
|
|
189
|
+
size = None
|
|
190
|
+
try:
|
|
191
|
+
size = child.stat().st_size
|
|
192
|
+
except OSError:
|
|
193
|
+
size = None
|
|
194
|
+
files.append({"name": child.name, "url": url, "size": size})
|
|
195
|
+
except OSError:
|
|
196
|
+
continue
|
|
197
|
+
except OSError:
|
|
198
|
+
files = []
|
|
199
|
+
created_at = _timestamp(reply_path) or _timestamp(entry_dir)
|
|
200
|
+
history.append(
|
|
201
|
+
{
|
|
202
|
+
"seq": seq,
|
|
203
|
+
"dir": safe_relpath(entry_dir, workspace_root),
|
|
204
|
+
"created_at": created_at,
|
|
205
|
+
"reply": (
|
|
206
|
+
{"title": reply.title, "body": reply.body, "extra": reply.extra}
|
|
207
|
+
if reply
|
|
208
|
+
else None
|
|
209
|
+
),
|
|
210
|
+
"errors": errors,
|
|
211
|
+
"files": files,
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
return history
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _ticket_state_snapshot(record: FlowRunRecord) -> dict[str, Any]:
|
|
218
|
+
state = record.state if isinstance(record.state, dict) else {}
|
|
219
|
+
ticket_state = state.get("ticket_engine") if isinstance(state, dict) else {}
|
|
220
|
+
if not isinstance(ticket_state, dict):
|
|
221
|
+
ticket_state = {}
|
|
222
|
+
allowed_keys = {
|
|
223
|
+
"current_ticket",
|
|
224
|
+
"total_turns",
|
|
225
|
+
"ticket_turns",
|
|
226
|
+
"dispatch_seq",
|
|
227
|
+
"reply_seq",
|
|
228
|
+
"reason",
|
|
229
|
+
"status",
|
|
230
|
+
}
|
|
231
|
+
return {k: ticket_state.get(k) for k in allowed_keys if k in ticket_state}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def build_messages_routes() -> APIRouter:
|
|
235
|
+
router = APIRouter()
|
|
236
|
+
|
|
237
|
+
@router.get("/api/messages/active")
|
|
238
|
+
def get_active_message(request: Request):
|
|
239
|
+
repo_root = find_repo_root()
|
|
240
|
+
db_path = _flows_db_path(repo_root)
|
|
241
|
+
if not db_path.exists():
|
|
242
|
+
return {"active": False}
|
|
243
|
+
store = FlowStore(db_path)
|
|
244
|
+
try:
|
|
245
|
+
store.initialize()
|
|
246
|
+
except Exception:
|
|
247
|
+
# Corrupt flows db should not 500 the UI.
|
|
248
|
+
return {"active": False}
|
|
249
|
+
|
|
250
|
+
paused = store.list_flow_runs(
|
|
251
|
+
flow_type="ticket_flow", status=FlowRunStatus.PAUSED
|
|
252
|
+
)
|
|
253
|
+
if not paused:
|
|
254
|
+
return {"active": False}
|
|
255
|
+
|
|
256
|
+
# Walk paused runs (newest first as returned by FlowStore) until we find
|
|
257
|
+
# one with at least one archived dispatch. This avoids hiding
|
|
258
|
+
# older paused runs that do have history when the newest paused run
|
|
259
|
+
# hasn't yet written DISPATCH.md.
|
|
260
|
+
for record in paused:
|
|
261
|
+
history = _collect_dispatch_history(
|
|
262
|
+
repo_root=repo_root,
|
|
263
|
+
run_id=str(record.id),
|
|
264
|
+
record_input=dict(record.input_data or {}),
|
|
265
|
+
)
|
|
266
|
+
if not history:
|
|
267
|
+
continue
|
|
268
|
+
latest = history[0]
|
|
269
|
+
return {
|
|
270
|
+
"active": True,
|
|
271
|
+
"run_id": record.id,
|
|
272
|
+
"flow_type": record.flow_type,
|
|
273
|
+
"status": record.status.value,
|
|
274
|
+
"seq": latest.get("seq"),
|
|
275
|
+
"dispatch": latest.get("dispatch"),
|
|
276
|
+
"files": latest.get("files"),
|
|
277
|
+
"open_url": f"?tab=inbox&run_id={record.id}",
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {"active": False}
|
|
281
|
+
|
|
282
|
+
@router.get("/api/messages/threads")
|
|
283
|
+
def list_threads():
|
|
284
|
+
repo_root = find_repo_root()
|
|
285
|
+
db_path = _flows_db_path(repo_root)
|
|
286
|
+
if not db_path.exists():
|
|
287
|
+
return {"conversations": []}
|
|
288
|
+
store = FlowStore(db_path)
|
|
289
|
+
try:
|
|
290
|
+
store.initialize()
|
|
291
|
+
except Exception:
|
|
292
|
+
return {"conversations": []}
|
|
293
|
+
runs = store.list_flow_runs(flow_type="ticket_flow")
|
|
294
|
+
conversations: list[dict[str, Any]] = []
|
|
295
|
+
for record in runs:
|
|
296
|
+
record_input = dict(record.input_data or {})
|
|
297
|
+
dispatch_history = _collect_dispatch_history(
|
|
298
|
+
repo_root=repo_root,
|
|
299
|
+
run_id=str(record.id),
|
|
300
|
+
record_input=record_input,
|
|
301
|
+
)
|
|
302
|
+
if not dispatch_history:
|
|
303
|
+
continue
|
|
304
|
+
latest = dispatch_history[0]
|
|
305
|
+
reply_history = _collect_reply_history(
|
|
306
|
+
repo_root=repo_root,
|
|
307
|
+
run_id=str(record.id),
|
|
308
|
+
record_input=record_input,
|
|
309
|
+
)
|
|
310
|
+
conversations.append(
|
|
311
|
+
{
|
|
312
|
+
"run_id": record.id,
|
|
313
|
+
"flow_type": record.flow_type,
|
|
314
|
+
"status": record.status.value,
|
|
315
|
+
"created_at": record.created_at,
|
|
316
|
+
"started_at": record.started_at,
|
|
317
|
+
"finished_at": record.finished_at,
|
|
318
|
+
"current_step": record.current_step,
|
|
319
|
+
"latest": latest,
|
|
320
|
+
"dispatch_count": len(dispatch_history),
|
|
321
|
+
"reply_count": len(reply_history),
|
|
322
|
+
"ticket_state": _ticket_state_snapshot(record),
|
|
323
|
+
"open_url": f"?tab=inbox&run_id={record.id}",
|
|
324
|
+
}
|
|
325
|
+
)
|
|
326
|
+
return {"conversations": conversations}
|
|
327
|
+
|
|
328
|
+
@router.get("/api/messages/threads/{run_id}")
|
|
329
|
+
def get_thread(run_id: str):
|
|
330
|
+
repo_root = find_repo_root()
|
|
331
|
+
db_path = _flows_db_path(repo_root)
|
|
332
|
+
empty_response = {
|
|
333
|
+
"dispatch_history": [],
|
|
334
|
+
"reply_history": [],
|
|
335
|
+
"dispatch_count": 0,
|
|
336
|
+
"reply_count": 0,
|
|
337
|
+
}
|
|
338
|
+
if not db_path.exists():
|
|
339
|
+
return empty_response
|
|
340
|
+
store = _load_store_or_404(db_path)
|
|
341
|
+
try:
|
|
342
|
+
record = store.get_flow_run(run_id)
|
|
343
|
+
finally:
|
|
344
|
+
try:
|
|
345
|
+
store.close()
|
|
346
|
+
except Exception:
|
|
347
|
+
pass
|
|
348
|
+
if not record:
|
|
349
|
+
return empty_response
|
|
350
|
+
input_data = dict(record.input_data or {})
|
|
351
|
+
dispatch_history = _collect_dispatch_history(
|
|
352
|
+
repo_root=repo_root, run_id=run_id, record_input=input_data
|
|
353
|
+
)
|
|
354
|
+
reply_history = _collect_reply_history(
|
|
355
|
+
repo_root=repo_root, run_id=run_id, record_input=input_data
|
|
356
|
+
)
|
|
357
|
+
return {
|
|
358
|
+
"run": {
|
|
359
|
+
"id": record.id,
|
|
360
|
+
"flow_type": record.flow_type,
|
|
361
|
+
"status": record.status.value,
|
|
362
|
+
"created_at": record.created_at,
|
|
363
|
+
"started_at": record.started_at,
|
|
364
|
+
"finished_at": record.finished_at,
|
|
365
|
+
"current_step": record.current_step,
|
|
366
|
+
"error_message": record.error_message,
|
|
367
|
+
},
|
|
368
|
+
"dispatch_history": dispatch_history,
|
|
369
|
+
"reply_history": reply_history,
|
|
370
|
+
"dispatch_count": len(dispatch_history),
|
|
371
|
+
"reply_count": len(reply_history),
|
|
372
|
+
"ticket_state": _ticket_state_snapshot(record),
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
@router.post("/api/messages/{run_id}/reply")
|
|
376
|
+
async def post_reply(
|
|
377
|
+
run_id: str,
|
|
378
|
+
body: str = Form(""),
|
|
379
|
+
title: Optional[str] = Form(None),
|
|
380
|
+
# NOTE: FastAPI/starlette will supply either a single UploadFile or a list
|
|
381
|
+
# depending on how the multipart form is encoded. Declaring this as a
|
|
382
|
+
# concrete list avoids a common 422 where a single file upload is treated
|
|
383
|
+
# as a non-list value.
|
|
384
|
+
files: list[UploadFile] = File(default=[]), # noqa: B006,B008
|
|
385
|
+
):
|
|
386
|
+
repo_root = find_repo_root()
|
|
387
|
+
db_path = _flows_db_path(repo_root)
|
|
388
|
+
if not db_path.exists():
|
|
389
|
+
raise HTTPException(status_code=404, detail="No flows database")
|
|
390
|
+
store = _load_store_or_404(db_path)
|
|
391
|
+
try:
|
|
392
|
+
record = store.get_flow_run(run_id)
|
|
393
|
+
finally:
|
|
394
|
+
try:
|
|
395
|
+
store.close()
|
|
396
|
+
except Exception:
|
|
397
|
+
pass
|
|
398
|
+
if not record:
|
|
399
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
400
|
+
|
|
401
|
+
input_data = dict(record.input_data or {})
|
|
402
|
+
workspace_root = Path(input_data.get("workspace_root") or repo_root)
|
|
403
|
+
runs_dir = Path(input_data.get("runs_dir") or ".codex-autorunner/runs")
|
|
404
|
+
reply_paths = resolve_reply_paths(
|
|
405
|
+
workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
|
|
406
|
+
)
|
|
407
|
+
ensure_reply_dirs(reply_paths)
|
|
408
|
+
|
|
409
|
+
cleaned_title = (
|
|
410
|
+
title.strip() if isinstance(title, str) and title.strip() else None
|
|
411
|
+
)
|
|
412
|
+
cleaned_body = body or ""
|
|
413
|
+
|
|
414
|
+
if cleaned_title:
|
|
415
|
+
fm = yaml.safe_dump({"title": cleaned_title}, sort_keys=False).strip()
|
|
416
|
+
raw = f"---\n{fm}\n---\n\n{cleaned_body}\n"
|
|
417
|
+
else:
|
|
418
|
+
raw = cleaned_body
|
|
419
|
+
if raw and not raw.endswith("\n"):
|
|
420
|
+
raw += "\n"
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
reply_paths.user_reply_path.parent.mkdir(parents=True, exist_ok=True)
|
|
424
|
+
reply_paths.user_reply_path.write_text(raw, encoding="utf-8")
|
|
425
|
+
except OSError as exc:
|
|
426
|
+
raise HTTPException(
|
|
427
|
+
status_code=500, detail=f"Failed to write USER_REPLY.md: {exc}"
|
|
428
|
+
) from exc
|
|
429
|
+
|
|
430
|
+
for upload in files:
|
|
431
|
+
try:
|
|
432
|
+
filename = _safe_attachment_name(upload.filename or "")
|
|
433
|
+
except ValueError as exc:
|
|
434
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
435
|
+
dest = reply_paths.reply_dir / filename
|
|
436
|
+
data = await upload.read()
|
|
437
|
+
try:
|
|
438
|
+
dest.write_bytes(data)
|
|
439
|
+
except OSError as exc:
|
|
440
|
+
raise HTTPException(
|
|
441
|
+
status_code=500, detail=f"Failed to write attachment: {exc}"
|
|
442
|
+
) from exc
|
|
443
|
+
|
|
444
|
+
seq = next_reply_seq(reply_paths.reply_history_dir)
|
|
445
|
+
dispatch, errors = dispatch_reply(reply_paths, next_seq=seq)
|
|
446
|
+
if errors:
|
|
447
|
+
raise HTTPException(status_code=400, detail=errors)
|
|
448
|
+
if dispatch is None:
|
|
449
|
+
raise HTTPException(status_code=500, detail="Failed to archive reply")
|
|
450
|
+
return {
|
|
451
|
+
"status": "ok",
|
|
452
|
+
"seq": dispatch.seq,
|
|
453
|
+
"reply": {"title": dispatch.reply.title, "body": dispatch.reply.body},
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return router
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
__all__ = ["build_messages_routes"]
|
codex_autorunner/routes/repos.py
CHANGED
|
@@ -123,6 +123,23 @@ def build_repos_routes() -> APIRouter:
|
|
|
123
123
|
)
|
|
124
124
|
save_state(engine.state_path, new_state)
|
|
125
125
|
clear_stale_lock(engine.lock_path)
|
|
126
|
+
engine.reconcile_run_index()
|
|
127
|
+
return {"running": manager.running}
|
|
128
|
+
|
|
129
|
+
@router.post("/api/run/clear-lock", response_model=RunStatusResponse)
|
|
130
|
+
def clear_lock(request: Request):
|
|
131
|
+
manager = request.app.state.manager
|
|
132
|
+
logger = request.app.state.logger
|
|
133
|
+
try:
|
|
134
|
+
logger.info("run/clear-lock requested")
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
assessment = manager.clear_freeable_lock()
|
|
138
|
+
if not assessment.freeable:
|
|
139
|
+
detail = "Lock is still active; cannot clear."
|
|
140
|
+
if assessment.pid:
|
|
141
|
+
detail = f"Lock pid {assessment.pid} is still active; cannot clear."
|
|
142
|
+
raise HTTPException(status_code=409, detail=detail)
|
|
126
143
|
return {"running": manager.running}
|
|
127
144
|
|
|
128
145
|
@router.post("/api/run/resume", response_model=RunControlResponse)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Review workflow routes.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Query, Request
|
|
8
|
+
from fastapi.responses import FileResponse
|
|
9
|
+
|
|
10
|
+
from ..core.review import ReviewBusyError, ReviewError, ReviewService
|
|
11
|
+
from ..web.schemas import (
|
|
12
|
+
ReviewControlResponse,
|
|
13
|
+
ReviewStartRequest,
|
|
14
|
+
ReviewStatusResponse,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _review(request: Request) -> ReviewService:
|
|
19
|
+
"""Get a ReviewService instance from request."""
|
|
20
|
+
manager = getattr(request.app.state, "review_manager", None)
|
|
21
|
+
if manager is None:
|
|
22
|
+
engine = request.app.state.engine
|
|
23
|
+
manager = ReviewService(
|
|
24
|
+
engine,
|
|
25
|
+
app_server_supervisor=getattr(
|
|
26
|
+
request.app.state, "app_server_supervisor", None
|
|
27
|
+
),
|
|
28
|
+
opencode_supervisor=getattr(request.app.state, "opencode_supervisor", None),
|
|
29
|
+
logger=getattr(request.app.state, "logger", None),
|
|
30
|
+
)
|
|
31
|
+
request.app.state.review_manager = manager
|
|
32
|
+
return manager
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_review_routes() -> APIRouter:
|
|
36
|
+
"""Build routes for review workflow."""
|
|
37
|
+
router = APIRouter()
|
|
38
|
+
|
|
39
|
+
@router.get("/api/review/status")
|
|
40
|
+
async def review_status(request: Request):
|
|
41
|
+
try:
|
|
42
|
+
service = _review(request)
|
|
43
|
+
status = service.status()
|
|
44
|
+
return ReviewStatusResponse(review=status)
|
|
45
|
+
except ReviewError as exc:
|
|
46
|
+
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
49
|
+
|
|
50
|
+
@router.post("/api/review/start")
|
|
51
|
+
async def review_start(request: Request, payload: ReviewStartRequest):
|
|
52
|
+
try:
|
|
53
|
+
service = _review(request)
|
|
54
|
+
state = service.start(payload=payload.model_dump(exclude_none=True))
|
|
55
|
+
return ReviewControlResponse(
|
|
56
|
+
status=state.get("status", "unknown"),
|
|
57
|
+
detail="Review started",
|
|
58
|
+
)
|
|
59
|
+
except ReviewBusyError as exc:
|
|
60
|
+
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
|
|
61
|
+
except ReviewError as exc:
|
|
62
|
+
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
|
|
63
|
+
except Exception as exc:
|
|
64
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
65
|
+
|
|
66
|
+
@router.post("/api/review/stop")
|
|
67
|
+
async def review_stop(request: Request):
|
|
68
|
+
try:
|
|
69
|
+
service = _review(request)
|
|
70
|
+
state = service.stop()
|
|
71
|
+
return ReviewControlResponse(
|
|
72
|
+
status=state.get("status", "unknown"),
|
|
73
|
+
detail="Review stopped",
|
|
74
|
+
)
|
|
75
|
+
except ReviewError as exc:
|
|
76
|
+
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
79
|
+
|
|
80
|
+
@router.post("/api/review/reset")
|
|
81
|
+
async def review_reset(request: Request):
|
|
82
|
+
try:
|
|
83
|
+
service = _review(request)
|
|
84
|
+
state = service.reset()
|
|
85
|
+
return ReviewControlResponse(
|
|
86
|
+
status=state.get("status", "idle"),
|
|
87
|
+
detail="Review state reset",
|
|
88
|
+
)
|
|
89
|
+
except ReviewBusyError as exc:
|
|
90
|
+
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
|
|
91
|
+
except ReviewError as exc:
|
|
92
|
+
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
95
|
+
|
|
96
|
+
@router.get("/api/review/artifact")
|
|
97
|
+
async def review_artifact(
|
|
98
|
+
request: Request,
|
|
99
|
+
kind: str = Query(
|
|
100
|
+
..., description="final_report|workflow_log|scratchpad_bundle"
|
|
101
|
+
),
|
|
102
|
+
):
|
|
103
|
+
try:
|
|
104
|
+
service = _review(request)
|
|
105
|
+
status = service.status()
|
|
106
|
+
|
|
107
|
+
mapping = {
|
|
108
|
+
"final_report": status.get("final_output_path"),
|
|
109
|
+
"workflow_log": status.get("run_dir"),
|
|
110
|
+
"scratchpad_bundle": status.get("scratchpad_bundle_path"),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
raw_path = mapping.get(kind)
|
|
114
|
+
if not raw_path:
|
|
115
|
+
raise HTTPException(status_code=404, detail="Artifact not found")
|
|
116
|
+
|
|
117
|
+
target = Path(raw_path).expanduser().resolve()
|
|
118
|
+
allowed_root = request.app.state.engine.repo_root.resolve()
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
target.relative_to(allowed_root)
|
|
122
|
+
if ".codex-autorunner" not in target.parts:
|
|
123
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
124
|
+
except ValueError:
|
|
125
|
+
raise HTTPException(status_code=403, detail="Access denied") from None
|
|
126
|
+
|
|
127
|
+
if not target.exists():
|
|
128
|
+
raise HTTPException(status_code=404, detail="Artifact not found")
|
|
129
|
+
|
|
130
|
+
if kind == "workflow_log" and target.is_dir():
|
|
131
|
+
target = target / "review.log"
|
|
132
|
+
|
|
133
|
+
media_type = "text/plain"
|
|
134
|
+
if target.suffix == ".md":
|
|
135
|
+
media_type = "text/markdown"
|
|
136
|
+
elif target.suffix == ".zip":
|
|
137
|
+
media_type = "application/zip"
|
|
138
|
+
|
|
139
|
+
return FileResponse(target, media_type=media_type, filename=target.name)
|
|
140
|
+
|
|
141
|
+
except ReviewError as exc:
|
|
142
|
+
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
|
|
143
|
+
except HTTPException:
|
|
144
|
+
raise
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
147
|
+
|
|
148
|
+
return router
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Terminal session registry routes.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
import logging
|
|
5
6
|
import time
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
@@ -14,6 +15,8 @@ from ..web.schemas import (
|
|
|
14
15
|
SessionStopResponse,
|
|
15
16
|
)
|
|
16
17
|
|
|
18
|
+
logger = logging.getLogger("codex_autorunner.routes.sessions")
|
|
19
|
+
|
|
17
20
|
|
|
18
21
|
def _relative_repo_path(repo_path: str, repo_root: Path) -> str:
|
|
19
22
|
path = Path(repo_path)
|
|
@@ -22,7 +25,8 @@ def _relative_repo_path(repo_path: str, repo_root: Path) -> str:
|
|
|
22
25
|
try:
|
|
23
26
|
rel = path.resolve().relative_to(repo_root)
|
|
24
27
|
return rel.as_posix() or "."
|
|
25
|
-
except
|
|
28
|
+
except ValueError as exc:
|
|
29
|
+
logger.debug("Failed to resolve relative path: %s", exc)
|
|
26
30
|
return path.name
|
|
27
31
|
|
|
28
32
|
|
|
@@ -121,21 +125,25 @@ def build_sessions_routes() -> APIRouter:
|
|
|
121
125
|
repo_root = Path(request.app.state.engine.repo_root)
|
|
122
126
|
normalized_repo_path = repo_path.strip()
|
|
123
127
|
if normalized_repo_path:
|
|
124
|
-
|
|
125
|
-
if not candidate.is_absolute():
|
|
126
|
-
candidate = (repo_root / candidate).resolve()
|
|
128
|
+
raw_path = Path(normalized_repo_path)
|
|
127
129
|
try:
|
|
128
|
-
|
|
129
|
-
|
|
130
|
+
# Reject absolute paths outright to prevent symlink traversal attacks
|
|
131
|
+
if raw_path.is_absolute():
|
|
132
|
+
raise ValueError("Absolute paths are not allowed")
|
|
133
|
+
# Only process relative paths, join with repo_root and resolve
|
|
134
|
+
resolved = (repo_root / raw_path).resolve()
|
|
135
|
+
# Verify the resolved path is still under repo_root
|
|
136
|
+
resolved.relative_to(repo_root)
|
|
137
|
+
except (OSError, RuntimeError, ValueError):
|
|
138
|
+
# On any resolution or containment failure, treat as invalid
|
|
130
139
|
normalized_repo_path = ""
|
|
131
140
|
else:
|
|
132
|
-
normalized_repo_path = str(
|
|
141
|
+
normalized_repo_path = str(resolved)
|
|
133
142
|
candidates: list[str] = []
|
|
134
143
|
if normalized_repo_path:
|
|
135
144
|
candidates.extend(
|
|
136
145
|
[normalized_repo_path, f"{normalized_repo_path}:opencode"]
|
|
137
146
|
)
|
|
138
|
-
candidates.extend([repo_path, f"{repo_path}:opencode"])
|
|
139
147
|
for key in candidates:
|
|
140
148
|
mapped = repo_to_session.get(key)
|
|
141
149
|
if mapped:
|