codex-autorunner 0.1.2__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.
Files changed (189) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/opencode/client.py +68 -35
  3. codex_autorunner/agents/opencode/logging.py +21 -5
  4. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  5. codex_autorunner/agents/opencode/runtime.py +118 -30
  6. codex_autorunner/agents/opencode/supervisor.py +36 -48
  7. codex_autorunner/agents/registry.py +136 -8
  8. codex_autorunner/api.py +25 -0
  9. codex_autorunner/bootstrap.py +16 -35
  10. codex_autorunner/cli.py +157 -139
  11. codex_autorunner/core/about_car.py +44 -32
  12. codex_autorunner/core/adapter_utils.py +21 -0
  13. codex_autorunner/core/app_server_logging.py +7 -3
  14. codex_autorunner/core/app_server_prompts.py +27 -260
  15. codex_autorunner/core/app_server_threads.py +15 -26
  16. codex_autorunner/core/codex_runner.py +6 -0
  17. codex_autorunner/core/config.py +390 -100
  18. codex_autorunner/core/docs.py +10 -2
  19. codex_autorunner/core/drafts.py +82 -0
  20. codex_autorunner/core/engine.py +278 -262
  21. codex_autorunner/core/flows/__init__.py +25 -0
  22. codex_autorunner/core/flows/controller.py +178 -0
  23. codex_autorunner/core/flows/definition.py +82 -0
  24. codex_autorunner/core/flows/models.py +75 -0
  25. codex_autorunner/core/flows/runtime.py +351 -0
  26. codex_autorunner/core/flows/store.py +485 -0
  27. codex_autorunner/core/flows/transition.py +133 -0
  28. codex_autorunner/core/flows/worker_process.py +242 -0
  29. codex_autorunner/core/hub.py +15 -9
  30. codex_autorunner/core/locks.py +4 -0
  31. codex_autorunner/core/prompt.py +15 -7
  32. codex_autorunner/core/redaction.py +29 -0
  33. codex_autorunner/core/review_context.py +5 -8
  34. codex_autorunner/core/run_index.py +6 -0
  35. codex_autorunner/core/runner_process.py +5 -2
  36. codex_autorunner/core/state.py +0 -88
  37. codex_autorunner/core/static_assets.py +55 -0
  38. codex_autorunner/core/supervisor_utils.py +67 -0
  39. codex_autorunner/core/update.py +20 -11
  40. codex_autorunner/core/update_runner.py +2 -0
  41. codex_autorunner/core/utils.py +29 -2
  42. codex_autorunner/discovery.py +2 -4
  43. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  44. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  45. codex_autorunner/integrations/agents/__init__.py +27 -0
  46. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  47. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  48. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  49. codex_autorunner/integrations/agents/run_event.py +71 -0
  50. codex_autorunner/integrations/app_server/client.py +576 -92
  51. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  52. codex_autorunner/integrations/telegram/adapter.py +141 -167
  53. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  54. codex_autorunner/integrations/telegram/config.py +175 -0
  55. codex_autorunner/integrations/telegram/constants.py +16 -1
  56. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  57. codex_autorunner/integrations/telegram/doctor.py +47 -0
  58. codex_autorunner/integrations/telegram/handlers/callbacks.py +0 -4
  59. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  63. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  64. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  65. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  66. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +133 -475
  67. codex_autorunner/integrations/telegram/handlers/commands_spec.py +11 -4
  68. codex_autorunner/integrations/telegram/handlers/messages.py +120 -9
  69. codex_autorunner/integrations/telegram/helpers.py +88 -16
  70. codex_autorunner/integrations/telegram/outbox.py +208 -37
  71. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  72. codex_autorunner/integrations/telegram/service.py +214 -40
  73. codex_autorunner/integrations/telegram/state.py +100 -2
  74. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  75. codex_autorunner/integrations/telegram/transport.py +36 -3
  76. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  77. codex_autorunner/manifest.py +2 -0
  78. codex_autorunner/plugin_api.py +22 -0
  79. codex_autorunner/routes/__init__.py +23 -14
  80. codex_autorunner/routes/analytics.py +239 -0
  81. codex_autorunner/routes/base.py +81 -109
  82. codex_autorunner/routes/file_chat.py +836 -0
  83. codex_autorunner/routes/flows.py +980 -0
  84. codex_autorunner/routes/messages.py +459 -0
  85. codex_autorunner/routes/system.py +6 -1
  86. codex_autorunner/routes/usage.py +87 -0
  87. codex_autorunner/routes/workspace.py +271 -0
  88. codex_autorunner/server.py +2 -1
  89. codex_autorunner/static/agentControls.js +1 -0
  90. codex_autorunner/static/agentEvents.js +248 -0
  91. codex_autorunner/static/app.js +25 -22
  92. codex_autorunner/static/autoRefresh.js +29 -1
  93. codex_autorunner/static/bootstrap.js +1 -0
  94. codex_autorunner/static/bus.js +1 -0
  95. codex_autorunner/static/cache.js +1 -0
  96. codex_autorunner/static/constants.js +20 -4
  97. codex_autorunner/static/dashboard.js +162 -196
  98. codex_autorunner/static/diffRenderer.js +37 -0
  99. codex_autorunner/static/docChatCore.js +324 -0
  100. codex_autorunner/static/docChatStorage.js +65 -0
  101. codex_autorunner/static/docChatVoice.js +65 -0
  102. codex_autorunner/static/docEditor.js +133 -0
  103. codex_autorunner/static/env.js +1 -0
  104. codex_autorunner/static/eventSummarizer.js +166 -0
  105. codex_autorunner/static/fileChat.js +182 -0
  106. codex_autorunner/static/health.js +155 -0
  107. codex_autorunner/static/hub.js +41 -118
  108. codex_autorunner/static/index.html +787 -858
  109. codex_autorunner/static/liveUpdates.js +1 -0
  110. codex_autorunner/static/loader.js +1 -0
  111. codex_autorunner/static/messages.js +470 -0
  112. codex_autorunner/static/mobileCompact.js +2 -1
  113. codex_autorunner/static/settings.js +24 -211
  114. codex_autorunner/static/styles.css +7567 -3865
  115. codex_autorunner/static/tabs.js +28 -5
  116. codex_autorunner/static/terminal.js +14 -0
  117. codex_autorunner/static/terminalManager.js +34 -59
  118. codex_autorunner/static/ticketChatActions.js +333 -0
  119. codex_autorunner/static/ticketChatEvents.js +16 -0
  120. codex_autorunner/static/ticketChatStorage.js +16 -0
  121. codex_autorunner/static/ticketChatStream.js +264 -0
  122. codex_autorunner/static/ticketEditor.js +750 -0
  123. codex_autorunner/static/ticketVoice.js +9 -0
  124. codex_autorunner/static/tickets.js +1315 -0
  125. codex_autorunner/static/utils.js +32 -3
  126. codex_autorunner/static/voice.js +1 -0
  127. codex_autorunner/static/workspace.js +672 -0
  128. codex_autorunner/static/workspaceApi.js +53 -0
  129. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  130. codex_autorunner/tickets/__init__.py +20 -0
  131. codex_autorunner/tickets/agent_pool.py +377 -0
  132. codex_autorunner/tickets/files.py +85 -0
  133. codex_autorunner/tickets/frontmatter.py +55 -0
  134. codex_autorunner/tickets/lint.py +102 -0
  135. codex_autorunner/tickets/models.py +95 -0
  136. codex_autorunner/tickets/outbox.py +232 -0
  137. codex_autorunner/tickets/replies.py +179 -0
  138. codex_autorunner/tickets/runner.py +823 -0
  139. codex_autorunner/tickets/spec_ingest.py +77 -0
  140. codex_autorunner/web/app.py +269 -91
  141. codex_autorunner/web/middleware.py +3 -4
  142. codex_autorunner/web/schemas.py +89 -109
  143. codex_autorunner/web/static_assets.py +1 -44
  144. codex_autorunner/workspace/__init__.py +40 -0
  145. codex_autorunner/workspace/paths.py +319 -0
  146. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +18 -21
  147. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  148. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  149. codex_autorunner/agents/execution/policy.py +0 -292
  150. codex_autorunner/agents/factory.py +0 -52
  151. codex_autorunner/agents/orchestrator.py +0 -358
  152. codex_autorunner/core/doc_chat.py +0 -1446
  153. codex_autorunner/core/snapshot.py +0 -580
  154. codex_autorunner/integrations/github/chatops.py +0 -268
  155. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  156. codex_autorunner/routes/docs.py +0 -381
  157. codex_autorunner/routes/github.py +0 -327
  158. codex_autorunner/routes/runs.py +0 -250
  159. codex_autorunner/spec_ingest.py +0 -812
  160. codex_autorunner/static/docChatActions.js +0 -287
  161. codex_autorunner/static/docChatEvents.js +0 -300
  162. codex_autorunner/static/docChatRender.js +0 -205
  163. codex_autorunner/static/docChatStream.js +0 -361
  164. codex_autorunner/static/docs.js +0 -20
  165. codex_autorunner/static/docsClipboard.js +0 -69
  166. codex_autorunner/static/docsCrud.js +0 -257
  167. codex_autorunner/static/docsDocUpdates.js +0 -62
  168. codex_autorunner/static/docsDrafts.js +0 -16
  169. codex_autorunner/static/docsElements.js +0 -69
  170. codex_autorunner/static/docsInit.js +0 -285
  171. codex_autorunner/static/docsParse.js +0 -160
  172. codex_autorunner/static/docsSnapshot.js +0 -87
  173. codex_autorunner/static/docsSpecIngest.js +0 -263
  174. codex_autorunner/static/docsState.js +0 -127
  175. codex_autorunner/static/docsThreadRegistry.js +0 -44
  176. codex_autorunner/static/docsUi.js +0 -153
  177. codex_autorunner/static/docsVoice.js +0 -56
  178. codex_autorunner/static/github.js +0 -504
  179. codex_autorunner/static/logs.js +0 -678
  180. codex_autorunner/static/review.js +0 -157
  181. codex_autorunner/static/runs.js +0 -418
  182. codex_autorunner/static/snapshot.js +0 -124
  183. codex_autorunner/static/state.js +0 -94
  184. codex_autorunner/static/todoPreview.js +0 -27
  185. codex_autorunner/workspace.py +0 -16
  186. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  187. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  188. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  189. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -4,7 +4,7 @@ Pydantic request/response schemas for web and API routes.
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
- from typing import Any, Dict, List, Optional
7
+ from typing import Any, Dict, List, Literal, Optional
8
8
 
9
9
  from pydantic import AliasChoices, BaseModel, ConfigDict, Field
10
10
 
@@ -13,6 +13,65 @@ class Payload(BaseModel):
13
13
  model_config = ConfigDict(extra="ignore", populate_by_name=True)
14
14
 
15
15
 
16
+ class ResponseModel(BaseModel):
17
+ model_config = ConfigDict(extra="ignore")
18
+
19
+
20
+ class WorkspaceWriteRequest(Payload):
21
+ content: str = ""
22
+
23
+
24
+ class WorkspaceResponse(ResponseModel):
25
+ active_context: str
26
+ decisions: str
27
+ spec: str
28
+
29
+
30
+ class WorkspaceFileItem(ResponseModel):
31
+ name: str
32
+ path: str
33
+ is_pinned: bool = False
34
+ modified_at: Optional[str] = None
35
+
36
+
37
+ class WorkspaceFileListResponse(ResponseModel):
38
+ files: List[WorkspaceFileItem]
39
+
40
+
41
+ class WorkspaceNode(ResponseModel):
42
+ name: str
43
+ path: str
44
+ type: Literal["file", "folder"]
45
+ is_pinned: bool = False
46
+ modified_at: Optional[str] = None
47
+ size: Optional[int] = None
48
+ children: Optional[List["WorkspaceNode"]] = None
49
+
50
+
51
+ WorkspaceNode.model_rebuild()
52
+
53
+
54
+ class WorkspaceTreeResponse(ResponseModel):
55
+ tree: List[WorkspaceNode]
56
+
57
+
58
+ class WorkspaceUploadedItem(ResponseModel):
59
+ filename: str
60
+ path: str
61
+ size: int
62
+
63
+
64
+ class WorkspaceUploadResponse(ResponseModel):
65
+ status: str
66
+ uploaded: List[WorkspaceUploadedItem]
67
+
68
+
69
+ class SpecIngestTicketsResponse(ResponseModel):
70
+ status: str
71
+ created: int
72
+ first_ticket_path: Optional[str] = None
73
+
74
+
16
75
  class RunControlRequest(Payload):
17
76
  once: bool = False
18
77
  agent: Optional[str] = None
@@ -60,37 +119,6 @@ class HubCleanupWorktreeRequest(Payload):
60
119
  delete_remote: bool = False
61
120
 
62
121
 
63
- class DocContentRequest(Payload):
64
- content: str = ""
65
-
66
-
67
- class SnapshotRequest(Payload):
68
- pass
69
-
70
-
71
- class DocChatPayload(Payload):
72
- message: Optional[str] = None
73
- stream: bool = False
74
- targets: Optional[List[str]] = None
75
- target: Optional[str] = None
76
- agent: Optional[str] = None
77
- model: Optional[str] = None
78
- reasoning: Optional[str] = None
79
- context_doc: Optional[str] = Field(
80
- default=None,
81
- validation_alias=AliasChoices("context_doc", "contextDoc", "viewing"),
82
- )
83
-
84
-
85
- class IngestSpecRequest(Payload):
86
- force: bool = False
87
- spec_path: Optional[str] = None
88
- message: Optional[str] = None
89
- agent: Optional[str] = None
90
- model: Optional[str] = None
91
- reasoning: Optional[str] = None
92
-
93
-
94
122
  class AppServerThreadResetRequest(Payload):
95
123
  key: str = Field(
96
124
  validation_alias=AliasChoices("key", "feature", "feature_key", "featureKey")
@@ -125,39 +153,6 @@ class GithubPrSyncRequest(Payload):
125
153
  mode: Optional[str] = None
126
154
 
127
155
 
128
- class GithubPrFlowStartRequest(Payload):
129
- mode: Optional[str] = "issue"
130
- issue: Optional[str] = None
131
- pr: Optional[str] = None
132
- draft: Optional[bool] = None
133
- base_branch: Optional[str] = Field(
134
- default=None, validation_alias=AliasChoices("base_branch", "baseBranch", "base")
135
- )
136
- stop_condition: Optional[str] = Field(
137
- default=None,
138
- validation_alias=AliasChoices("stop_condition", "stopCondition", "until"),
139
- )
140
- max_cycles: Optional[int] = Field(
141
- default=None, validation_alias=AliasChoices("max_cycles", "maxCycles")
142
- )
143
- max_implementation_runs: Optional[int] = Field(
144
- default=None,
145
- validation_alias=AliasChoices(
146
- "max_implementation_runs", "maxImplementationRuns"
147
- ),
148
- )
149
- max_wallclock_seconds: Optional[int] = Field(
150
- default=None,
151
- validation_alias=AliasChoices(
152
- "max_wallclock_seconds", "maxWallclockSeconds", "timeout_seconds"
153
- ),
154
- )
155
-
156
-
157
- class GithubPrFlowActionRequest(Payload):
158
- pass
159
-
160
-
161
156
  class SessionStopRequest(Payload):
162
157
  session_id: Optional[str] = None
163
158
  repo_path: Optional[str] = None
@@ -167,10 +162,6 @@ class SystemUpdateRequest(Payload):
167
162
  target: Optional[str] = None
168
163
 
169
164
 
170
- class ResponseModel(BaseModel):
171
- model_config = ConfigDict(extra="ignore")
172
-
173
-
174
165
  class HubJobResponse(ResponseModel):
175
166
  job_id: str
176
167
  kind: str
@@ -248,30 +239,9 @@ class SessionStopResponse(ResponseModel):
248
239
  session_id: str
249
240
 
250
241
 
251
- class DocsResponse(ResponseModel):
252
- todo: str
253
- progress: str
254
- opinions: str
255
- spec: str
256
- summary: str
257
-
258
-
259
- class IngestSpecResponse(ResponseModel):
260
- status: str
261
- todo: str
262
- progress: str
263
- opinions: str
264
- spec: str
265
- summary: str
266
- patch: Optional[str] = None
267
- agent_message: Optional[str] = None
268
-
269
-
270
242
  class AppServerThreadsResponse(ResponseModel):
271
- doc_chat: Dict[str, Optional[str]]
272
- doc_chat_opencode: Optional[Dict[str, Optional[str]]] = None
273
- spec_ingest: Optional[str] = None
274
- spec_ingest_opencode: Optional[str] = None
243
+ file_chat: Optional[str] = None
244
+ file_chat_opencode: Optional[str] = None
275
245
  autorunner: Optional[str] = None
276
246
  autorunner_opencode: Optional[str] = None
277
247
  corruption: Optional[Dict[str, Any]] = None
@@ -294,23 +264,6 @@ class AppServerThreadResetAllResponse(ResponseModel):
294
264
  cleared: bool
295
265
 
296
266
 
297
- class DocWriteResponse(ResponseModel):
298
- kind: str
299
- content: str
300
-
301
-
302
- class SnapshotResponse(ResponseModel):
303
- exists: bool
304
- content: str
305
- state: Dict[str, Any]
306
-
307
-
308
- class SnapshotCreateResponse(ResponseModel):
309
- content: str
310
- truncated: bool
311
- state: Dict[str, Any]
312
-
313
-
314
267
  class TokenTotalsResponse(ResponseModel):
315
268
  input_tokens: int
316
269
  cached_input_tokens: int
@@ -395,3 +348,30 @@ class ReviewStatusResponse(ResponseModel):
395
348
  class ReviewControlResponse(ResponseModel):
396
349
  status: str
397
350
  detail: Optional[str] = None
351
+
352
+
353
+ # Ticket CRUD schemas
354
+
355
+
356
+ class TicketCreateRequest(Payload):
357
+ agent: str = "codex"
358
+ title: Optional[str] = None
359
+ goal: Optional[str] = None
360
+ body: str = ""
361
+
362
+
363
+ class TicketUpdateRequest(Payload):
364
+ content: str # Full markdown with frontmatter
365
+
366
+
367
+ class TicketResponse(ResponseModel):
368
+ path: str
369
+ index: int
370
+ frontmatter: Dict[str, Any]
371
+ body: str
372
+
373
+
374
+ class TicketDeleteResponse(ResponseModel):
375
+ status: str
376
+ index: int
377
+ path: str
@@ -6,36 +6,14 @@ import os
6
6
  import shutil
7
7
  import time
8
8
  from contextlib import ExitStack
9
- from importlib import resources
10
9
  from pathlib import Path
11
10
  from typing import Iterable, Optional
12
11
  from uuid import uuid4
13
12
 
14
13
  from ..core.logging_utils import safe_log
14
+ from ..core.static_assets import missing_static_assets, resolve_static_dir
15
15
 
16
16
  _ASSET_VERSION_TOKEN = "__CAR_ASSET_VERSION__"
17
- _REQUIRED_STATIC_ASSETS = (
18
- "index.html",
19
- "styles.css",
20
- "bootstrap.js",
21
- "loader.js",
22
- "app.js",
23
- "github.js",
24
- "vendor/xterm.js",
25
- "vendor/xterm-addon-fit.js",
26
- "vendor/xterm.css",
27
- )
28
-
29
-
30
- def missing_static_assets(static_dir: Path) -> list[str]:
31
- missing: list[str] = []
32
- for rel_path in _REQUIRED_STATIC_ASSETS:
33
- try:
34
- if not (static_dir / rel_path).exists():
35
- missing.append(rel_path)
36
- except OSError:
37
- missing.append(rel_path)
38
- return missing
39
17
 
40
18
 
41
19
  def _iter_static_source_files(source_dir: Path) -> Iterable[Path]:
@@ -177,27 +155,6 @@ def index_response_headers() -> dict[str, str]:
177
155
  return headers
178
156
 
179
157
 
180
- def resolve_static_dir() -> tuple[Path, Optional[ExitStack]]:
181
- static_root = resources.files("codex_autorunner").joinpath("static")
182
- if isinstance(static_root, Path):
183
- if static_root.exists():
184
- return static_root, None
185
- fallback = Path(__file__).resolve().parent.parent / "static"
186
- return fallback, None
187
- stack = ExitStack()
188
- try:
189
- static_path = stack.enter_context(resources.as_file(static_root))
190
- except Exception:
191
- stack.close()
192
- fallback = Path(__file__).resolve().parent.parent / "static"
193
- return fallback, None
194
- if static_path.exists():
195
- return static_path, stack
196
- stack.close()
197
- fallback = Path(__file__).resolve().parent.parent / "static"
198
- return fallback, None
199
-
200
-
201
158
  def _cleanup_temp_dir(path: Path, logger: logging.Logger) -> None:
202
159
  try:
203
160
  shutil.rmtree(path)
@@ -0,0 +1,40 @@
1
+ """Workspace docs helpers (active context, decisions, spec).
2
+
3
+ Workspace docs are optional and live under `.codex-autorunner/workspace/`.
4
+ They are distinct from tickets, which live under `.codex-autorunner/tickets/`.
5
+ """
6
+
7
+ import hashlib
8
+ from pathlib import Path
9
+
10
+ from ..core.utils import canonicalize_path
11
+ from .paths import (
12
+ WORKSPACE_DOC_KINDS,
13
+ WorkspaceDocKind,
14
+ read_workspace_doc,
15
+ workspace_doc_path,
16
+ write_workspace_doc,
17
+ )
18
+
19
+ WORKSPACE_ID_HEX_LEN = 12
20
+
21
+
22
+ def canonical_workspace_root(path: Path) -> Path:
23
+ return canonicalize_path(path)
24
+
25
+
26
+ def workspace_id_for_path(path: Path) -> str:
27
+ canonical = canonical_workspace_root(path)
28
+ digest = hashlib.sha256(str(canonical).encode("utf-8")).hexdigest()
29
+ return digest[:WORKSPACE_ID_HEX_LEN]
30
+
31
+
32
+ __all__ = [
33
+ "WORKSPACE_DOC_KINDS",
34
+ "WorkspaceDocKind",
35
+ "workspace_doc_path",
36
+ "read_workspace_doc",
37
+ "write_workspace_doc",
38
+ "canonical_workspace_root",
39
+ "workspace_id_for_path",
40
+ ]
@@ -0,0 +1,319 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path, PurePosixPath
7
+ from typing import Literal, cast
8
+
9
+ from ..core import drafts as draft_utils
10
+
11
+ WorkspaceDocKind = Literal["active_context", "decisions", "spec"]
12
+ WORKSPACE_DOC_KINDS: tuple[WorkspaceDocKind, ...] = (
13
+ "active_context",
14
+ "decisions",
15
+ "spec",
16
+ )
17
+
18
+
19
+ @dataclass
20
+ class WorkspaceFile:
21
+ name: str
22
+ path: str # path relative to the workspace directory (POSIX)
23
+ is_pinned: bool = False
24
+ modified_at: str | None = None
25
+
26
+
27
+ def _normalize_kind(kind: str) -> WorkspaceDocKind:
28
+ key = (kind or "").strip().lower()
29
+ if key not in WORKSPACE_DOC_KINDS:
30
+ raise ValueError("invalid workspace doc kind")
31
+ return cast(WorkspaceDocKind, key)
32
+
33
+
34
+ def workspace_dir(repo_root: Path) -> Path:
35
+ return repo_root / ".codex-autorunner" / "workspace"
36
+
37
+
38
+ PINNED_DOC_FILENAMES = {f"{kind}.md" for kind in WORKSPACE_DOC_KINDS}
39
+
40
+
41
+ @dataclass
42
+ class WorkspaceNode:
43
+ name: str
44
+ path: str # relative to workspace dir
45
+ type: Literal["file", "folder"]
46
+ is_pinned: bool = False
47
+ modified_at: str | None = None
48
+ size: int | None = None # files only
49
+ children: list["WorkspaceNode"] | None = None # folders only
50
+
51
+
52
+ def normalize_workspace_rel_path(repo_root: Path, rel_path: str) -> tuple[Path, str]:
53
+ """Normalize a user-supplied workspace path and ensure it stays in-tree.
54
+
55
+ We accept POSIX-style relative paths only, then resolve the full path and
56
+ verify the result is still under the workspace directory. This guards
57
+ against ".." traversal and symlink escapes that CodeQL flagged.
58
+ """
59
+
60
+ base = workspace_dir(repo_root)
61
+ base_real = os.path.realpath(base)
62
+ cleaned = (rel_path or "").strip()
63
+ if not cleaned:
64
+ raise ValueError("invalid workspace file path")
65
+
66
+ relative = PurePosixPath(cleaned)
67
+ if relative.is_absolute() or ".." in relative.parts:
68
+ raise ValueError("invalid workspace file path")
69
+
70
+ # Normalize the relative path to collapse any sneaky segments
71
+ norm_relative = os.path.normpath(relative.as_posix())
72
+ if norm_relative in {".", ""}:
73
+ normalized = ""
74
+ else:
75
+ normalized = norm_relative
76
+
77
+ # Reject traversal or absolute inputs after normalization
78
+ if (
79
+ normalized.startswith("..")
80
+ or normalized.startswith("/")
81
+ or normalized.startswith("\\")
82
+ ):
83
+ raise ValueError("invalid workspace file path")
84
+
85
+ candidate_str = os.path.realpath(os.path.join(base_real, normalized))
86
+ # Ensure the resolved path stays under the workspace directory
87
+ if not (candidate_str == base_real or candidate_str.startswith(base_real + os.sep)):
88
+ raise ValueError("invalid workspace file path")
89
+
90
+ candidate = Path(candidate_str)
91
+ rel_posix = candidate.relative_to(base_real).as_posix()
92
+ return candidate, rel_posix
93
+
94
+
95
+ def sanitize_workspace_filename(filename: str) -> str:
96
+ """Return a safe filename for workspace uploads.
97
+
98
+ We strip any directory components, collapse whitespace, and guard against
99
+ empty names. Caller is responsible for applying any per-workspace policy
100
+ (e.g., overwrite vs. reject).
101
+ """
102
+
103
+ cleaned = (filename or "").strip()
104
+ # Drop any path fragments that may be embedded in the upload
105
+ base = PurePosixPath(cleaned).name
106
+ # Remove remaining separators/backslashes that PurePosixPath.name could keep
107
+ base = base.replace("/", "").replace("\\", "")
108
+ if base in {".", ".."}:
109
+ base = ""
110
+ # Collapse whitespace to single spaces to keep names readable
111
+ base = " ".join(base.split())
112
+ if not base:
113
+ return "upload"
114
+ return base
115
+
116
+
117
+ def workspace_doc_path(repo_root: Path, kind: str) -> Path:
118
+ key = _normalize_kind(kind)
119
+ return workspace_dir(repo_root) / f"{key}.md"
120
+
121
+
122
+ def read_workspace_file(
123
+ repo_root: Path, rel_path: str
124
+ ) -> str: # codeql[py/path-injection]
125
+ path, _ = normalize_workspace_rel_path(repo_root, rel_path)
126
+ if (
127
+ path.is_dir()
128
+ ): # codeql[py/path-injection] validated by normalize_workspace_rel_path
129
+ raise ValueError("path points to a directory")
130
+ if (
131
+ not path.exists()
132
+ ): # codeql[py/path-injection] validated by normalize_workspace_rel_path
133
+ return ""
134
+ return path.read_text(
135
+ encoding="utf-8"
136
+ ) # codeql[py/path-injection] validated by normalize_workspace_rel_path
137
+
138
+
139
+ def write_workspace_file( # codeql[py/path-injection]
140
+ repo_root: Path, rel_path: str, content: str
141
+ ) -> str:
142
+ path, rel_posix = normalize_workspace_rel_path(repo_root, rel_path)
143
+ if (
144
+ path.exists() and path.is_dir()
145
+ ): # codeql[py/path-injection] validated by normalize_workspace_rel_path
146
+ raise ValueError("path points to a directory")
147
+ path.parent.mkdir(
148
+ parents=True, exist_ok=True
149
+ ) # codeql[py/path-injection] validated by normalize_workspace_rel_path
150
+ path.write_text(
151
+ content or "", encoding="utf-8"
152
+ ) # codeql[py/path-injection] validated by normalize_workspace_rel_path
153
+ try:
154
+ rel = path.relative_to(repo_root).as_posix()
155
+ draft_utils.invalidate_drafts_for_path(repo_root, rel)
156
+ state_key = f"workspace_{rel_posix.replace('/', '_')}"
157
+ draft_utils.remove_draft(repo_root, state_key)
158
+ except Exception:
159
+ # best effort; do not block writes
160
+ pass
161
+ return path.read_text(encoding="utf-8")
162
+
163
+
164
+ def read_workspace_doc(repo_root: Path, kind: str) -> str:
165
+ path = workspace_doc_path(repo_root, kind)
166
+ if not path.exists():
167
+ return ""
168
+ return path.read_text(encoding="utf-8")
169
+
170
+
171
+ def write_workspace_doc( # codeql[py/path-injection]
172
+ repo_root: Path, kind: str, content: str
173
+ ) -> str:
174
+ path = workspace_doc_path(repo_root, kind)
175
+ path.parent.mkdir(parents=True, exist_ok=True)
176
+ path.write_text(
177
+ content or "", encoding="utf-8"
178
+ ) # codeql[py/path-injection] workspace_doc_path is deterministic
179
+ try:
180
+ rel = path.relative_to(repo_root).as_posix()
181
+ draft_utils.invalidate_drafts_for_path(repo_root, rel)
182
+ state_key = f"workspace_{rel.replace('/', '_')}"
183
+ draft_utils.remove_draft(repo_root, state_key)
184
+ except Exception:
185
+ pass
186
+ return path.read_text(encoding="utf-8")
187
+
188
+
189
+ def _format_mtime(path: Path) -> str | None:
190
+ if not path.exists():
191
+ return None
192
+ ts = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
193
+ return ts.isoformat()
194
+
195
+
196
+ def list_workspace_files(
197
+ repo_root: Path,
198
+ ) -> list[WorkspaceFile]: # codeql[py/path-injection]
199
+ base = workspace_dir(repo_root)
200
+ base.mkdir(parents=True, exist_ok=True)
201
+
202
+ pinned: list[WorkspaceFile] = []
203
+ for kind in WORKSPACE_DOC_KINDS:
204
+ path = workspace_doc_path(repo_root, kind)
205
+ rel = path.relative_to(base).as_posix()
206
+ pinned.append(
207
+ WorkspaceFile(
208
+ name=path.name,
209
+ path=rel,
210
+ is_pinned=True,
211
+ modified_at=_format_mtime(path),
212
+ )
213
+ )
214
+
215
+ others: list[WorkspaceFile] = []
216
+ if base.exists():
217
+ for file_path in base.rglob("*"):
218
+ if file_path.is_dir():
219
+ continue
220
+ try:
221
+ rel = file_path.relative_to(base).as_posix()
222
+ except ValueError:
223
+ continue
224
+ if any(rel == pinned_file.path for pinned_file in pinned):
225
+ continue
226
+ others.append(
227
+ WorkspaceFile(
228
+ name=file_path.name,
229
+ path=rel,
230
+ is_pinned=False,
231
+ modified_at=_format_mtime(file_path),
232
+ )
233
+ )
234
+
235
+ others.sort(key=lambda f: f.path)
236
+ return [*pinned, *others]
237
+
238
+
239
+ def _sort_workspace_children(path: Path) -> tuple[int, str]:
240
+ # Folders first, then files, both alphabetized (case-insensitive)
241
+ return (0 if path.is_dir() else 1, path.name.lower())
242
+
243
+
244
+ def _is_within_workspace(base_real: Path, candidate: Path) -> bool:
245
+ try:
246
+ candidate.resolve().relative_to(base_real)
247
+ return True
248
+ except Exception:
249
+ return False
250
+
251
+
252
+ def _file_node(base: Path, path: Path, is_pinned: bool = False) -> WorkspaceNode:
253
+ rel = path.relative_to(base).as_posix()
254
+ size: int | None = None
255
+ if path.exists() and path.is_file():
256
+ try:
257
+ size = path.stat().st_size
258
+ except OSError:
259
+ size = None
260
+ return WorkspaceNode(
261
+ name=path.name,
262
+ path=rel,
263
+ type="file",
264
+ is_pinned=is_pinned,
265
+ modified_at=_format_mtime(path),
266
+ size=size,
267
+ )
268
+
269
+
270
+ def _build_workspace_tree(base: Path, path: Path) -> WorkspaceNode:
271
+ is_symlink = path.is_symlink()
272
+ is_folder = path.is_dir() and not is_symlink
273
+ is_pinned = path.name in PINNED_DOC_FILENAMES and path.parent == base
274
+
275
+ if not is_folder:
276
+ return _file_node(base, path, is_pinned=is_pinned)
277
+
278
+ children: list[WorkspaceNode] = []
279
+ for child in sorted(path.iterdir(), key=_sort_workspace_children):
280
+ # Avoid duplicating pinned docs surfaced at the root list
281
+ if child.parent == base and child.name in PINNED_DOC_FILENAMES:
282
+ continue
283
+ # Skip symlink escapes that resolve outside the workspace
284
+ if child.is_symlink() and not _is_within_workspace(base.resolve(), child):
285
+ continue
286
+ children.append(_build_workspace_tree(base, child))
287
+
288
+ return WorkspaceNode(
289
+ name=path.name,
290
+ path=path.relative_to(base).as_posix(),
291
+ type="folder",
292
+ is_pinned=False,
293
+ modified_at=_format_mtime(path),
294
+ children=children,
295
+ )
296
+
297
+
298
+ def list_workspace_tree(repo_root: Path) -> list[WorkspaceNode]:
299
+ """Return hierarchical workspace structure (folders + files)."""
300
+
301
+ base = workspace_dir(repo_root)
302
+ base.mkdir(parents=True, exist_ok=True)
303
+ base_real = base.resolve()
304
+
305
+ nodes: list[WorkspaceNode] = []
306
+
307
+ # Pinned docs first (even if missing)
308
+ for name in sorted(PINNED_DOC_FILENAMES):
309
+ pinned_path = base / name
310
+ nodes.append(_file_node(base, pinned_path, is_pinned=True))
311
+
312
+ for child in sorted(base.iterdir(), key=_sort_workspace_children):
313
+ if child.parent == base and child.name in PINNED_DOC_FILENAMES:
314
+ continue
315
+ if child.is_symlink() and not _is_within_workspace(base_real, child):
316
+ continue
317
+ nodes.append(_build_workspace_tree(base, child))
318
+
319
+ return nodes