codex-autorunner 1.0.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/__init__.py +12 -1
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/client.py +113 -4
- codex_autorunner/agents/opencode/constants.py +3 -0
- codex_autorunner/agents/opencode/harness.py +6 -1
- codex_autorunner/agents/opencode/runtime.py +59 -18
- codex_autorunner/agents/opencode/supervisor.py +4 -0
- codex_autorunner/agents/registry.py +36 -7
- codex_autorunner/bootstrap.py +226 -4
- codex_autorunner/cli.py +5 -1174
- codex_autorunner/codex_cli.py +20 -84
- codex_autorunner/core/__init__.py +20 -0
- codex_autorunner/core/about_car.py +119 -1
- codex_autorunner/core/app_server_ids.py +59 -0
- codex_autorunner/core/app_server_threads.py +17 -2
- codex_autorunner/core/app_server_utils.py +165 -0
- codex_autorunner/core/archive.py +349 -0
- codex_autorunner/core/codex_runner.py +6 -2
- codex_autorunner/core/config.py +433 -4
- codex_autorunner/core/context_awareness.py +38 -0
- codex_autorunner/core/docs.py +0 -122
- codex_autorunner/core/drafts.py +58 -4
- codex_autorunner/core/exceptions.py +4 -0
- codex_autorunner/core/filebox.py +265 -0
- codex_autorunner/core/flows/controller.py +96 -2
- codex_autorunner/core/flows/models.py +13 -0
- codex_autorunner/core/flows/reasons.py +52 -0
- codex_autorunner/core/flows/reconciler.py +134 -0
- codex_autorunner/core/flows/runtime.py +57 -4
- codex_autorunner/core/flows/store.py +142 -7
- codex_autorunner/core/flows/transition.py +27 -15
- codex_autorunner/core/flows/ux_helpers.py +272 -0
- codex_autorunner/core/flows/worker_process.py +32 -6
- codex_autorunner/core/git_utils.py +62 -0
- codex_autorunner/core/hub.py +291 -20
- codex_autorunner/core/lifecycle_events.py +253 -0
- codex_autorunner/core/notifications.py +14 -2
- 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/__init__.py +28 -0
- codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
- codex_autorunner/core/ports/backend_orchestrator.py +41 -0
- codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
- 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 +62 -0
- codex_autorunner/core/supervisor_protocol.py +15 -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/text_delta_coalescer.py +54 -0
- codex_autorunner/core/ticket_linter_cli.py +218 -0
- codex_autorunner/core/ticket_manager_cli.py +494 -0
- codex_autorunner/core/time_utils.py +11 -0
- codex_autorunner/core/types.py +18 -0
- codex_autorunner/core/update.py +4 -5
- codex_autorunner/core/update_paths.py +28 -0
- codex_autorunner/core/usage.py +164 -12
- codex_autorunner/core/utils.py +125 -15
- codex_autorunner/flows/review/__init__.py +17 -0
- codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
- codex_autorunner/flows/ticket_flow/definition.py +52 -3
- codex_autorunner/integrations/agents/__init__.py +11 -19
- codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
- codex_autorunner/integrations/agents/codex_adapter.py +90 -0
- codex_autorunner/integrations/agents/codex_backend.py +177 -25
- codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
- codex_autorunner/integrations/agents/opencode_backend.py +305 -32
- codex_autorunner/integrations/agents/runner.py +86 -0
- codex_autorunner/integrations/agents/wiring.py +279 -0
- codex_autorunner/integrations/app_server/client.py +7 -60
- codex_autorunner/integrations/app_server/env.py +2 -107
- codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
- codex_autorunner/integrations/telegram/adapter.py +65 -0
- codex_autorunner/integrations/telegram/config.py +46 -0
- codex_autorunner/integrations/telegram/constants.py +1 -1
- codex_autorunner/integrations/telegram/doctor.py +228 -6
- codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
- 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 +1496 -71
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
- codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
- codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
- codex_autorunner/integrations/telegram/helpers.py +22 -1
- codex_autorunner/integrations/telegram/runtime.py +9 -4
- codex_autorunner/integrations/telegram/service.py +45 -10
- codex_autorunner/integrations/telegram/state.py +38 -0
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
- codex_autorunner/integrations/telegram/transport.py +13 -4
- codex_autorunner/integrations/templates/__init__.py +27 -0
- codex_autorunner/integrations/templates/scan_agent.py +312 -0
- codex_autorunner/routes/__init__.py +37 -76
- codex_autorunner/routes/agents.py +2 -137
- codex_autorunner/routes/analytics.py +2 -238
- codex_autorunner/routes/app_server.py +2 -131
- codex_autorunner/routes/base.py +2 -596
- codex_autorunner/routes/file_chat.py +4 -833
- codex_autorunner/routes/flows.py +4 -977
- codex_autorunner/routes/messages.py +4 -456
- codex_autorunner/routes/repos.py +2 -196
- codex_autorunner/routes/review.py +2 -147
- codex_autorunner/routes/sessions.py +2 -175
- codex_autorunner/routes/settings.py +2 -168
- codex_autorunner/routes/shared.py +2 -275
- codex_autorunner/routes/system.py +4 -193
- codex_autorunner/routes/usage.py +2 -86
- codex_autorunner/routes/voice.py +2 -119
- codex_autorunner/routes/workspace.py +2 -270
- codex_autorunner/server.py +4 -4
- codex_autorunner/static/agentControls.js +61 -16
- codex_autorunner/static/app.js +126 -14
- codex_autorunner/static/archive.js +826 -0
- codex_autorunner/static/archiveApi.js +37 -0
- codex_autorunner/static/autoRefresh.js +7 -7
- codex_autorunner/static/chatUploads.js +137 -0
- codex_autorunner/static/dashboard.js +224 -171
- 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 +114 -131
- codex_autorunner/static/index.html +375 -49
- codex_autorunner/static/messages.js +568 -87
- codex_autorunner/static/notifications.js +255 -0
- codex_autorunner/static/pma.js +1167 -0
- codex_autorunner/static/preserve.js +17 -0
- codex_autorunner/static/settings.js +128 -6
- codex_autorunner/static/smartRefresh.js +52 -0
- codex_autorunner/static/streamUtils.js +57 -0
- codex_autorunner/static/styles.css +9798 -6143
- codex_autorunner/static/tabs.js +152 -11
- codex_autorunner/static/templateReposSettings.js +225 -0
- codex_autorunner/static/terminal.js +18 -0
- codex_autorunner/static/ticketChatActions.js +165 -3
- codex_autorunner/static/ticketChatStream.js +17 -119
- codex_autorunner/static/ticketEditor.js +137 -15
- codex_autorunner/static/ticketTemplates.js +798 -0
- codex_autorunner/static/tickets.js +821 -98
- codex_autorunner/static/turnEvents.js +27 -0
- codex_autorunner/static/turnResume.js +33 -0
- codex_autorunner/static/utils.js +39 -0
- codex_autorunner/static/workspace.js +389 -82
- codex_autorunner/static/workspaceFileBrowser.js +15 -13
- codex_autorunner/surfaces/__init__.py +5 -0
- codex_autorunner/surfaces/cli/__init__.py +6 -0
- codex_autorunner/surfaces/cli/cli.py +2534 -0
- codex_autorunner/surfaces/cli/codex_cli.py +20 -0
- codex_autorunner/surfaces/cli/pma_cli.py +817 -0
- codex_autorunner/surfaces/telegram/__init__.py +3 -0
- codex_autorunner/surfaces/web/__init__.py +1 -0
- codex_autorunner/surfaces/web/app.py +2223 -0
- codex_autorunner/surfaces/web/hub_jobs.py +192 -0
- codex_autorunner/surfaces/web/middleware.py +587 -0
- codex_autorunner/surfaces/web/pty_session.py +370 -0
- codex_autorunner/surfaces/web/review.py +6 -0
- codex_autorunner/surfaces/web/routes/__init__.py +82 -0
- codex_autorunner/surfaces/web/routes/agents.py +138 -0
- codex_autorunner/surfaces/web/routes/analytics.py +284 -0
- codex_autorunner/surfaces/web/routes/app_server.py +132 -0
- codex_autorunner/surfaces/web/routes/archive.py +357 -0
- codex_autorunner/surfaces/web/routes/base.py +615 -0
- codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
- codex_autorunner/surfaces/web/routes/filebox.py +227 -0
- codex_autorunner/surfaces/web/routes/flows.py +1354 -0
- codex_autorunner/surfaces/web/routes/messages.py +490 -0
- codex_autorunner/surfaces/web/routes/pma.py +1652 -0
- codex_autorunner/surfaces/web/routes/repos.py +197 -0
- codex_autorunner/surfaces/web/routes/review.py +148 -0
- codex_autorunner/surfaces/web/routes/sessions.py +176 -0
- codex_autorunner/surfaces/web/routes/settings.py +169 -0
- codex_autorunner/surfaces/web/routes/shared.py +277 -0
- codex_autorunner/surfaces/web/routes/system.py +196 -0
- codex_autorunner/surfaces/web/routes/templates.py +634 -0
- codex_autorunner/surfaces/web/routes/usage.py +89 -0
- codex_autorunner/surfaces/web/routes/voice.py +120 -0
- codex_autorunner/surfaces/web/routes/workspace.py +271 -0
- codex_autorunner/surfaces/web/runner_manager.py +25 -0
- codex_autorunner/surfaces/web/schemas.py +469 -0
- codex_autorunner/surfaces/web/static_assets.py +490 -0
- codex_autorunner/surfaces/web/static_refresh.py +86 -0
- codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
- codex_autorunner/tickets/__init__.py +8 -1
- codex_autorunner/tickets/agent_pool.py +53 -4
- codex_autorunner/tickets/files.py +37 -16
- codex_autorunner/tickets/lint.py +50 -0
- codex_autorunner/tickets/models.py +6 -1
- codex_autorunner/tickets/outbox.py +50 -2
- codex_autorunner/tickets/runner.py +396 -57
- codex_autorunner/web/__init__.py +5 -1
- codex_autorunner/web/app.py +2 -1949
- codex_autorunner/web/hub_jobs.py +2 -191
- codex_autorunner/web/middleware.py +2 -586
- codex_autorunner/web/pty_session.py +2 -369
- codex_autorunner/web/runner_manager.py +2 -24
- codex_autorunner/web/schemas.py +2 -376
- codex_autorunner/web/static_assets.py +4 -441
- codex_autorunner/web/static_refresh.py +2 -85
- codex_autorunner/web/terminal_sessions.py +2 -77
- codex_autorunner/workspace/paths.py +49 -33
- codex_autorunner-1.2.0.dist-info/METADATA +150 -0
- codex_autorunner-1.2.0.dist-info/RECORD +339 -0
- codex_autorunner/core/adapter_utils.py +0 -21
- codex_autorunner/core/engine.py +0 -2653
- codex_autorunner/core/static_assets.py +0 -55
- codex_autorunner-1.0.0.dist-info/METADATA +0 -246
- codex_autorunner-1.0.0.dist-info/RECORD +0 -251
- /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Usage routes: token usage summaries for repo/hub.
|
|
3
|
+
|
|
4
|
+
Moved out of the legacy docs routes during the workspace + file chat cutover.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
12
|
+
|
|
13
|
+
from ....core.usage import (
|
|
14
|
+
UsageError,
|
|
15
|
+
default_codex_home,
|
|
16
|
+
get_repo_usage_series_cached,
|
|
17
|
+
get_repo_usage_summary_cached,
|
|
18
|
+
parse_iso_datetime,
|
|
19
|
+
)
|
|
20
|
+
from ..schemas import RepoUsageResponse, UsageSeriesResponse
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_usage_routes() -> APIRouter:
|
|
24
|
+
router = APIRouter(prefix="/api", tags=["usage"])
|
|
25
|
+
|
|
26
|
+
@router.get("/usage", response_model=RepoUsageResponse)
|
|
27
|
+
def get_usage(
|
|
28
|
+
request: Request, since: Optional[str] = None, until: Optional[str] = None
|
|
29
|
+
):
|
|
30
|
+
engine = request.app.state.engine
|
|
31
|
+
try:
|
|
32
|
+
since_dt = parse_iso_datetime(since)
|
|
33
|
+
until_dt = parse_iso_datetime(until)
|
|
34
|
+
except UsageError as exc:
|
|
35
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
36
|
+
summary, status = get_repo_usage_summary_cached(
|
|
37
|
+
engine.repo_root,
|
|
38
|
+
default_codex_home(),
|
|
39
|
+
config=engine.config,
|
|
40
|
+
since=since_dt,
|
|
41
|
+
until=until_dt,
|
|
42
|
+
)
|
|
43
|
+
return {
|
|
44
|
+
"mode": "repo",
|
|
45
|
+
"repo": str(engine.repo_root),
|
|
46
|
+
"codex_home": str(default_codex_home()),
|
|
47
|
+
"since": since,
|
|
48
|
+
"until": until,
|
|
49
|
+
"status": status,
|
|
50
|
+
**summary.to_dict(),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@router.get("/usage/series", response_model=UsageSeriesResponse)
|
|
54
|
+
def get_usage_series(
|
|
55
|
+
request: Request,
|
|
56
|
+
since: Optional[str] = None,
|
|
57
|
+
until: Optional[str] = None,
|
|
58
|
+
bucket: str = "day",
|
|
59
|
+
segment: str = "none",
|
|
60
|
+
):
|
|
61
|
+
engine = request.app.state.engine
|
|
62
|
+
try:
|
|
63
|
+
since_dt = parse_iso_datetime(since)
|
|
64
|
+
until_dt = parse_iso_datetime(until)
|
|
65
|
+
except UsageError as exc:
|
|
66
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
67
|
+
try:
|
|
68
|
+
series, status = get_repo_usage_series_cached(
|
|
69
|
+
engine.repo_root,
|
|
70
|
+
default_codex_home(),
|
|
71
|
+
config=engine.config,
|
|
72
|
+
since=since_dt,
|
|
73
|
+
until=until_dt,
|
|
74
|
+
bucket=bucket,
|
|
75
|
+
segment=segment,
|
|
76
|
+
)
|
|
77
|
+
except UsageError as exc:
|
|
78
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
79
|
+
return {
|
|
80
|
+
"mode": "repo",
|
|
81
|
+
"repo": str(engine.repo_root),
|
|
82
|
+
"codex_home": str(default_codex_home()),
|
|
83
|
+
"since": since,
|
|
84
|
+
"until": until,
|
|
85
|
+
"status": status,
|
|
86
|
+
**series,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return router
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Voice transcription and configuration routes.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
|
|
10
|
+
|
|
11
|
+
from ....voice import VoiceService, VoiceServiceError
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("codex_autorunner.routes.voice")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_voice_routes() -> APIRouter:
|
|
17
|
+
"""Build routes for voice transcription and config."""
|
|
18
|
+
router = APIRouter()
|
|
19
|
+
|
|
20
|
+
@router.get("/api/voice/config")
|
|
21
|
+
def get_voice_config(request: Request):
|
|
22
|
+
voice_service: Optional[VoiceService] = request.app.state.voice_service
|
|
23
|
+
voice_config = request.app.state.voice_config
|
|
24
|
+
missing_reason = getattr(request.app.state, "voice_missing_reason", None)
|
|
25
|
+
if missing_reason:
|
|
26
|
+
return {
|
|
27
|
+
"enabled": False,
|
|
28
|
+
"provider": voice_config.provider,
|
|
29
|
+
"latency_mode": voice_config.latency_mode,
|
|
30
|
+
"chunk_ms": voice_config.chunk_ms,
|
|
31
|
+
"sample_rate": voice_config.sample_rate,
|
|
32
|
+
"warn_on_remote_api": voice_config.warn_on_remote_api,
|
|
33
|
+
"has_api_key": False,
|
|
34
|
+
"push_to_talk": {
|
|
35
|
+
"max_ms": voice_config.push_to_talk.max_ms,
|
|
36
|
+
"silence_auto_stop_ms": voice_config.push_to_talk.silence_auto_stop_ms,
|
|
37
|
+
"min_hold_ms": voice_config.push_to_talk.min_hold_ms,
|
|
38
|
+
},
|
|
39
|
+
"missing_extra": missing_reason,
|
|
40
|
+
}
|
|
41
|
+
if voice_service is None:
|
|
42
|
+
# Degrade gracefully: still return config to the UI even if service init failed.
|
|
43
|
+
try:
|
|
44
|
+
return VoiceService(
|
|
45
|
+
voice_config, logger=request.app.state.logger
|
|
46
|
+
).config_payload()
|
|
47
|
+
except (ValueError, TypeError, OSError) as exc:
|
|
48
|
+
logger.debug("Failed to create VoiceService for config: %s", exc)
|
|
49
|
+
return {
|
|
50
|
+
"enabled": False,
|
|
51
|
+
"provider": voice_config.provider,
|
|
52
|
+
"latency_mode": voice_config.latency_mode,
|
|
53
|
+
"chunk_ms": voice_config.chunk_ms,
|
|
54
|
+
"sample_rate": voice_config.sample_rate,
|
|
55
|
+
"warn_on_remote_api": voice_config.warn_on_remote_api,
|
|
56
|
+
"has_api_key": False,
|
|
57
|
+
"push_to_talk": {
|
|
58
|
+
"max_ms": voice_config.push_to_talk.max_ms,
|
|
59
|
+
"silence_auto_stop_ms": voice_config.push_to_talk.silence_auto_stop_ms,
|
|
60
|
+
"min_hold_ms": voice_config.push_to_talk.min_hold_ms,
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
return voice_service.config_payload()
|
|
64
|
+
|
|
65
|
+
@router.post("/api/voice/transcribe")
|
|
66
|
+
async def transcribe_voice(
|
|
67
|
+
request: Request,
|
|
68
|
+
file: Optional[UploadFile] = File(None),
|
|
69
|
+
language: Optional[str] = None,
|
|
70
|
+
):
|
|
71
|
+
voice_service: Optional[VoiceService] = request.app.state.voice_service
|
|
72
|
+
voice_config = request.app.state.voice_config
|
|
73
|
+
missing_reason = getattr(request.app.state, "voice_missing_reason", None)
|
|
74
|
+
if missing_reason:
|
|
75
|
+
raise HTTPException(status_code=503, detail=missing_reason)
|
|
76
|
+
if not voice_service or not voice_config.enabled:
|
|
77
|
+
raise HTTPException(status_code=400, detail="Voice is disabled")
|
|
78
|
+
|
|
79
|
+
filename: Optional[str] = None
|
|
80
|
+
content_type: Optional[str] = None
|
|
81
|
+
if file is not None:
|
|
82
|
+
filename = file.filename
|
|
83
|
+
content_type = file.content_type
|
|
84
|
+
try:
|
|
85
|
+
audio_bytes = await file.read()
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
raise HTTPException(
|
|
88
|
+
status_code=400, detail="Unable to read audio upload"
|
|
89
|
+
) from exc
|
|
90
|
+
else:
|
|
91
|
+
audio_bytes = await request.body()
|
|
92
|
+
try:
|
|
93
|
+
result = await asyncio.to_thread(
|
|
94
|
+
voice_service.transcribe,
|
|
95
|
+
audio_bytes,
|
|
96
|
+
client="web",
|
|
97
|
+
user_agent=request.headers.get("user-agent"),
|
|
98
|
+
language=language,
|
|
99
|
+
filename=filename,
|
|
100
|
+
content_type=content_type,
|
|
101
|
+
)
|
|
102
|
+
except VoiceServiceError as exc:
|
|
103
|
+
if exc.reason == "unauthorized":
|
|
104
|
+
status = 401
|
|
105
|
+
elif exc.reason == "forbidden":
|
|
106
|
+
status = 403
|
|
107
|
+
elif exc.reason == "audio_too_large":
|
|
108
|
+
status = 413
|
|
109
|
+
elif exc.reason == "rate_limited":
|
|
110
|
+
status = 429
|
|
111
|
+
else:
|
|
112
|
+
status = (
|
|
113
|
+
400
|
|
114
|
+
if exc.reason in ("disabled", "empty_audio", "invalid_audio")
|
|
115
|
+
else 502
|
|
116
|
+
)
|
|
117
|
+
raise HTTPException(status_code=status, detail=exc.detail) from exc
|
|
118
|
+
return {"status": "ok", **result}
|
|
119
|
+
|
|
120
|
+
return router
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import zipfile
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
|
8
|
+
from fastapi.responses import FileResponse, PlainTextResponse, StreamingResponse
|
|
9
|
+
|
|
10
|
+
from ....core import drafts as draft_utils
|
|
11
|
+
from ....tickets.spec_ingest import (
|
|
12
|
+
SpecIngestTicketsError,
|
|
13
|
+
ingest_workspace_spec_to_tickets,
|
|
14
|
+
)
|
|
15
|
+
from ....workspace.paths import (
|
|
16
|
+
PINNED_DOC_FILENAMES,
|
|
17
|
+
WORKSPACE_DOC_KINDS,
|
|
18
|
+
list_workspace_files,
|
|
19
|
+
list_workspace_tree,
|
|
20
|
+
normalize_workspace_rel_path,
|
|
21
|
+
read_workspace_doc,
|
|
22
|
+
read_workspace_file,
|
|
23
|
+
sanitize_workspace_filename,
|
|
24
|
+
workspace_dir,
|
|
25
|
+
workspace_doc_path,
|
|
26
|
+
write_workspace_doc,
|
|
27
|
+
write_workspace_file,
|
|
28
|
+
)
|
|
29
|
+
from ..schemas import (
|
|
30
|
+
SpecIngestTicketsResponse,
|
|
31
|
+
WorkspaceFileListResponse,
|
|
32
|
+
WorkspaceResponse,
|
|
33
|
+
WorkspaceTreeResponse,
|
|
34
|
+
WorkspaceUploadResponse,
|
|
35
|
+
WorkspaceWriteRequest,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_workspace_routes() -> APIRouter:
|
|
40
|
+
router = APIRouter(prefix="/api", tags=["workspace"])
|
|
41
|
+
|
|
42
|
+
@router.get("/workspace", response_model=WorkspaceResponse)
|
|
43
|
+
def get_workspace(request: Request):
|
|
44
|
+
repo_root = request.app.state.engine.repo_root
|
|
45
|
+
return {
|
|
46
|
+
"active_context": read_workspace_doc(repo_root, "active_context"),
|
|
47
|
+
"decisions": read_workspace_doc(repo_root, "decisions"),
|
|
48
|
+
"spec": read_workspace_doc(repo_root, "spec"),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@router.get("/workspace/file", response_class=PlainTextResponse)
|
|
52
|
+
def read_workspace(request: Request, path: str):
|
|
53
|
+
repo_root = request.app.state.engine.repo_root
|
|
54
|
+
try:
|
|
55
|
+
content = read_workspace_file(repo_root, path)
|
|
56
|
+
except ValueError as exc: # invalid path
|
|
57
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
58
|
+
return PlainTextResponse(content)
|
|
59
|
+
|
|
60
|
+
@router.put("/workspace/file", response_class=PlainTextResponse)
|
|
61
|
+
def write_workspace(request: Request, payload: WorkspaceWriteRequest, path: str):
|
|
62
|
+
repo_root = request.app.state.engine.repo_root
|
|
63
|
+
try:
|
|
64
|
+
# Normalize path the same way workspace helpers do to avoid traversal
|
|
65
|
+
safe_path, rel_posix = normalize_workspace_rel_path(repo_root, path)
|
|
66
|
+
content = write_workspace_file(repo_root, path, payload.content)
|
|
67
|
+
try:
|
|
68
|
+
rel_repo_path = safe_path.relative_to(repo_root).as_posix()
|
|
69
|
+
draft_utils.invalidate_drafts_for_path(repo_root, rel_repo_path)
|
|
70
|
+
state_key = f"workspace_{rel_posix.replace('/', '_')}"
|
|
71
|
+
draft_utils.remove_draft(repo_root, state_key)
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
76
|
+
return PlainTextResponse(content)
|
|
77
|
+
|
|
78
|
+
@router.put("/workspace/{kind}", response_model=WorkspaceResponse)
|
|
79
|
+
def put_workspace(kind: str, payload: WorkspaceWriteRequest, request: Request):
|
|
80
|
+
key = (kind or "").strip().lower()
|
|
81
|
+
if key not in WORKSPACE_DOC_KINDS:
|
|
82
|
+
raise HTTPException(status_code=400, detail="invalid workspace doc kind")
|
|
83
|
+
repo_root = request.app.state.engine.repo_root
|
|
84
|
+
write_workspace_doc(repo_root, key, payload.content)
|
|
85
|
+
try:
|
|
86
|
+
rel_path = workspace_doc_path(repo_root, key).relative_to(repo_root)
|
|
87
|
+
draft_utils.invalidate_drafts_for_path(repo_root, rel_path.as_posix())
|
|
88
|
+
state_key = f"workspace_{rel_path.name}"
|
|
89
|
+
draft_utils.remove_draft(repo_root, state_key)
|
|
90
|
+
except Exception:
|
|
91
|
+
# best-effort invalidation; avoid blocking writes
|
|
92
|
+
pass
|
|
93
|
+
return {
|
|
94
|
+
"active_context": read_workspace_doc(repo_root, "active_context"),
|
|
95
|
+
"decisions": read_workspace_doc(repo_root, "decisions"),
|
|
96
|
+
"spec": read_workspace_doc(repo_root, "spec"),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@router.get("/workspace/files", response_model=WorkspaceFileListResponse)
|
|
100
|
+
def list_files(request: Request):
|
|
101
|
+
repo_root = request.app.state.engine.repo_root
|
|
102
|
+
files = [asdict(item) for item in list_workspace_files(repo_root)]
|
|
103
|
+
return {"files": files}
|
|
104
|
+
|
|
105
|
+
@router.get("/workspace/tree", response_model=WorkspaceTreeResponse)
|
|
106
|
+
def get_workspace_tree(request: Request):
|
|
107
|
+
repo_root = request.app.state.engine.repo_root
|
|
108
|
+
tree = [asdict(item) for item in list_workspace_tree(repo_root)]
|
|
109
|
+
return {"tree": tree}
|
|
110
|
+
|
|
111
|
+
@router.post("/workspace/upload", response_model=WorkspaceUploadResponse)
|
|
112
|
+
async def upload_workspace_files(
|
|
113
|
+
request: Request,
|
|
114
|
+
files: list[UploadFile] = File(...), # noqa: B008
|
|
115
|
+
subdir: str = Form(""),
|
|
116
|
+
):
|
|
117
|
+
if not files:
|
|
118
|
+
raise HTTPException(status_code=400, detail="no files provided")
|
|
119
|
+
|
|
120
|
+
repo_root = request.app.state.engine.repo_root
|
|
121
|
+
base = workspace_dir(repo_root)
|
|
122
|
+
target_dir = base
|
|
123
|
+
if subdir:
|
|
124
|
+
try:
|
|
125
|
+
target_dir, _ = normalize_workspace_rel_path(repo_root, subdir)
|
|
126
|
+
except ValueError as exc:
|
|
127
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
128
|
+
|
|
129
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
|
|
131
|
+
uploaded: list[dict[str, str | int]] = []
|
|
132
|
+
for upload in files:
|
|
133
|
+
filename = sanitize_workspace_filename(upload.filename or "")
|
|
134
|
+
try:
|
|
135
|
+
data = await upload.read()
|
|
136
|
+
except (
|
|
137
|
+
Exception
|
|
138
|
+
) as exc: # pragma: no cover - handled by FastAPI for most cases
|
|
139
|
+
raise HTTPException(
|
|
140
|
+
status_code=400, detail="failed to read upload"
|
|
141
|
+
) from exc
|
|
142
|
+
|
|
143
|
+
dest = target_dir / filename
|
|
144
|
+
dest.write_bytes(
|
|
145
|
+
data
|
|
146
|
+
) # codeql[py/path-injection] dest sits under normalized workspace dir
|
|
147
|
+
rel_path = dest.relative_to(base).as_posix()
|
|
148
|
+
uploaded.append({"filename": filename, "path": rel_path, "size": len(data)})
|
|
149
|
+
|
|
150
|
+
return {"status": "ok", "uploaded": uploaded}
|
|
151
|
+
|
|
152
|
+
@router.get("/workspace/download")
|
|
153
|
+
async def download_workspace_file(request: Request, path: str):
|
|
154
|
+
repo_root = request.app.state.engine.repo_root
|
|
155
|
+
try:
|
|
156
|
+
safe_path, _ = normalize_workspace_rel_path(repo_root, path)
|
|
157
|
+
except ValueError as exc:
|
|
158
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
159
|
+
|
|
160
|
+
if not safe_path.exists() or safe_path.is_dir():
|
|
161
|
+
raise HTTPException(status_code=404, detail="file not found")
|
|
162
|
+
|
|
163
|
+
return FileResponse(
|
|
164
|
+
path=safe_path, filename=safe_path.name
|
|
165
|
+
) # codeql[py/path-injection] safe_path validated by normalize_workspace_rel_path
|
|
166
|
+
|
|
167
|
+
@router.get("/workspace/download-zip")
|
|
168
|
+
async def download_workspace_zip(request: Request, path: str = ""):
|
|
169
|
+
repo_root = request.app.state.engine.repo_root
|
|
170
|
+
base = workspace_dir(repo_root)
|
|
171
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
|
|
173
|
+
target_dir = base
|
|
174
|
+
zip_name = "workspace.zip"
|
|
175
|
+
if path:
|
|
176
|
+
try:
|
|
177
|
+
target_dir, _ = normalize_workspace_rel_path(repo_root, path)
|
|
178
|
+
except ValueError as exc:
|
|
179
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
180
|
+
if not target_dir.exists() or not target_dir.is_dir():
|
|
181
|
+
raise HTTPException(status_code=404, detail="folder not found")
|
|
182
|
+
zip_name = f"{target_dir.name}.zip"
|
|
183
|
+
|
|
184
|
+
buffer = io.BytesIO()
|
|
185
|
+
base_real = base.resolve()
|
|
186
|
+
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
187
|
+
for file_path in target_dir.rglob("*"):
|
|
188
|
+
if file_path.is_dir():
|
|
189
|
+
continue
|
|
190
|
+
if file_path.is_symlink():
|
|
191
|
+
try:
|
|
192
|
+
file_path.resolve().relative_to(base_real)
|
|
193
|
+
except Exception:
|
|
194
|
+
continue
|
|
195
|
+
arc_name = file_path.relative_to(target_dir).as_posix()
|
|
196
|
+
zf.write(
|
|
197
|
+
file_path, arc_name
|
|
198
|
+
) # codeql[py/path-injection] file_path constrained to workspace dir
|
|
199
|
+
|
|
200
|
+
buffer.seek(0)
|
|
201
|
+
return StreamingResponse(
|
|
202
|
+
buffer,
|
|
203
|
+
media_type="application/zip",
|
|
204
|
+
headers={"Content-Disposition": f'attachment; filename="{zip_name}"'},
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
@router.post("/workspace/folder")
|
|
208
|
+
async def create_workspace_folder(request: Request, path: str):
|
|
209
|
+
repo_root = request.app.state.engine.repo_root
|
|
210
|
+
try:
|
|
211
|
+
safe_path, rel_posix = normalize_workspace_rel_path(repo_root, path)
|
|
212
|
+
except ValueError as exc:
|
|
213
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
214
|
+
|
|
215
|
+
if safe_path.exists():
|
|
216
|
+
raise HTTPException(status_code=400, detail="path already exists")
|
|
217
|
+
|
|
218
|
+
safe_path.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
return {"status": "created", "path": rel_posix}
|
|
220
|
+
|
|
221
|
+
@router.delete("/workspace/folder")
|
|
222
|
+
async def delete_workspace_folder(request: Request, path: str):
|
|
223
|
+
repo_root = request.app.state.engine.repo_root
|
|
224
|
+
try:
|
|
225
|
+
safe_path, rel_posix = normalize_workspace_rel_path(repo_root, path)
|
|
226
|
+
except ValueError as exc:
|
|
227
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
228
|
+
|
|
229
|
+
if not safe_path.exists():
|
|
230
|
+
raise HTTPException(status_code=404, detail="folder not found")
|
|
231
|
+
if not safe_path.is_dir():
|
|
232
|
+
raise HTTPException(status_code=400, detail="not a folder")
|
|
233
|
+
if any(safe_path.iterdir()):
|
|
234
|
+
raise HTTPException(status_code=400, detail="folder not empty")
|
|
235
|
+
|
|
236
|
+
safe_path.rmdir()
|
|
237
|
+
return {"status": "deleted", "path": rel_posix}
|
|
238
|
+
|
|
239
|
+
@router.delete("/workspace/file")
|
|
240
|
+
async def delete_workspace_file(request: Request, path: str):
|
|
241
|
+
repo_root = request.app.state.engine.repo_root
|
|
242
|
+
base = workspace_dir(repo_root)
|
|
243
|
+
try:
|
|
244
|
+
safe_path, rel_posix = normalize_workspace_rel_path(repo_root, path)
|
|
245
|
+
except ValueError as exc:
|
|
246
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
247
|
+
|
|
248
|
+
if safe_path.parent == base and safe_path.name in PINNED_DOC_FILENAMES:
|
|
249
|
+
raise HTTPException(status_code=400, detail="cannot delete pinned docs")
|
|
250
|
+
if not safe_path.exists():
|
|
251
|
+
raise HTTPException(status_code=404, detail="file not found")
|
|
252
|
+
if safe_path.is_dir():
|
|
253
|
+
raise HTTPException(status_code=400, detail="use folder delete endpoint")
|
|
254
|
+
|
|
255
|
+
safe_path.unlink() # codeql[py/path-injection] safe_path validated by normalize_workspace_rel_path
|
|
256
|
+
return {"status": "deleted", "path": rel_posix}
|
|
257
|
+
|
|
258
|
+
@router.post("/workspace/spec/ingest", response_model=SpecIngestTicketsResponse)
|
|
259
|
+
def ingest_workspace_spec(request: Request):
|
|
260
|
+
repo_root = request.app.state.engine.repo_root
|
|
261
|
+
try:
|
|
262
|
+
result = ingest_workspace_spec_to_tickets(repo_root)
|
|
263
|
+
except SpecIngestTicketsError as exc:
|
|
264
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
265
|
+
return {
|
|
266
|
+
"status": "ok",
|
|
267
|
+
"created": result.created,
|
|
268
|
+
"first_ticket_path": result.first_ticket_path,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return router
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ...core.runner_controller import ProcessRunnerController
|
|
4
|
+
from ...core.runtime import RuntimeContext
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RunnerManager:
|
|
8
|
+
def __init__(self, engine: RuntimeContext):
|
|
9
|
+
self._controller = ProcessRunnerController(engine)
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def running(self) -> bool:
|
|
13
|
+
return self._controller.running
|
|
14
|
+
|
|
15
|
+
def start(self, once: bool = False) -> None:
|
|
16
|
+
self._controller.start(once=once)
|
|
17
|
+
|
|
18
|
+
def resume(self, once: bool = False) -> None:
|
|
19
|
+
self._controller.resume(once=once)
|
|
20
|
+
|
|
21
|
+
def stop(self) -> None:
|
|
22
|
+
self._controller.stop()
|
|
23
|
+
|
|
24
|
+
def kill(self) -> None:
|
|
25
|
+
self._controller.kill()
|