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
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core workflow utilities - stage execution, rollback handling.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from galangal.ai import get_backend_for_stage
|
|
11
|
+
from galangal.ai.base import PauseCheck
|
|
12
|
+
from galangal.config.loader import get_config
|
|
13
|
+
from galangal.core.artifacts import artifact_exists, artifact_path, read_artifact, write_artifact
|
|
14
|
+
from galangal.core.state import (
|
|
15
|
+
STAGE_ORDER,
|
|
16
|
+
Stage,
|
|
17
|
+
WorkflowState,
|
|
18
|
+
get_conditional_stages,
|
|
19
|
+
get_task_dir,
|
|
20
|
+
save_state,
|
|
21
|
+
should_skip_for_task_type,
|
|
22
|
+
)
|
|
23
|
+
from galangal.core.utils import now_iso
|
|
24
|
+
from galangal.prompts.builder import PromptBuilder
|
|
25
|
+
from galangal.results import StageResult, StageResultType
|
|
26
|
+
from galangal.ui.tui import TUIAdapter
|
|
27
|
+
from galangal.validation.runner import ValidationRunner
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from galangal.ui.tui import WorkflowTUIApp
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Get conditional stages from metadata (cached at module load)
|
|
34
|
+
CONDITIONAL_STAGES: dict[Stage, str] = get_conditional_stages()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _format_issues(issues: list[dict[str, Any]]) -> str:
|
|
38
|
+
"""Format issues list into markdown."""
|
|
39
|
+
if not issues:
|
|
40
|
+
return ""
|
|
41
|
+
|
|
42
|
+
formatted = "\n\n## Issues Found\n\n"
|
|
43
|
+
for issue in issues:
|
|
44
|
+
severity = issue.get("severity", "unknown")
|
|
45
|
+
desc = issue.get("description", "")
|
|
46
|
+
file_ref = issue.get("file", "")
|
|
47
|
+
line = issue.get("line")
|
|
48
|
+
loc = f" ({file_ref}:{line})" if file_ref and line else ""
|
|
49
|
+
formatted += f"- **[{severity.upper()}]** {desc}{loc}\n"
|
|
50
|
+
return formatted
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _write_artifacts_from_readonly_output(
|
|
54
|
+
stage: Stage,
|
|
55
|
+
output: str,
|
|
56
|
+
task_name: str,
|
|
57
|
+
tui_app: WorkflowTUIApp,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Write stage artifacts from read-only backend's structured JSON output.
|
|
61
|
+
|
|
62
|
+
Read-only backends (like Codex) cannot write files directly. Instead,
|
|
63
|
+
they return structured JSON which we post-process to create the expected
|
|
64
|
+
artifacts based on STAGE_ARTIFACT_SCHEMA.
|
|
65
|
+
|
|
66
|
+
Supports two modes:
|
|
67
|
+
1. Schema-based: Uses STAGE_METADATA artifact_schema mapping
|
|
68
|
+
2. Generic fallback: Looks for 'artifacts' array in JSON output
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
stage: The stage that was executed
|
|
72
|
+
output: JSON string containing structured output
|
|
73
|
+
task_name: Task name for artifact paths
|
|
74
|
+
tui_app: TUI app for activity logging
|
|
75
|
+
"""
|
|
76
|
+
import json
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
data = json.loads(output)
|
|
80
|
+
except json.JSONDecodeError:
|
|
81
|
+
tui_app.add_activity("Warning: Backend output is not valid JSON", "⚠️")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
# Try schema-based artifact writing first
|
|
85
|
+
schema = stage.metadata.artifact_schema
|
|
86
|
+
if schema:
|
|
87
|
+
_write_schema_artifacts(data, schema, stage, task_name, tui_app)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
# Fall back to generic artifacts array
|
|
91
|
+
artifacts = data.get("artifacts", [])
|
|
92
|
+
if artifacts:
|
|
93
|
+
_write_generic_artifacts(artifacts, task_name, tui_app)
|
|
94
|
+
else:
|
|
95
|
+
tui_app.add_activity(f"Warning: No artifact schema for {stage.value}", "⚠️")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _write_schema_artifacts(
|
|
99
|
+
data: dict[str, Any],
|
|
100
|
+
schema: dict[str, str | None],
|
|
101
|
+
stage: Stage,
|
|
102
|
+
task_name: str,
|
|
103
|
+
tui_app: WorkflowTUIApp,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Write artifacts based on stage schema mapping."""
|
|
106
|
+
from galangal.core.state import get_decision_values
|
|
107
|
+
|
|
108
|
+
notes_file = schema.get("notes_file")
|
|
109
|
+
notes_field = schema.get("notes_field")
|
|
110
|
+
decision_file = schema.get("decision_file")
|
|
111
|
+
decision_field = schema.get("decision_field")
|
|
112
|
+
issues_field = schema.get("issues_field")
|
|
113
|
+
|
|
114
|
+
# Write notes file
|
|
115
|
+
if notes_file and notes_field:
|
|
116
|
+
notes = data.get(notes_field, "")
|
|
117
|
+
if notes:
|
|
118
|
+
# Append formatted issues if present
|
|
119
|
+
if issues_field:
|
|
120
|
+
issues = data.get(issues_field, [])
|
|
121
|
+
notes += _format_issues(issues)
|
|
122
|
+
|
|
123
|
+
write_artifact(notes_file, notes, task_name)
|
|
124
|
+
tui_app.add_activity(f"Wrote {notes_file} from backend output", "📝")
|
|
125
|
+
|
|
126
|
+
# Write decision file using stage-specific valid values
|
|
127
|
+
if decision_file and decision_field:
|
|
128
|
+
decision = data.get(decision_field, "")
|
|
129
|
+
# Get valid decisions from STAGE_METADATA
|
|
130
|
+
valid_decisions = get_decision_values(stage)
|
|
131
|
+
if decision in valid_decisions:
|
|
132
|
+
write_artifact(decision_file, decision, task_name)
|
|
133
|
+
tui_app.add_activity(f"Wrote {decision_file}: {decision}", "📝")
|
|
134
|
+
elif decision:
|
|
135
|
+
tui_app.add_activity(
|
|
136
|
+
f"Warning: Invalid decision '{decision}' for {stage.value} "
|
|
137
|
+
f"(expected: {', '.join(valid_decisions)})",
|
|
138
|
+
"⚠️",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _write_generic_artifacts(
|
|
143
|
+
artifacts: list[dict[str, Any]],
|
|
144
|
+
task_name: str,
|
|
145
|
+
tui_app: WorkflowTUIApp,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Write artifacts from generic artifacts array in JSON output.
|
|
148
|
+
|
|
149
|
+
Expected format:
|
|
150
|
+
[
|
|
151
|
+
{"name": "ARTIFACT_NAME.md", "content": "..."},
|
|
152
|
+
...
|
|
153
|
+
]
|
|
154
|
+
"""
|
|
155
|
+
for artifact in artifacts:
|
|
156
|
+
name = artifact.get("name")
|
|
157
|
+
content = artifact.get("content")
|
|
158
|
+
if name and content:
|
|
159
|
+
write_artifact(name, content, task_name)
|
|
160
|
+
tui_app.add_activity(f"Wrote {name} from backend output", "📝")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_next_stage(current: Stage, state: WorkflowState) -> Stage | None:
|
|
164
|
+
"""
|
|
165
|
+
Determine the next stage in the workflow pipeline.
|
|
166
|
+
|
|
167
|
+
Iterates through STAGE_ORDER starting after current stage, skipping
|
|
168
|
+
stages that should be bypassed based on (in order):
|
|
169
|
+
1. Config-level skipping (config.stages.skip)
|
|
170
|
+
2. Task type skipping (e.g., DOCS tasks skip TEST, BENCHMARK)
|
|
171
|
+
3. Fast-track skipping (minor rollback - skip stages that already passed)
|
|
172
|
+
4. PM-driven stage plan (STAGE_PLAN.md recommendations)
|
|
173
|
+
5. Manual skip artifacts (e.g., MIGRATION_SKIP.md from galangal skip-*)
|
|
174
|
+
6. TEST_GATE: skip if not enabled or no tests configured
|
|
175
|
+
7. skip_if conditions from validation config (glob-based skipping for all stages)
|
|
176
|
+
|
|
177
|
+
This is the single source of truth for skip logic. All skip decisions
|
|
178
|
+
happen here during planning, ensuring the progress bar accurately
|
|
179
|
+
reflects which stages will run.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
current: The stage that just completed.
|
|
183
|
+
state: Current workflow state containing task_name and task_type.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
The next stage to execute, or None if current is the last stage.
|
|
187
|
+
"""
|
|
188
|
+
config = get_config()
|
|
189
|
+
task_name = state.task_name
|
|
190
|
+
task_type = state.task_type
|
|
191
|
+
start_idx = STAGE_ORDER.index(current) + 1
|
|
192
|
+
config_skip_stages = [s.upper() for s in config.stages.skip]
|
|
193
|
+
runner = ValidationRunner() # Create once for all skip_if checks
|
|
194
|
+
|
|
195
|
+
for next_stage in STAGE_ORDER[start_idx:]:
|
|
196
|
+
# Check 1: config-level skipping
|
|
197
|
+
if next_stage.value in config_skip_stages:
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
# Check 2: task type skipping
|
|
201
|
+
if should_skip_for_task_type(next_stage, task_type):
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
# Check 3: fast-track skipping (minor rollback - skip stages that already passed)
|
|
205
|
+
if state.should_fast_track_skip(next_stage):
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
# Check 4: PM-driven stage plan (STAGE_PLAN.md recommendations)
|
|
209
|
+
if state.stage_plan and next_stage.value in state.stage_plan:
|
|
210
|
+
if state.stage_plan[next_stage.value].get("action") == "skip":
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
# Check 5: manual skip artifacts (e.g., MIGRATION_SKIP.md from galangal skip-*)
|
|
214
|
+
# Uses metadata as source of truth for which stages have skip artifacts
|
|
215
|
+
stage_metadata = next_stage.metadata
|
|
216
|
+
if stage_metadata.skip_artifact and artifact_exists(
|
|
217
|
+
stage_metadata.skip_artifact, task_name
|
|
218
|
+
):
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
# Check 6: TEST_GATE - skip if not enabled or no tests configured
|
|
222
|
+
if next_stage == Stage.TEST_GATE:
|
|
223
|
+
if not config.test_gate.enabled or not config.test_gate.tests:
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
# Check 7: for conditional stages, if PM explicitly said "run", skip the glob check
|
|
227
|
+
if next_stage in CONDITIONAL_STAGES:
|
|
228
|
+
if state.stage_plan and next_stage.value in state.stage_plan:
|
|
229
|
+
if state.stage_plan[next_stage.value].get("action") == "run":
|
|
230
|
+
return next_stage # PM says run, skip the glob check
|
|
231
|
+
|
|
232
|
+
# Check 8: skip_if conditions for ALL stages (glob-based skipping)
|
|
233
|
+
# This is the single place where skip_if is evaluated
|
|
234
|
+
if runner.should_skip_stage(next_stage.value.upper(), task_name):
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
return next_stage
|
|
238
|
+
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _execute_test_gate(
|
|
243
|
+
state: WorkflowState,
|
|
244
|
+
tui_app: WorkflowTUIApp,
|
|
245
|
+
config: Any,
|
|
246
|
+
) -> StageResult:
|
|
247
|
+
"""
|
|
248
|
+
Execute the TEST_GATE stage - run configured test commands mechanically.
|
|
249
|
+
|
|
250
|
+
This is a non-AI stage that runs shell commands to verify tests pass.
|
|
251
|
+
All configured tests must pass for the stage to succeed.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
state: Current workflow state.
|
|
255
|
+
tui_app: TUI app for progress display.
|
|
256
|
+
config: Galangal configuration with test_gate settings.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
StageResult indicating success or failure with rollback to DEV.
|
|
260
|
+
"""
|
|
261
|
+
import subprocess
|
|
262
|
+
|
|
263
|
+
from galangal.config.loader import get_project_root
|
|
264
|
+
from galangal.logging import workflow_logger
|
|
265
|
+
|
|
266
|
+
task_name = state.task_name
|
|
267
|
+
test_config = config.test_gate
|
|
268
|
+
project_root = get_project_root()
|
|
269
|
+
|
|
270
|
+
tui_app.add_activity("Running test gate checks...", "🧪")
|
|
271
|
+
|
|
272
|
+
# Track results for each test suite
|
|
273
|
+
results: list[dict[str, Any]] = []
|
|
274
|
+
all_passed = True
|
|
275
|
+
failed_tests: list[str] = []
|
|
276
|
+
|
|
277
|
+
for test in test_config.tests:
|
|
278
|
+
tui_app.add_activity(f"Running: {test.name}", "▶")
|
|
279
|
+
tui_app.show_message(f"Test Gate: {test.name}", "info")
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
result = subprocess.run(
|
|
283
|
+
test.command,
|
|
284
|
+
shell=True,
|
|
285
|
+
cwd=project_root,
|
|
286
|
+
capture_output=True,
|
|
287
|
+
text=True,
|
|
288
|
+
timeout=test.timeout,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
passed = result.returncode == 0
|
|
292
|
+
output = result.stdout + result.stderr
|
|
293
|
+
|
|
294
|
+
# Truncate output for display
|
|
295
|
+
output_preview = output[:2000] if len(output) > 2000 else output
|
|
296
|
+
|
|
297
|
+
results.append({
|
|
298
|
+
"name": test.name,
|
|
299
|
+
"command": test.command,
|
|
300
|
+
"passed": passed,
|
|
301
|
+
"exit_code": result.returncode,
|
|
302
|
+
"output": output_preview,
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
if passed:
|
|
306
|
+
tui_app.add_activity(f"✓ {test.name} passed", "✅")
|
|
307
|
+
else:
|
|
308
|
+
tui_app.add_activity(f"✗ {test.name} failed (exit code {result.returncode})", "❌")
|
|
309
|
+
all_passed = False
|
|
310
|
+
failed_tests.append(test.name)
|
|
311
|
+
|
|
312
|
+
# Stop on first failure if fail_fast is enabled
|
|
313
|
+
if test_config.fail_fast:
|
|
314
|
+
tui_app.add_activity("Stopping (fail_fast enabled)", "⚠")
|
|
315
|
+
break
|
|
316
|
+
|
|
317
|
+
except subprocess.TimeoutExpired:
|
|
318
|
+
results.append({
|
|
319
|
+
"name": test.name,
|
|
320
|
+
"command": test.command,
|
|
321
|
+
"passed": False,
|
|
322
|
+
"exit_code": -1,
|
|
323
|
+
"output": f"Command timed out after {test.timeout} seconds",
|
|
324
|
+
})
|
|
325
|
+
tui_app.add_activity(f"✗ {test.name} timed out", "⏱️")
|
|
326
|
+
all_passed = False
|
|
327
|
+
failed_tests.append(test.name)
|
|
328
|
+
|
|
329
|
+
if test_config.fail_fast:
|
|
330
|
+
break
|
|
331
|
+
|
|
332
|
+
except Exception as e:
|
|
333
|
+
results.append({
|
|
334
|
+
"name": test.name,
|
|
335
|
+
"command": test.command,
|
|
336
|
+
"passed": False,
|
|
337
|
+
"exit_code": -1,
|
|
338
|
+
"output": f"Error running command: {e}",
|
|
339
|
+
})
|
|
340
|
+
tui_app.add_activity(f"✗ {test.name} error: {e}", "❌")
|
|
341
|
+
all_passed = False
|
|
342
|
+
failed_tests.append(test.name)
|
|
343
|
+
|
|
344
|
+
if test_config.fail_fast:
|
|
345
|
+
break
|
|
346
|
+
|
|
347
|
+
# Build TEST_GATE_RESULTS.md artifact
|
|
348
|
+
passed_count = sum(1 for r in results if r["passed"])
|
|
349
|
+
total_count = len(results)
|
|
350
|
+
status = "PASS" if all_passed else "FAIL"
|
|
351
|
+
|
|
352
|
+
artifact_lines = [
|
|
353
|
+
"# Test Gate Results\n",
|
|
354
|
+
f"\n**Status:** {status}",
|
|
355
|
+
f"\n**Passed:** {passed_count}/{total_count}\n",
|
|
356
|
+
]
|
|
357
|
+
|
|
358
|
+
if not all_passed:
|
|
359
|
+
artifact_lines.append(f"\n**Failed Tests:** {', '.join(failed_tests)}\n")
|
|
360
|
+
|
|
361
|
+
artifact_lines.append("\n## Test Results\n")
|
|
362
|
+
|
|
363
|
+
for r in results:
|
|
364
|
+
status_icon = "✅" if r["passed"] else "❌"
|
|
365
|
+
artifact_lines.append(f"\n### {status_icon} {r['name']}\n")
|
|
366
|
+
artifact_lines.append(f"\n**Command:** `{r['command']}`\n")
|
|
367
|
+
artifact_lines.append(f"**Exit Code:** {r['exit_code']}\n")
|
|
368
|
+
if r["output"]:
|
|
369
|
+
artifact_lines.append(f"\n<details>\n<summary>Output</summary>\n\n```\n{r['output']}\n```\n\n</details>\n")
|
|
370
|
+
|
|
371
|
+
write_artifact("TEST_GATE_RESULTS.md", "".join(artifact_lines), task_name)
|
|
372
|
+
tui_app.add_activity("Wrote TEST_GATE_RESULTS.md", "📝")
|
|
373
|
+
|
|
374
|
+
# Write decision file
|
|
375
|
+
decision = "PASS" if all_passed else "FAIL"
|
|
376
|
+
write_artifact("TEST_GATE_DECISION", decision, task_name)
|
|
377
|
+
|
|
378
|
+
# Log result
|
|
379
|
+
workflow_logger.stage_completed(
|
|
380
|
+
stage="TEST_GATE",
|
|
381
|
+
task_name=task_name,
|
|
382
|
+
success=all_passed,
|
|
383
|
+
duration=0, # Duration tracked by caller
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
if all_passed:
|
|
387
|
+
message = f"All {total_count} test suite(s) passed"
|
|
388
|
+
tui_app.show_message(f"Test Gate: {message}", "success")
|
|
389
|
+
return StageResult.create_success(message)
|
|
390
|
+
else:
|
|
391
|
+
message = f"Test gate failed: {len(failed_tests)} test suite(s) failed"
|
|
392
|
+
tui_app.show_message(f"Test Gate: {message}", "error")
|
|
393
|
+
return StageResult.rollback_required(
|
|
394
|
+
message=message,
|
|
395
|
+
rollback_to=Stage.DEV,
|
|
396
|
+
output="\n".join(f"- {t}" for t in failed_tests),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def execute_stage(
|
|
401
|
+
state: WorkflowState,
|
|
402
|
+
tui_app: WorkflowTUIApp,
|
|
403
|
+
pause_check: PauseCheck | None = None,
|
|
404
|
+
) -> StageResult:
|
|
405
|
+
"""
|
|
406
|
+
Execute a single workflow stage and validate its output.
|
|
407
|
+
|
|
408
|
+
This function handles the full lifecycle of a stage execution:
|
|
409
|
+
1. Check for pending clarifications (QUESTIONS.md without ANSWERS.md)
|
|
410
|
+
2. For PREFLIGHT stage: run validation checks directly
|
|
411
|
+
3. For other stages: build prompt, invoke AI backend, validate output
|
|
412
|
+
|
|
413
|
+
The prompt is built using PromptBuilder which merges default prompts
|
|
414
|
+
with project-specific overrides. Retry context is appended when
|
|
415
|
+
state.attempt > 1.
|
|
416
|
+
|
|
417
|
+
All prompts and outputs are logged to the task's logs/ directory.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
state: Current workflow state containing stage, task_name, attempt count,
|
|
421
|
+
and last_failure for retry context.
|
|
422
|
+
tui_app: TUI application instance for displaying progress and messages.
|
|
423
|
+
pause_check: Optional callback that returns True if a pause was requested
|
|
424
|
+
(e.g., user pressed Ctrl+C). Passed to ClaudeBackend for graceful stop.
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
StageResult with one of:
|
|
428
|
+
- SUCCESS: Stage completed and validated successfully
|
|
429
|
+
- PREFLIGHT_FAILED: Preflight checks failed
|
|
430
|
+
- VALIDATION_FAILED: Stage output failed validation
|
|
431
|
+
- ROLLBACK_REQUIRED: Validation indicated rollback needed
|
|
432
|
+
- CLARIFICATION_NEEDED: Questions pending without answers
|
|
433
|
+
- PAUSED/TIMEOUT/ERROR: AI execution issues
|
|
434
|
+
"""
|
|
435
|
+
from galangal.logging import workflow_logger
|
|
436
|
+
|
|
437
|
+
stage = state.stage
|
|
438
|
+
task_name = state.task_name
|
|
439
|
+
config = get_config()
|
|
440
|
+
start_time = time.time()
|
|
441
|
+
|
|
442
|
+
# Log stage start
|
|
443
|
+
workflow_logger.stage_started(
|
|
444
|
+
stage=stage.value,
|
|
445
|
+
task_name=task_name,
|
|
446
|
+
attempt=state.attempt,
|
|
447
|
+
max_retries=config.stages.max_retries,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
if stage == Stage.COMPLETE:
|
|
451
|
+
return StageResult.create_success("Workflow complete")
|
|
452
|
+
|
|
453
|
+
# NOTE: Skip conditions are checked in get_next_stage() which is the single
|
|
454
|
+
# source of truth for skip logic. By the time we reach execute_stage(),
|
|
455
|
+
# the stage has already been determined to not be skipped.
|
|
456
|
+
|
|
457
|
+
# Check for clarification
|
|
458
|
+
if artifact_exists("QUESTIONS.md", task_name) and not artifact_exists("ANSWERS.md", task_name):
|
|
459
|
+
state.clarification_required = True
|
|
460
|
+
save_state(state)
|
|
461
|
+
return StageResult.clarification_needed()
|
|
462
|
+
|
|
463
|
+
# PREFLIGHT runs validation directly
|
|
464
|
+
if stage == Stage.PREFLIGHT:
|
|
465
|
+
tui_app.add_activity("Running preflight checks...", "⚙")
|
|
466
|
+
|
|
467
|
+
runner = ValidationRunner()
|
|
468
|
+
result = runner.validate_stage("PREFLIGHT", task_name)
|
|
469
|
+
|
|
470
|
+
if result.success:
|
|
471
|
+
tui_app.show_message(f"Preflight: {result.message}", "success")
|
|
472
|
+
return StageResult.create_success(result.message)
|
|
473
|
+
else:
|
|
474
|
+
return StageResult.preflight_failed(
|
|
475
|
+
message=result.message,
|
|
476
|
+
details=result.output or "",
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
# TEST_GATE runs configured test commands mechanically (no AI)
|
|
480
|
+
if stage == Stage.TEST_GATE:
|
|
481
|
+
return _execute_test_gate(state, tui_app, config)
|
|
482
|
+
|
|
483
|
+
# Get backend first (needed for backend-specific prompts)
|
|
484
|
+
backend = get_backend_for_stage(stage, config, use_fallback=True)
|
|
485
|
+
|
|
486
|
+
# Build prompt
|
|
487
|
+
builder = PromptBuilder()
|
|
488
|
+
|
|
489
|
+
# For read-only backends on review-type stages, use minimal context
|
|
490
|
+
# This gives an unbiased review without Claude's interpretations
|
|
491
|
+
review_stages = {Stage.REVIEW, Stage.SECURITY, Stage.QA}
|
|
492
|
+
if backend.read_only and stage in review_stages:
|
|
493
|
+
prompt = builder.build_minimal_review_prompt(state, backend_name=backend.name)
|
|
494
|
+
tui_app.add_activity("Using minimal context for independent review", "📋")
|
|
495
|
+
else:
|
|
496
|
+
prompt = builder.build_full_prompt(stage, state, backend_name=backend.name)
|
|
497
|
+
|
|
498
|
+
# Add retry context
|
|
499
|
+
if state.attempt > 1 and state.last_failure:
|
|
500
|
+
retry_context = f"""
|
|
501
|
+
## ⚠️ RETRY ATTEMPT {state.attempt}
|
|
502
|
+
|
|
503
|
+
The previous attempt failed with the following error:
|
|
504
|
+
|
|
505
|
+
```
|
|
506
|
+
{state.last_failure[:1000]}
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Please fix the issue above before proceeding. Do not repeat the same mistake.
|
|
510
|
+
"""
|
|
511
|
+
prompt = f"{prompt}\n\n{retry_context}"
|
|
512
|
+
|
|
513
|
+
# Set up log file for streaming output
|
|
514
|
+
logs_dir = get_task_dir(task_name) / "logs"
|
|
515
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
516
|
+
log_file = logs_dir / f"{stage.value.lower()}_{state.attempt}.log"
|
|
517
|
+
|
|
518
|
+
# Write prompt header to log file
|
|
519
|
+
with open(log_file, "w") as f:
|
|
520
|
+
f.write(f"=== Prompt ===\n{prompt}\n\n")
|
|
521
|
+
f.write(f"=== Backend: {backend.name} ===\n")
|
|
522
|
+
f.write("=== Streaming Output ===\n")
|
|
523
|
+
|
|
524
|
+
tui_app.add_activity(f"Using {backend.name} backend", "🤖")
|
|
525
|
+
|
|
526
|
+
ui = TUIAdapter(tui_app)
|
|
527
|
+
max_turns = backend.config.max_turns if backend.config else 200
|
|
528
|
+
invoke_result = backend.invoke(
|
|
529
|
+
prompt=prompt,
|
|
530
|
+
timeout=config.stages.timeout,
|
|
531
|
+
max_turns=max_turns,
|
|
532
|
+
ui=ui,
|
|
533
|
+
pause_check=pause_check,
|
|
534
|
+
stage=stage.value,
|
|
535
|
+
log_file=str(log_file), # Pass log file for streaming
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# Add completion marker to log
|
|
539
|
+
with open(log_file, "a") as f:
|
|
540
|
+
f.write(f"\n=== Result: {invoke_result.type.value} ===\n")
|
|
541
|
+
if invoke_result.message:
|
|
542
|
+
f.write(f"{invoke_result.message}\n")
|
|
543
|
+
|
|
544
|
+
# Return early if AI invocation failed
|
|
545
|
+
if not invoke_result.success:
|
|
546
|
+
return invoke_result
|
|
547
|
+
|
|
548
|
+
# Post-process for read-only backends (e.g., Codex)
|
|
549
|
+
# These backends return structured JSON instead of writing files directly
|
|
550
|
+
if backend.read_only and invoke_result.output:
|
|
551
|
+
_write_artifacts_from_readonly_output(stage, invoke_result.output, task_name, tui_app)
|
|
552
|
+
|
|
553
|
+
# Validate stage
|
|
554
|
+
tui_app.add_activity("Validating stage outputs...", "⚙")
|
|
555
|
+
|
|
556
|
+
runner = ValidationRunner()
|
|
557
|
+
result = runner.validate_stage(stage.value, task_name)
|
|
558
|
+
|
|
559
|
+
# Log validation details including rollback_to for debugging
|
|
560
|
+
with open(log_file, "a") as f:
|
|
561
|
+
f.write("\n=== Validation ===\n")
|
|
562
|
+
f.write(f"success: {result.success}\n")
|
|
563
|
+
f.write(f"message: {result.message}\n")
|
|
564
|
+
f.write(f"rollback_to: {result.rollback_to}\n")
|
|
565
|
+
f.write(f"skipped: {result.skipped}\n")
|
|
566
|
+
if result.output:
|
|
567
|
+
f.write(f"\n=== Validation Output ===\n{result.output}\n")
|
|
568
|
+
|
|
569
|
+
duration = time.time() - start_time
|
|
570
|
+
|
|
571
|
+
# Log validation result
|
|
572
|
+
workflow_logger.validation_result(
|
|
573
|
+
stage=stage.value,
|
|
574
|
+
task_name=task_name,
|
|
575
|
+
success=result.success,
|
|
576
|
+
message=result.message,
|
|
577
|
+
skipped=result.skipped,
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
if result.success:
|
|
581
|
+
tui_app.show_message(result.message, "success")
|
|
582
|
+
workflow_logger.stage_completed(
|
|
583
|
+
stage=stage.value,
|
|
584
|
+
task_name=task_name,
|
|
585
|
+
success=True,
|
|
586
|
+
duration=duration,
|
|
587
|
+
)
|
|
588
|
+
return StageResult.create_success(result.message, output=invoke_result.output)
|
|
589
|
+
else:
|
|
590
|
+
# Check if user decision is needed (decision file missing)
|
|
591
|
+
if result.needs_user_decision:
|
|
592
|
+
tui_app.add_activity("Decision file missing - user confirmation required", "❓")
|
|
593
|
+
return StageResult.user_decision_needed(
|
|
594
|
+
message=result.message,
|
|
595
|
+
artifact_content=result.output,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
tui_app.show_message(result.message, "error")
|
|
599
|
+
workflow_logger.stage_failed(
|
|
600
|
+
stage=stage.value,
|
|
601
|
+
task_name=task_name,
|
|
602
|
+
error=result.message,
|
|
603
|
+
attempt=state.attempt,
|
|
604
|
+
)
|
|
605
|
+
# Check if rollback is required
|
|
606
|
+
if result.rollback_to:
|
|
607
|
+
rollback_type = "fast-track rollback" if result.is_fast_track else "rollback"
|
|
608
|
+
tui_app.add_activity(f"Triggering {rollback_type} to {result.rollback_to}", "🔄")
|
|
609
|
+
return StageResult.rollback_required(
|
|
610
|
+
message=result.message,
|
|
611
|
+
rollback_to=Stage.from_str(result.rollback_to),
|
|
612
|
+
output=invoke_result.output,
|
|
613
|
+
is_fast_track=result.is_fast_track,
|
|
614
|
+
)
|
|
615
|
+
# Log when rollback_to is not set (helps debug missing rollback)
|
|
616
|
+
tui_app.add_activity("Validation failed without rollback target", "⚠")
|
|
617
|
+
return StageResult.validation_failed(result.message)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def append_rollback_entry(
|
|
621
|
+
task_name: str,
|
|
622
|
+
source: str,
|
|
623
|
+
from_stage: str,
|
|
624
|
+
target_stage: str,
|
|
625
|
+
reason: str,
|
|
626
|
+
) -> None:
|
|
627
|
+
"""
|
|
628
|
+
Append a rollback entry to ROLLBACK.md, preserving history.
|
|
629
|
+
|
|
630
|
+
Creates a structured entry documenting the rollback event and appends it
|
|
631
|
+
to existing ROLLBACK.md content (or creates new file if none exists).
|
|
632
|
+
|
|
633
|
+
Args:
|
|
634
|
+
task_name: Name of the task.
|
|
635
|
+
source: Description of what triggered the rollback
|
|
636
|
+
(e.g., "User interrupt (Ctrl+I)", "Validation failure", "Manual review").
|
|
637
|
+
from_stage: Stage where the rollback was triggered.
|
|
638
|
+
target_stage: Stage to roll back to.
|
|
639
|
+
reason: Description of issues to fix.
|
|
640
|
+
"""
|
|
641
|
+
rollback_entry = f"""
|
|
642
|
+
---
|
|
643
|
+
|
|
644
|
+
## {source}
|
|
645
|
+
|
|
646
|
+
**Date:** {now_iso()}
|
|
647
|
+
**From Stage:** {from_stage}
|
|
648
|
+
**Target Stage:** {target_stage}
|
|
649
|
+
|
|
650
|
+
### Issues to Fix
|
|
651
|
+
{reason}
|
|
652
|
+
"""
|
|
653
|
+
|
|
654
|
+
existing = read_artifact("ROLLBACK.md", task_name)
|
|
655
|
+
if existing:
|
|
656
|
+
new_content = existing + rollback_entry
|
|
657
|
+
else:
|
|
658
|
+
new_content = f"# Rollback Log\n\nThis file tracks issues that required rolling back to earlier stages.\n{rollback_entry}"
|
|
659
|
+
|
|
660
|
+
write_artifact("ROLLBACK.md", new_content, task_name)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def archive_rollback_if_exists(task_name: str, tui_app: WorkflowTUIApp) -> None:
|
|
664
|
+
"""
|
|
665
|
+
Archive ROLLBACK.md to ROLLBACK_RESOLVED.md after DEV stage succeeds.
|
|
666
|
+
|
|
667
|
+
When validation failures or manual review trigger a rollback to DEV,
|
|
668
|
+
the issues are recorded in ROLLBACK.md. Once DEV completes successfully,
|
|
669
|
+
this function moves the rollback content to ROLLBACK_RESOLVED.md with
|
|
670
|
+
a resolution timestamp.
|
|
671
|
+
|
|
672
|
+
Multiple rollbacks are accumulated in ROLLBACK_RESOLVED.md, separated
|
|
673
|
+
by horizontal rules, providing a history of issues encountered.
|
|
674
|
+
|
|
675
|
+
Args:
|
|
676
|
+
task_name: Name of the task to archive rollback for.
|
|
677
|
+
tui_app: TUI app for displaying archive notification.
|
|
678
|
+
"""
|
|
679
|
+
if not artifact_exists("ROLLBACK.md", task_name):
|
|
680
|
+
return
|
|
681
|
+
|
|
682
|
+
rollback_content = read_artifact("ROLLBACK.md", task_name) or ""
|
|
683
|
+
resolved_path = artifact_path("ROLLBACK_RESOLVED.md", task_name)
|
|
684
|
+
|
|
685
|
+
resolution_note = f"\n\n## Resolved: {now_iso()}\n\nIssues fixed by DEV stage.\n"
|
|
686
|
+
|
|
687
|
+
if resolved_path.exists():
|
|
688
|
+
existing = resolved_path.read_text()
|
|
689
|
+
resolved_path.write_text(existing + "\n---\n" + rollback_content + resolution_note)
|
|
690
|
+
else:
|
|
691
|
+
resolved_path.write_text(rollback_content + resolution_note)
|
|
692
|
+
|
|
693
|
+
rollback_path = artifact_path("ROLLBACK.md", task_name)
|
|
694
|
+
rollback_path.unlink()
|
|
695
|
+
|
|
696
|
+
tui_app.add_activity("Archived ROLLBACK.md → ROLLBACK_RESOLVED.md", "📋")
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def handle_rollback(state: WorkflowState, result: StageResult) -> bool:
|
|
700
|
+
"""
|
|
701
|
+
Process a rollback from validation failure.
|
|
702
|
+
|
|
703
|
+
When validation indicates a rollback is needed (e.g., QA fails and needs
|
|
704
|
+
to go back to DEV), this function:
|
|
705
|
+
1. Checks if rollback is allowed (prevents infinite loops)
|
|
706
|
+
2. Records the rollback in state history
|
|
707
|
+
3. Appends the rollback details to ROLLBACK.md
|
|
708
|
+
4. Updates the workflow state to target stage
|
|
709
|
+
5. Resets attempt counter and records failure reason
|
|
710
|
+
|
|
711
|
+
The ROLLBACK.md file serves as context for the target stage, describing
|
|
712
|
+
what issues need to be fixed.
|
|
713
|
+
|
|
714
|
+
Rollback loop prevention: If too many rollbacks to the same stage occur
|
|
715
|
+
within the time window (default: 3 within 1 hour), the rollback is blocked
|
|
716
|
+
and returns False.
|
|
717
|
+
|
|
718
|
+
Args:
|
|
719
|
+
state: Current workflow state to update. Modified in place.
|
|
720
|
+
result: StageResult with type=ROLLBACK_REQUIRED and rollback_to set.
|
|
721
|
+
The message field describes what needs to be fixed.
|
|
722
|
+
|
|
723
|
+
Returns:
|
|
724
|
+
True if rollback was processed (result was ROLLBACK_REQUIRED type).
|
|
725
|
+
False if result was not a rollback or rollback was blocked due to
|
|
726
|
+
too many recent rollbacks to the same stage.
|
|
727
|
+
"""
|
|
728
|
+
if result.type != StageResultType.ROLLBACK_REQUIRED or result.rollback_to is None:
|
|
729
|
+
return False
|
|
730
|
+
|
|
731
|
+
task_name = state.task_name
|
|
732
|
+
from_stage = state.stage
|
|
733
|
+
target_stage = result.rollback_to
|
|
734
|
+
reason = result.message
|
|
735
|
+
|
|
736
|
+
# Check for rollback loops
|
|
737
|
+
if not state.should_allow_rollback(target_stage):
|
|
738
|
+
from galangal.logging import workflow_logger
|
|
739
|
+
|
|
740
|
+
rollback_count = state.get_rollback_count(target_stage)
|
|
741
|
+
loop_msg = (
|
|
742
|
+
f"Rollback loop detected: {rollback_count} rollbacks to {target_stage.value} "
|
|
743
|
+
f"in the last hour. Manual intervention required."
|
|
744
|
+
)
|
|
745
|
+
workflow_logger.rollback(
|
|
746
|
+
from_stage=from_stage.value,
|
|
747
|
+
to_stage=target_stage.value,
|
|
748
|
+
task_name=task_name,
|
|
749
|
+
reason=f"BLOCKED: {loop_msg}",
|
|
750
|
+
)
|
|
751
|
+
return False
|
|
752
|
+
|
|
753
|
+
# Record rollback in state history
|
|
754
|
+
state.record_rollback(from_stage, target_stage, reason)
|
|
755
|
+
|
|
756
|
+
# Append to ROLLBACK.md
|
|
757
|
+
append_rollback_entry(
|
|
758
|
+
task_name=task_name,
|
|
759
|
+
source=f"Validation failure in {from_stage.value}",
|
|
760
|
+
from_stage=from_stage.value,
|
|
761
|
+
target_stage=target_stage.value,
|
|
762
|
+
reason=reason,
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
# Log rollback event
|
|
766
|
+
from galangal.logging import workflow_logger
|
|
767
|
+
|
|
768
|
+
workflow_logger.rollback(
|
|
769
|
+
from_stage=from_stage.value,
|
|
770
|
+
to_stage=target_stage.value,
|
|
771
|
+
task_name=task_name,
|
|
772
|
+
reason=reason,
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
# Handle fast-track vs full rollback
|
|
776
|
+
if result.is_fast_track:
|
|
777
|
+
# Minor rollback: skip stages that already passed
|
|
778
|
+
state.setup_fast_track()
|
|
779
|
+
else:
|
|
780
|
+
# Full rollback: re-run all stages
|
|
781
|
+
state.clear_fast_track()
|
|
782
|
+
state.clear_passed_stages()
|
|
783
|
+
|
|
784
|
+
state.stage = target_stage
|
|
785
|
+
state.last_failure = f"Rollback from {from_stage.value}: {reason}"
|
|
786
|
+
state.reset_attempts(clear_failure=False)
|
|
787
|
+
save_state(state)
|
|
788
|
+
|
|
789
|
+
return True
|