galangal-orchestrate 0.13.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.
- galangal/__init__.py +36 -0
- galangal/__main__.py +6 -0
- galangal/ai/__init__.py +167 -0
- galangal/ai/base.py +159 -0
- galangal/ai/claude.py +352 -0
- galangal/ai/codex.py +370 -0
- galangal/ai/gemini.py +43 -0
- galangal/ai/subprocess.py +254 -0
- galangal/cli.py +371 -0
- galangal/commands/__init__.py +27 -0
- galangal/commands/complete.py +367 -0
- galangal/commands/github.py +355 -0
- galangal/commands/init.py +177 -0
- galangal/commands/init_wizard.py +762 -0
- galangal/commands/list.py +20 -0
- galangal/commands/pause.py +34 -0
- galangal/commands/prompts.py +89 -0
- galangal/commands/reset.py +41 -0
- galangal/commands/resume.py +30 -0
- galangal/commands/skip.py +62 -0
- galangal/commands/start.py +530 -0
- galangal/commands/status.py +44 -0
- galangal/commands/switch.py +28 -0
- galangal/config/__init__.py +15 -0
- galangal/config/defaults.py +183 -0
- galangal/config/loader.py +163 -0
- galangal/config/schema.py +330 -0
- galangal/core/__init__.py +33 -0
- galangal/core/artifacts.py +136 -0
- galangal/core/state.py +1097 -0
- galangal/core/tasks.py +454 -0
- galangal/core/utils.py +116 -0
- galangal/core/workflow/__init__.py +68 -0
- galangal/core/workflow/core.py +789 -0
- galangal/core/workflow/engine.py +781 -0
- galangal/core/workflow/pause.py +35 -0
- galangal/core/workflow/tui_runner.py +1322 -0
- galangal/exceptions.py +36 -0
- galangal/github/__init__.py +31 -0
- galangal/github/client.py +427 -0
- galangal/github/images.py +324 -0
- galangal/github/issues.py +298 -0
- galangal/logging.py +364 -0
- galangal/prompts/__init__.py +5 -0
- galangal/prompts/builder.py +527 -0
- galangal/prompts/defaults/benchmark.md +34 -0
- galangal/prompts/defaults/contract.md +35 -0
- galangal/prompts/defaults/design.md +54 -0
- galangal/prompts/defaults/dev.md +89 -0
- galangal/prompts/defaults/docs.md +104 -0
- galangal/prompts/defaults/migration.md +59 -0
- galangal/prompts/defaults/pm.md +110 -0
- galangal/prompts/defaults/pm_questions.md +53 -0
- galangal/prompts/defaults/preflight.md +32 -0
- galangal/prompts/defaults/qa.md +65 -0
- galangal/prompts/defaults/review.md +90 -0
- galangal/prompts/defaults/review_codex.md +99 -0
- galangal/prompts/defaults/security.md +84 -0
- galangal/prompts/defaults/test.md +91 -0
- galangal/results.py +176 -0
- galangal/ui/__init__.py +5 -0
- galangal/ui/console.py +126 -0
- galangal/ui/tui/__init__.py +56 -0
- galangal/ui/tui/adapters.py +168 -0
- galangal/ui/tui/app.py +902 -0
- galangal/ui/tui/entry.py +24 -0
- galangal/ui/tui/mixins.py +196 -0
- galangal/ui/tui/modals.py +339 -0
- galangal/ui/tui/styles/app.tcss +86 -0
- galangal/ui/tui/styles/modals.tcss +197 -0
- galangal/ui/tui/types.py +107 -0
- galangal/ui/tui/widgets.py +263 -0
- galangal/validation/__init__.py +5 -0
- galangal/validation/runner.py +1072 -0
- galangal_orchestrate-0.13.0.dist-info/METADATA +985 -0
- galangal_orchestrate-0.13.0.dist-info/RECORD +79 -0
- galangal_orchestrate-0.13.0.dist-info/WHEEL +4 -0
- galangal_orchestrate-0.13.0.dist-info/entry_points.txt +2 -0
- galangal_orchestrate-0.13.0.dist-info/licenses/LICENSE +674 -0
galangal/ai/codex.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Codex CLI backend implementation for read-only code review.
|
|
3
|
+
|
|
4
|
+
Uses OpenAI's Codex in non-interactive mode with structured JSON output.
|
|
5
|
+
See: https://developers.openai.com/codex/noninteractive
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import time
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from galangal.ai.base import AIBackend, PauseCheck
|
|
17
|
+
from galangal.ai.subprocess import SubprocessRunner
|
|
18
|
+
from galangal.config.loader import get_project_root
|
|
19
|
+
from galangal.results import StageResult
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from galangal.ui.tui import StageUI
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_output_schema(stage: str | None) -> dict[str, Any]:
|
|
26
|
+
"""
|
|
27
|
+
Build stage-specific JSON output schema.
|
|
28
|
+
|
|
29
|
+
Derives artifact field names and decision values from STAGE_METADATA.
|
|
30
|
+
Falls back to generic review schema if stage not found.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
stage: Stage name (e.g., "QA", "SECURITY", "REVIEW")
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
JSON schema dict for structured output
|
|
37
|
+
"""
|
|
38
|
+
from galangal.core.state import Stage, get_decision_values
|
|
39
|
+
|
|
40
|
+
# Defaults for unknown or unspecified stages
|
|
41
|
+
notes_field = "review_notes"
|
|
42
|
+
notes_description = "Full review findings in markdown format"
|
|
43
|
+
decision_values = ["APPROVE", "REQUEST_CHANGES"]
|
|
44
|
+
|
|
45
|
+
if stage:
|
|
46
|
+
try:
|
|
47
|
+
stage_enum = Stage.from_str(stage)
|
|
48
|
+
metadata = stage_enum.metadata
|
|
49
|
+
|
|
50
|
+
# Derive notes field from produces_artifacts
|
|
51
|
+
if metadata.produces_artifacts:
|
|
52
|
+
artifact_name = metadata.produces_artifacts[0]
|
|
53
|
+
# Convert "QA_REPORT.md" -> "qa_report"
|
|
54
|
+
notes_field = artifact_name.lower().replace(".md", "")
|
|
55
|
+
notes_description = f"{metadata.display_name} findings in markdown format"
|
|
56
|
+
|
|
57
|
+
# Get decision values from metadata
|
|
58
|
+
values = get_decision_values(stage_enum)
|
|
59
|
+
if values:
|
|
60
|
+
decision_values = values
|
|
61
|
+
|
|
62
|
+
except (ValueError, AttributeError):
|
|
63
|
+
# Stage not found or invalid, use defaults
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"type": "object",
|
|
68
|
+
"properties": {
|
|
69
|
+
notes_field: {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"description": notes_description,
|
|
72
|
+
},
|
|
73
|
+
"decision": {
|
|
74
|
+
"type": "string",
|
|
75
|
+
"enum": decision_values,
|
|
76
|
+
"description": "Stage decision",
|
|
77
|
+
},
|
|
78
|
+
"issues": {
|
|
79
|
+
"type": "array",
|
|
80
|
+
"description": "List of specific issues found",
|
|
81
|
+
"items": {
|
|
82
|
+
"type": "object",
|
|
83
|
+
"properties": {
|
|
84
|
+
"severity": {
|
|
85
|
+
"type": "string",
|
|
86
|
+
"enum": ["critical", "major", "minor", "suggestion"],
|
|
87
|
+
},
|
|
88
|
+
"file": {"type": "string"},
|
|
89
|
+
"line": {"type": "integer"},
|
|
90
|
+
"description": {"type": "string"},
|
|
91
|
+
},
|
|
92
|
+
"required": ["severity", "file", "line", "description"],
|
|
93
|
+
"additionalProperties": False,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
"required": [notes_field, "decision", "issues"],
|
|
98
|
+
"additionalProperties": False,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class CodexBackend(AIBackend):
|
|
103
|
+
"""
|
|
104
|
+
Codex CLI backend for read-only code review.
|
|
105
|
+
|
|
106
|
+
Key characteristics:
|
|
107
|
+
- Runs in read-only sandbox by default (cannot write files)
|
|
108
|
+
- Uses --output-schema for structured JSON output
|
|
109
|
+
- Artifacts must be written by post-processing the output
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
# Default command and args when no config provided
|
|
113
|
+
DEFAULT_COMMAND = "codex"
|
|
114
|
+
DEFAULT_ARGS = [
|
|
115
|
+
"exec",
|
|
116
|
+
"--full-auto",
|
|
117
|
+
"--output-schema",
|
|
118
|
+
"{schema_file}",
|
|
119
|
+
"-o",
|
|
120
|
+
"{output_file}",
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def name(self) -> str:
|
|
125
|
+
return "codex"
|
|
126
|
+
|
|
127
|
+
def _build_command(
|
|
128
|
+
self,
|
|
129
|
+
prompt_file: str,
|
|
130
|
+
schema_file: str,
|
|
131
|
+
output_file: str,
|
|
132
|
+
) -> str:
|
|
133
|
+
"""
|
|
134
|
+
Build the shell command to invoke Codex.
|
|
135
|
+
|
|
136
|
+
Uses config.command and config.args if available, otherwise falls back
|
|
137
|
+
to hard-coded defaults for backwards compatibility.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
prompt_file: Path to temp file containing the prompt
|
|
141
|
+
schema_file: Path to JSON schema file
|
|
142
|
+
output_file: Path for structured output
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Shell command string ready for subprocess
|
|
146
|
+
"""
|
|
147
|
+
if self._config:
|
|
148
|
+
command = self._config.command
|
|
149
|
+
args = self._substitute_placeholders(
|
|
150
|
+
self._config.args,
|
|
151
|
+
schema_file=schema_file,
|
|
152
|
+
output_file=output_file,
|
|
153
|
+
)
|
|
154
|
+
else:
|
|
155
|
+
# Backwards compatibility: use defaults
|
|
156
|
+
command = self.DEFAULT_COMMAND
|
|
157
|
+
args = self._substitute_placeholders(
|
|
158
|
+
self.DEFAULT_ARGS,
|
|
159
|
+
schema_file=schema_file,
|
|
160
|
+
output_file=output_file,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
args_str = " ".join(f"'{a}'" if " " in a else a for a in args)
|
|
164
|
+
return f"cat '{prompt_file}' | {command} {args_str}"
|
|
165
|
+
|
|
166
|
+
def invoke(
|
|
167
|
+
self,
|
|
168
|
+
prompt: str,
|
|
169
|
+
timeout: int = 14400,
|
|
170
|
+
max_turns: int = 200,
|
|
171
|
+
ui: StageUI | None = None,
|
|
172
|
+
pause_check: PauseCheck | None = None,
|
|
173
|
+
stage: str | None = None,
|
|
174
|
+
log_file: str | None = None,
|
|
175
|
+
) -> StageResult:
|
|
176
|
+
"""
|
|
177
|
+
Invoke Codex in non-interactive read-only mode.
|
|
178
|
+
|
|
179
|
+
Uses --output-schema to enforce structured JSON output with:
|
|
180
|
+
- Stage-specific notes field (qa_report, security_checklist, review_notes)
|
|
181
|
+
- decision: Stage-appropriate values (PASS/FAIL, APPROVED/REJECTED, etc.)
|
|
182
|
+
- issues: Array of specific problems found
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
prompt: The full prompt to send
|
|
186
|
+
timeout: Maximum time in seconds
|
|
187
|
+
max_turns: Maximum conversation turns (unused for Codex)
|
|
188
|
+
ui: Optional TUI for progress display
|
|
189
|
+
pause_check: Optional callback for pause detection
|
|
190
|
+
stage: Stage name for schema customization (e.g., "QA", "SECURITY")
|
|
191
|
+
log_file: Optional path to log file (unused for Codex read-only)
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
StageResult with structured JSON in the output field
|
|
195
|
+
"""
|
|
196
|
+
# Track timing for activity updates
|
|
197
|
+
start_time = time.time()
|
|
198
|
+
last_activity_time = start_time
|
|
199
|
+
|
|
200
|
+
def on_output(line: str) -> None:
|
|
201
|
+
"""Process each output line."""
|
|
202
|
+
nonlocal last_activity_time
|
|
203
|
+
line = line.strip()
|
|
204
|
+
# Show meaningful output lines, skip raw JSON
|
|
205
|
+
if line and not line.startswith("{"):
|
|
206
|
+
if ui:
|
|
207
|
+
ui.add_activity(f"codex: {line[:80]}", "💬")
|
|
208
|
+
last_activity_time = time.time()
|
|
209
|
+
|
|
210
|
+
def on_idle(elapsed: float) -> None:
|
|
211
|
+
"""Update status periodically."""
|
|
212
|
+
nonlocal last_activity_time
|
|
213
|
+
if not ui:
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
# Update status with elapsed time
|
|
217
|
+
minutes = int(elapsed // 60)
|
|
218
|
+
seconds = int(elapsed % 60)
|
|
219
|
+
time_str = f"{minutes}m {seconds}s" if minutes > 0 else f"{seconds}s"
|
|
220
|
+
ui.set_status("running", f"Codex reviewing code ({time_str})")
|
|
221
|
+
|
|
222
|
+
# Add activity update if no output for 30 seconds
|
|
223
|
+
current_time = time.time()
|
|
224
|
+
if current_time - last_activity_time >= 30.0:
|
|
225
|
+
if minutes > 0:
|
|
226
|
+
ui.add_activity(f"Still reviewing... ({minutes}m elapsed)", "⏳")
|
|
227
|
+
else:
|
|
228
|
+
ui.add_activity("Still reviewing...", "⏳")
|
|
229
|
+
last_activity_time = current_time
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
# Create temp files for prompt, schema, and output
|
|
233
|
+
output_schema = _build_output_schema(stage)
|
|
234
|
+
schema_content = json.dumps(output_schema)
|
|
235
|
+
with (
|
|
236
|
+
self._temp_file(prompt, suffix=".txt") as prompt_file,
|
|
237
|
+
self._temp_file(schema_content, suffix=".json") as schema_file,
|
|
238
|
+
self._temp_file(suffix=".json") as output_file,
|
|
239
|
+
):
|
|
240
|
+
if ui:
|
|
241
|
+
ui.set_status("starting", "initializing Codex")
|
|
242
|
+
|
|
243
|
+
shell_cmd = self._build_command(prompt_file, schema_file, output_file)
|
|
244
|
+
|
|
245
|
+
if ui:
|
|
246
|
+
ui.set_status("running", "Codex reviewing code")
|
|
247
|
+
ui.add_activity("Codex code review started", "🔍")
|
|
248
|
+
|
|
249
|
+
runner = SubprocessRunner(
|
|
250
|
+
command=shell_cmd,
|
|
251
|
+
timeout=timeout,
|
|
252
|
+
pause_check=pause_check,
|
|
253
|
+
ui=ui,
|
|
254
|
+
on_output=on_output,
|
|
255
|
+
on_idle=on_idle,
|
|
256
|
+
idle_interval=5.0,
|
|
257
|
+
poll_interval_active=0.05,
|
|
258
|
+
poll_interval_idle=0.5,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
result = runner.run()
|
|
262
|
+
|
|
263
|
+
if result.paused:
|
|
264
|
+
if ui:
|
|
265
|
+
ui.finish(success=False)
|
|
266
|
+
return StageResult.paused()
|
|
267
|
+
|
|
268
|
+
if result.timed_out:
|
|
269
|
+
return StageResult.timeout(result.timeout_seconds or timeout)
|
|
270
|
+
|
|
271
|
+
# Process completed
|
|
272
|
+
if result.exit_code != 0:
|
|
273
|
+
if ui:
|
|
274
|
+
ui.add_activity(f"Codex failed (exit {result.exit_code})", "❌")
|
|
275
|
+
ui.finish(success=False)
|
|
276
|
+
return StageResult.error(
|
|
277
|
+
message=f"Codex failed (exit {result.exit_code})",
|
|
278
|
+
output=result.output,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Read the structured output
|
|
282
|
+
if not os.path.exists(output_file):
|
|
283
|
+
if ui:
|
|
284
|
+
ui.add_activity("No output file generated", "❌")
|
|
285
|
+
ui.finish(success=False)
|
|
286
|
+
debug_output = f"Expected output at: {output_file}\nOutput:\n{result.output}"
|
|
287
|
+
return StageResult.error(
|
|
288
|
+
message="Codex did not produce output file. Check if --output-schema is supported.",
|
|
289
|
+
output=debug_output,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
with open(output_file, encoding="utf-8") as f:
|
|
293
|
+
output_content = f.read()
|
|
294
|
+
|
|
295
|
+
# Validate JSON structure
|
|
296
|
+
try:
|
|
297
|
+
output_data = json.loads(output_content)
|
|
298
|
+
decision = output_data.get("decision", "")
|
|
299
|
+
|
|
300
|
+
if ui:
|
|
301
|
+
issues_count = len(output_data.get("issues", []))
|
|
302
|
+
elapsed = time.time() - start_time
|
|
303
|
+
minutes = int(elapsed // 60)
|
|
304
|
+
seconds = int(elapsed % 60)
|
|
305
|
+
time_str = f"{minutes}m {seconds}s" if minutes > 0 else f"{seconds}s"
|
|
306
|
+
|
|
307
|
+
if decision == "APPROVE":
|
|
308
|
+
ui.add_activity(
|
|
309
|
+
f"Review complete: APPROVED ({issues_count} suggestions) in {time_str}",
|
|
310
|
+
"✅",
|
|
311
|
+
)
|
|
312
|
+
else:
|
|
313
|
+
ui.add_activity(
|
|
314
|
+
f"Review complete: {issues_count} issues found in {time_str}",
|
|
315
|
+
"⚠️",
|
|
316
|
+
)
|
|
317
|
+
ui.finish(success=True)
|
|
318
|
+
|
|
319
|
+
return StageResult.create_success(
|
|
320
|
+
message=f"Codex review complete: {decision}",
|
|
321
|
+
output=output_content,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
except json.JSONDecodeError as e:
|
|
325
|
+
if ui:
|
|
326
|
+
ui.add_activity("Invalid JSON output", "❌")
|
|
327
|
+
ui.finish(success=False)
|
|
328
|
+
return StageResult.error(
|
|
329
|
+
message=f"Codex output is not valid JSON: {e}",
|
|
330
|
+
output=output_content,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
except Exception as e:
|
|
334
|
+
if ui:
|
|
335
|
+
ui.finish(success=False)
|
|
336
|
+
return StageResult.error(f"Codex invocation error: {e}")
|
|
337
|
+
|
|
338
|
+
def generate_text(self, prompt: str, timeout: int = 30) -> str:
|
|
339
|
+
"""
|
|
340
|
+
Simple text generation using Codex.
|
|
341
|
+
|
|
342
|
+
Note: For simple text generation, we use codex exec without
|
|
343
|
+
structured output schema.
|
|
344
|
+
"""
|
|
345
|
+
try:
|
|
346
|
+
with (
|
|
347
|
+
self._temp_file(prompt, suffix=".txt") as prompt_file,
|
|
348
|
+
self._temp_file(suffix=".txt") as output_file,
|
|
349
|
+
):
|
|
350
|
+
# Use config command or default
|
|
351
|
+
command = self._config.command if self._config else self.DEFAULT_COMMAND
|
|
352
|
+
shell_cmd = f"cat '{prompt_file}' | {command} exec -o '{output_file}'"
|
|
353
|
+
|
|
354
|
+
result = subprocess.run(
|
|
355
|
+
shell_cmd,
|
|
356
|
+
shell=True,
|
|
357
|
+
cwd=get_project_root(),
|
|
358
|
+
capture_output=True,
|
|
359
|
+
text=True,
|
|
360
|
+
timeout=timeout,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
if result.returncode == 0 and os.path.exists(output_file):
|
|
364
|
+
with open(output_file, encoding="utf-8") as f:
|
|
365
|
+
return f.read().strip()
|
|
366
|
+
|
|
367
|
+
except (subprocess.TimeoutExpired, Exception):
|
|
368
|
+
pass
|
|
369
|
+
|
|
370
|
+
return ""
|
galangal/ai/gemini.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Gemini backend implementation (stub for future use).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from galangal.ai.base import AIBackend, PauseCheck
|
|
10
|
+
from galangal.results import StageResult
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from galangal.ui.tui import StageUI
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GeminiBackend(AIBackend):
|
|
17
|
+
"""
|
|
18
|
+
Gemini backend (stub implementation).
|
|
19
|
+
|
|
20
|
+
TODO: Implement when Gemini CLI or API support is added.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def name(self) -> str:
|
|
25
|
+
return "gemini"
|
|
26
|
+
|
|
27
|
+
def invoke(
|
|
28
|
+
self,
|
|
29
|
+
prompt: str,
|
|
30
|
+
timeout: int = 14400,
|
|
31
|
+
max_turns: int = 200,
|
|
32
|
+
ui: StageUI | None = None,
|
|
33
|
+
pause_check: PauseCheck | None = None,
|
|
34
|
+
stage: str | None = None,
|
|
35
|
+
) -> StageResult:
|
|
36
|
+
"""Invoke Gemini with a prompt."""
|
|
37
|
+
# TODO: Implement Gemini invocation
|
|
38
|
+
return StageResult.error("Gemini backend not yet implemented")
|
|
39
|
+
|
|
40
|
+
def generate_text(self, prompt: str, timeout: int = 30) -> str:
|
|
41
|
+
"""Simple text generation with Gemini."""
|
|
42
|
+
# TODO: Implement Gemini text generation
|
|
43
|
+
return ""
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Subprocess runner with pause/timeout handling for AI backends.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import select
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from galangal.config.loader import get_project_root
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from galangal.ui.tui import StageUI
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RunOutcome(Enum):
|
|
22
|
+
"""Outcome of a subprocess run."""
|
|
23
|
+
|
|
24
|
+
COMPLETED = "completed"
|
|
25
|
+
PAUSED = "paused"
|
|
26
|
+
TIMEOUT = "timeout"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class RunResult:
|
|
31
|
+
"""Result of running a subprocess."""
|
|
32
|
+
|
|
33
|
+
outcome: RunOutcome
|
|
34
|
+
exit_code: int | None
|
|
35
|
+
output: str
|
|
36
|
+
timeout_seconds: int | None = None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def completed(self) -> bool:
|
|
40
|
+
return self.outcome == RunOutcome.COMPLETED
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def paused(self) -> bool:
|
|
44
|
+
return self.outcome == RunOutcome.PAUSED
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def timed_out(self) -> bool:
|
|
48
|
+
return self.outcome == RunOutcome.TIMEOUT
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Type aliases
|
|
52
|
+
PauseCheck = Callable[[], bool]
|
|
53
|
+
OutputCallback = Callable[[str], None]
|
|
54
|
+
IdleCallback = Callable[[float], None] # Called with elapsed seconds
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SubprocessRunner:
|
|
58
|
+
"""
|
|
59
|
+
Manages subprocess lifecycle with pause/timeout support.
|
|
60
|
+
|
|
61
|
+
Consolidates the common subprocess handling pattern used by AI backends:
|
|
62
|
+
- Non-blocking output reading with select()
|
|
63
|
+
- Pause request handling (graceful termination)
|
|
64
|
+
- Timeout handling
|
|
65
|
+
- Periodic idle callbacks for status updates
|
|
66
|
+
|
|
67
|
+
Usage:
|
|
68
|
+
runner = SubprocessRunner(
|
|
69
|
+
command="cat prompt.txt | claude --verbose",
|
|
70
|
+
timeout=3600,
|
|
71
|
+
pause_check=lambda: user_requested_pause,
|
|
72
|
+
on_output=lambda line: process_line(line),
|
|
73
|
+
on_idle=lambda elapsed: update_status(elapsed),
|
|
74
|
+
)
|
|
75
|
+
result = runner.run()
|
|
76
|
+
if result.completed:
|
|
77
|
+
# Process result.output
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
command: str,
|
|
83
|
+
timeout: int = 14400,
|
|
84
|
+
pause_check: PauseCheck | None = None,
|
|
85
|
+
ui: StageUI | None = None,
|
|
86
|
+
on_output: OutputCallback | None = None,
|
|
87
|
+
on_idle: IdleCallback | None = None,
|
|
88
|
+
idle_interval: float = 3.0,
|
|
89
|
+
poll_interval_active: float = 0.05,
|
|
90
|
+
poll_interval_idle: float = 0.5,
|
|
91
|
+
):
|
|
92
|
+
"""
|
|
93
|
+
Initialize the subprocess runner.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
command: Shell command to execute
|
|
97
|
+
timeout: Maximum runtime in seconds
|
|
98
|
+
pause_check: Callback returning True if pause requested
|
|
99
|
+
ui: Optional TUI for basic status updates
|
|
100
|
+
on_output: Callback for each output line
|
|
101
|
+
on_idle: Callback when idle (no output), receives elapsed seconds
|
|
102
|
+
idle_interval: Seconds between idle callbacks
|
|
103
|
+
poll_interval_active: Sleep between polls when receiving output
|
|
104
|
+
poll_interval_idle: Sleep between polls when idle
|
|
105
|
+
"""
|
|
106
|
+
self.command = command
|
|
107
|
+
self.timeout = timeout
|
|
108
|
+
self.pause_check = pause_check
|
|
109
|
+
self.ui = ui
|
|
110
|
+
self.on_output = on_output
|
|
111
|
+
self.on_idle = on_idle
|
|
112
|
+
self.idle_interval = idle_interval
|
|
113
|
+
self.poll_interval_active = poll_interval_active
|
|
114
|
+
self.poll_interval_idle = poll_interval_idle
|
|
115
|
+
|
|
116
|
+
def run(self) -> RunResult:
|
|
117
|
+
"""
|
|
118
|
+
Run the subprocess with pause/timeout handling.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
RunResult with outcome, exit code, and captured output
|
|
122
|
+
"""
|
|
123
|
+
process = subprocess.Popen(
|
|
124
|
+
self.command,
|
|
125
|
+
shell=True,
|
|
126
|
+
cwd=get_project_root(),
|
|
127
|
+
stdout=subprocess.PIPE,
|
|
128
|
+
stderr=subprocess.STDOUT,
|
|
129
|
+
text=True,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
output_lines: list[str] = []
|
|
133
|
+
start_time = time.time()
|
|
134
|
+
last_idle_callback = start_time
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
while True:
|
|
138
|
+
retcode = process.poll()
|
|
139
|
+
|
|
140
|
+
# Read available output (non-blocking)
|
|
141
|
+
had_output = self._read_output(process, output_lines)
|
|
142
|
+
|
|
143
|
+
# Update last idle callback time if we had output
|
|
144
|
+
if had_output:
|
|
145
|
+
last_idle_callback = time.time()
|
|
146
|
+
|
|
147
|
+
# Process completed
|
|
148
|
+
if retcode is not None:
|
|
149
|
+
break
|
|
150
|
+
|
|
151
|
+
# Check for pause request
|
|
152
|
+
if self.pause_check and self.pause_check():
|
|
153
|
+
self._terminate_gracefully(process)
|
|
154
|
+
if self.ui:
|
|
155
|
+
self.ui.add_activity("Paused by user request", "⏸️")
|
|
156
|
+
return RunResult(
|
|
157
|
+
outcome=RunOutcome.PAUSED,
|
|
158
|
+
exit_code=None,
|
|
159
|
+
output="".join(output_lines),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Check for timeout
|
|
163
|
+
elapsed = time.time() - start_time
|
|
164
|
+
if elapsed > self.timeout:
|
|
165
|
+
process.kill()
|
|
166
|
+
if self.ui:
|
|
167
|
+
self.ui.add_activity(f"Timeout after {self.timeout}s", "❌")
|
|
168
|
+
return RunResult(
|
|
169
|
+
outcome=RunOutcome.TIMEOUT,
|
|
170
|
+
exit_code=None,
|
|
171
|
+
output="".join(output_lines),
|
|
172
|
+
timeout_seconds=self.timeout,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Idle callback for status updates
|
|
176
|
+
current_time = time.time()
|
|
177
|
+
if self.on_idle and current_time - last_idle_callback >= self.idle_interval:
|
|
178
|
+
self.on_idle(elapsed)
|
|
179
|
+
last_idle_callback = current_time
|
|
180
|
+
|
|
181
|
+
# Adaptive sleep
|
|
182
|
+
time.sleep(self.poll_interval_active if had_output else self.poll_interval_idle)
|
|
183
|
+
|
|
184
|
+
# Capture any remaining output
|
|
185
|
+
remaining = self._capture_remaining(process)
|
|
186
|
+
if remaining:
|
|
187
|
+
output_lines.append(remaining)
|
|
188
|
+
|
|
189
|
+
return RunResult(
|
|
190
|
+
outcome=RunOutcome.COMPLETED,
|
|
191
|
+
exit_code=process.returncode,
|
|
192
|
+
output="".join(output_lines),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
except Exception:
|
|
196
|
+
# Ensure process is terminated on any error
|
|
197
|
+
try:
|
|
198
|
+
process.kill()
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
raise
|
|
202
|
+
|
|
203
|
+
def _read_output(
|
|
204
|
+
self,
|
|
205
|
+
process: subprocess.Popen[str],
|
|
206
|
+
output_lines: list[str],
|
|
207
|
+
) -> bool:
|
|
208
|
+
"""
|
|
209
|
+
Read all available output lines (non-blocking).
|
|
210
|
+
|
|
211
|
+
Returns True if any output was read.
|
|
212
|
+
"""
|
|
213
|
+
had_output = False
|
|
214
|
+
|
|
215
|
+
if not process.stdout:
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
while True:
|
|
220
|
+
ready, _, _ = select.select([process.stdout], [], [], 0)
|
|
221
|
+
if not ready:
|
|
222
|
+
break
|
|
223
|
+
|
|
224
|
+
line = process.stdout.readline()
|
|
225
|
+
if not line:
|
|
226
|
+
break
|
|
227
|
+
|
|
228
|
+
output_lines.append(line)
|
|
229
|
+
had_output = True
|
|
230
|
+
|
|
231
|
+
if self.on_output:
|
|
232
|
+
self.on_output(line)
|
|
233
|
+
|
|
234
|
+
except (ValueError, TypeError, OSError):
|
|
235
|
+
# select() may fail on non-selectable streams
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
return had_output
|
|
239
|
+
|
|
240
|
+
def _terminate_gracefully(self, process: subprocess.Popen[str]) -> None:
|
|
241
|
+
"""Terminate process gracefully, then force kill if needed."""
|
|
242
|
+
process.terminate()
|
|
243
|
+
try:
|
|
244
|
+
process.wait(timeout=5)
|
|
245
|
+
except subprocess.TimeoutExpired:
|
|
246
|
+
process.kill()
|
|
247
|
+
|
|
248
|
+
def _capture_remaining(self, process: subprocess.Popen[str]) -> str:
|
|
249
|
+
"""Capture any remaining output after process completes."""
|
|
250
|
+
try:
|
|
251
|
+
remaining, _ = process.communicate(timeout=10)
|
|
252
|
+
return remaining or ""
|
|
253
|
+
except (OSError, ValueError, subprocess.TimeoutExpired):
|
|
254
|
+
return ""
|