galangal-orchestrate 0.13.0__py3-none-any.whl → 0.13.4__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 +1 -1
- galangal/ai/claude.py +1 -51
- galangal/ai/codex.py +2 -1
- galangal/ai/subprocess.py +59 -13
- galangal/commands/complete.py +5 -0
- galangal/commands/start.py +6 -0
- galangal/config/schema.py +13 -1
- galangal/core/state.py +4 -0
- galangal/core/version_check.py +238 -0
- galangal/core/workflow/core.py +158 -45
- galangal/core/workflow/tui_runner.py +4 -0
- galangal/prompts/defaults/dev.md +4 -2
- galangal/ui/tui/app.py +38 -6
- galangal/ui/tui/widgets.py +4 -0
- galangal/validation/runner.py +15 -6
- {galangal_orchestrate-0.13.0.dist-info → galangal_orchestrate-0.13.4.dist-info}/METADATA +1 -1
- {galangal_orchestrate-0.13.0.dist-info → galangal_orchestrate-0.13.4.dist-info}/RECORD +20 -19
- {galangal_orchestrate-0.13.0.dist-info → galangal_orchestrate-0.13.4.dist-info}/WHEEL +0 -0
- {galangal_orchestrate-0.13.0.dist-info → galangal_orchestrate-0.13.4.dist-info}/entry_points.txt +0 -0
- {galangal_orchestrate-0.13.0.dist-info → galangal_orchestrate-0.13.4.dist-info}/licenses/LICENSE +0 -0
galangal/__init__.py
CHANGED
galangal/ai/claude.py
CHANGED
|
@@ -83,55 +83,11 @@ class ClaudeBackend(AIBackend):
|
|
|
83
83
|
"""Invoke Claude Code with a prompt."""
|
|
84
84
|
# State for output processing
|
|
85
85
|
pending_tools: list[tuple[str, str]] = []
|
|
86
|
-
log_handle = None
|
|
87
|
-
|
|
88
|
-
# Open log file for streaming if provided
|
|
89
|
-
if log_file:
|
|
90
|
-
try:
|
|
91
|
-
log_handle = open(log_file, "a")
|
|
92
|
-
except OSError as e:
|
|
93
|
-
logger.warning("failed_to_open_log_file", path=log_file, error=str(e))
|
|
94
|
-
|
|
95
|
-
def should_log_line(line: str) -> bool:
|
|
96
|
-
"""Determine if a line should be logged (errors, warnings, results)."""
|
|
97
|
-
if not line.strip():
|
|
98
|
-
return False
|
|
99
|
-
try:
|
|
100
|
-
data = json.loads(line.strip())
|
|
101
|
-
msg_type = data.get("type", "")
|
|
102
|
-
|
|
103
|
-
# Always log errors and results
|
|
104
|
-
if msg_type in ("error", "result"):
|
|
105
|
-
return True
|
|
106
|
-
|
|
107
|
-
# Log system messages (rate limiting, etc.)
|
|
108
|
-
if msg_type == "system":
|
|
109
|
-
return True
|
|
110
|
-
|
|
111
|
-
# Log tool errors
|
|
112
|
-
if msg_type == "user":
|
|
113
|
-
content = data.get("message", {}).get("content", [])
|
|
114
|
-
for item in content:
|
|
115
|
-
if item.get("type") == "tool_result" and item.get("is_error"):
|
|
116
|
-
return True
|
|
117
|
-
|
|
118
|
-
return False
|
|
119
|
-
except (json.JSONDecodeError, KeyError, TypeError):
|
|
120
|
-
# Log non-JSON lines that look like errors
|
|
121
|
-
lower = line.lower()
|
|
122
|
-
return "error" in lower or "warning" in lower or "failed" in lower
|
|
123
86
|
|
|
124
87
|
def on_output(line: str) -> None:
|
|
125
88
|
"""Process each output line."""
|
|
126
89
|
if ui:
|
|
127
90
|
ui.add_raw_line(line)
|
|
128
|
-
# Only log errors, warnings, and results to file
|
|
129
|
-
if log_handle and should_log_line(line):
|
|
130
|
-
try:
|
|
131
|
-
log_handle.write(line + "\n")
|
|
132
|
-
log_handle.flush() # Ensure immediate write
|
|
133
|
-
except OSError:
|
|
134
|
-
pass
|
|
135
91
|
self._process_stream_line(line, ui, pending_tools)
|
|
136
92
|
|
|
137
93
|
def on_idle(elapsed: float) -> None:
|
|
@@ -160,6 +116,7 @@ class ClaudeBackend(AIBackend):
|
|
|
160
116
|
idle_interval=3.0,
|
|
161
117
|
poll_interval_active=0.05,
|
|
162
118
|
poll_interval_idle=0.5,
|
|
119
|
+
output_file=log_file,
|
|
163
120
|
)
|
|
164
121
|
|
|
165
122
|
result = runner.run()
|
|
@@ -209,13 +166,6 @@ class ClaudeBackend(AIBackend):
|
|
|
209
166
|
|
|
210
167
|
except Exception as e:
|
|
211
168
|
return StageResult.error(f"Claude invocation error: {e}")
|
|
212
|
-
finally:
|
|
213
|
-
# Close log file handle
|
|
214
|
-
if log_handle:
|
|
215
|
-
try:
|
|
216
|
-
log_handle.close()
|
|
217
|
-
except OSError:
|
|
218
|
-
pass
|
|
219
169
|
|
|
220
170
|
def _process_stream_line(
|
|
221
171
|
self,
|
galangal/ai/codex.py
CHANGED
|
@@ -188,7 +188,7 @@ class CodexBackend(AIBackend):
|
|
|
188
188
|
ui: Optional TUI for progress display
|
|
189
189
|
pause_check: Optional callback for pause detection
|
|
190
190
|
stage: Stage name for schema customization (e.g., "QA", "SECURITY")
|
|
191
|
-
log_file: Optional path to log file
|
|
191
|
+
log_file: Optional path to log file for streaming raw output
|
|
192
192
|
|
|
193
193
|
Returns:
|
|
194
194
|
StageResult with structured JSON in the output field
|
|
@@ -256,6 +256,7 @@ class CodexBackend(AIBackend):
|
|
|
256
256
|
idle_interval=5.0,
|
|
257
257
|
poll_interval_active=0.05,
|
|
258
258
|
poll_interval_idle=0.5,
|
|
259
|
+
output_file=log_file,
|
|
259
260
|
)
|
|
260
261
|
|
|
261
262
|
result = runner.run()
|
galangal/ai/subprocess.py
CHANGED
|
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
import select
|
|
8
8
|
import subprocess
|
|
9
9
|
import time
|
|
10
|
+
from collections import deque
|
|
10
11
|
from collections.abc import Callable
|
|
11
12
|
from dataclasses import dataclass
|
|
12
13
|
from enum import Enum
|
|
@@ -88,6 +89,8 @@ class SubprocessRunner:
|
|
|
88
89
|
idle_interval: float = 3.0,
|
|
89
90
|
poll_interval_active: float = 0.05,
|
|
90
91
|
poll_interval_idle: float = 0.5,
|
|
92
|
+
max_output_chars: int | None = 1_000_000,
|
|
93
|
+
output_file: str | None = None,
|
|
91
94
|
):
|
|
92
95
|
"""
|
|
93
96
|
Initialize the subprocess runner.
|
|
@@ -102,6 +105,8 @@ class SubprocessRunner:
|
|
|
102
105
|
idle_interval: Seconds between idle callbacks
|
|
103
106
|
poll_interval_active: Sleep between polls when receiving output
|
|
104
107
|
poll_interval_idle: Sleep between polls when idle
|
|
108
|
+
max_output_chars: Max output chars kept in memory (None for unlimited)
|
|
109
|
+
output_file: Optional file path to stream full output
|
|
105
110
|
"""
|
|
106
111
|
self.command = command
|
|
107
112
|
self.timeout = timeout
|
|
@@ -112,6 +117,8 @@ class SubprocessRunner:
|
|
|
112
117
|
self.idle_interval = idle_interval
|
|
113
118
|
self.poll_interval_active = poll_interval_active
|
|
114
119
|
self.poll_interval_idle = poll_interval_idle
|
|
120
|
+
self.max_output_chars = max_output_chars
|
|
121
|
+
self.output_file = output_file
|
|
115
122
|
|
|
116
123
|
def run(self) -> RunResult:
|
|
117
124
|
"""
|
|
@@ -129,16 +136,39 @@ class SubprocessRunner:
|
|
|
129
136
|
text=True,
|
|
130
137
|
)
|
|
131
138
|
|
|
132
|
-
|
|
139
|
+
output_buffer: deque[str] = deque()
|
|
140
|
+
output_chars = 0
|
|
141
|
+
output_handle = None
|
|
133
142
|
start_time = time.time()
|
|
134
143
|
last_idle_callback = start_time
|
|
135
144
|
|
|
145
|
+
if self.output_file:
|
|
146
|
+
try:
|
|
147
|
+
output_handle = open(self.output_file, "a", encoding="utf-8", buffering=1)
|
|
148
|
+
except OSError:
|
|
149
|
+
output_handle = None
|
|
150
|
+
|
|
151
|
+
def record_output(chunk: str) -> None:
|
|
152
|
+
nonlocal output_chars, output_handle
|
|
153
|
+
if output_handle:
|
|
154
|
+
try:
|
|
155
|
+
output_handle.write(chunk)
|
|
156
|
+
except OSError:
|
|
157
|
+
output_handle = None
|
|
158
|
+
|
|
159
|
+
output_buffer.append(chunk)
|
|
160
|
+
output_chars += len(chunk)
|
|
161
|
+
if self.max_output_chars is not None:
|
|
162
|
+
while output_chars > self.max_output_chars and output_buffer:
|
|
163
|
+
removed = output_buffer.popleft()
|
|
164
|
+
output_chars -= len(removed)
|
|
165
|
+
|
|
136
166
|
try:
|
|
137
167
|
while True:
|
|
138
168
|
retcode = process.poll()
|
|
139
169
|
|
|
140
170
|
# Read available output (non-blocking)
|
|
141
|
-
had_output = self._read_output(process,
|
|
171
|
+
had_output = self._read_output(process, record_output)
|
|
142
172
|
|
|
143
173
|
# Update last idle callback time if we had output
|
|
144
174
|
if had_output:
|
|
@@ -151,24 +181,30 @@ class SubprocessRunner:
|
|
|
151
181
|
# Check for pause request
|
|
152
182
|
if self.pause_check and self.pause_check():
|
|
153
183
|
self._terminate_gracefully(process)
|
|
184
|
+
self._capture_remaining(process, record_output)
|
|
154
185
|
if self.ui:
|
|
155
186
|
self.ui.add_activity("Paused by user request", "⏸️")
|
|
156
187
|
return RunResult(
|
|
157
188
|
outcome=RunOutcome.PAUSED,
|
|
158
189
|
exit_code=None,
|
|
159
|
-
output="".join(
|
|
190
|
+
output="".join(output_buffer),
|
|
160
191
|
)
|
|
161
192
|
|
|
162
193
|
# Check for timeout
|
|
163
194
|
elapsed = time.time() - start_time
|
|
164
195
|
if elapsed > self.timeout:
|
|
165
196
|
process.kill()
|
|
197
|
+
try:
|
|
198
|
+
process.wait(timeout=5)
|
|
199
|
+
except subprocess.TimeoutExpired:
|
|
200
|
+
pass
|
|
201
|
+
self._capture_remaining(process, record_output)
|
|
166
202
|
if self.ui:
|
|
167
203
|
self.ui.add_activity(f"Timeout after {self.timeout}s", "❌")
|
|
168
204
|
return RunResult(
|
|
169
205
|
outcome=RunOutcome.TIMEOUT,
|
|
170
206
|
exit_code=None,
|
|
171
|
-
output="".join(
|
|
207
|
+
output="".join(output_buffer),
|
|
172
208
|
timeout_seconds=self.timeout,
|
|
173
209
|
)
|
|
174
210
|
|
|
@@ -182,28 +218,33 @@ class SubprocessRunner:
|
|
|
182
218
|
time.sleep(self.poll_interval_active if had_output else self.poll_interval_idle)
|
|
183
219
|
|
|
184
220
|
# Capture any remaining output
|
|
185
|
-
|
|
186
|
-
if remaining:
|
|
187
|
-
output_lines.append(remaining)
|
|
221
|
+
self._capture_remaining(process, record_output)
|
|
188
222
|
|
|
189
223
|
return RunResult(
|
|
190
224
|
outcome=RunOutcome.COMPLETED,
|
|
191
225
|
exit_code=process.returncode,
|
|
192
|
-
output="".join(
|
|
226
|
+
output="".join(output_buffer),
|
|
193
227
|
)
|
|
194
228
|
|
|
195
229
|
except Exception:
|
|
196
230
|
# Ensure process is terminated on any error
|
|
197
231
|
try:
|
|
198
232
|
process.kill()
|
|
233
|
+
process.wait(timeout=5)
|
|
199
234
|
except Exception:
|
|
200
235
|
pass
|
|
201
236
|
raise
|
|
237
|
+
finally:
|
|
238
|
+
if output_handle:
|
|
239
|
+
try:
|
|
240
|
+
output_handle.close()
|
|
241
|
+
except OSError:
|
|
242
|
+
pass
|
|
202
243
|
|
|
203
244
|
def _read_output(
|
|
204
245
|
self,
|
|
205
246
|
process: subprocess.Popen[str],
|
|
206
|
-
|
|
247
|
+
record_output: Callable[[str], None],
|
|
207
248
|
) -> bool:
|
|
208
249
|
"""
|
|
209
250
|
Read all available output lines (non-blocking).
|
|
@@ -225,7 +266,7 @@ class SubprocessRunner:
|
|
|
225
266
|
if not line:
|
|
226
267
|
break
|
|
227
268
|
|
|
228
|
-
|
|
269
|
+
record_output(line)
|
|
229
270
|
had_output = True
|
|
230
271
|
|
|
231
272
|
if self.on_output:
|
|
@@ -245,10 +286,15 @@ class SubprocessRunner:
|
|
|
245
286
|
except subprocess.TimeoutExpired:
|
|
246
287
|
process.kill()
|
|
247
288
|
|
|
248
|
-
def _capture_remaining(
|
|
289
|
+
def _capture_remaining(
|
|
290
|
+
self,
|
|
291
|
+
process: subprocess.Popen[str],
|
|
292
|
+
record_output: Callable[[str], None],
|
|
293
|
+
) -> None:
|
|
249
294
|
"""Capture any remaining output after process completes."""
|
|
250
295
|
try:
|
|
251
296
|
remaining, _ = process.communicate(timeout=10)
|
|
252
|
-
|
|
297
|
+
if remaining:
|
|
298
|
+
record_output(remaining)
|
|
253
299
|
except (OSError, ValueError, subprocess.TimeoutExpired):
|
|
254
|
-
return
|
|
300
|
+
return
|
galangal/commands/complete.py
CHANGED
|
@@ -303,6 +303,11 @@ def finalize_task(
|
|
|
303
303
|
if dest.exists():
|
|
304
304
|
dest = done_dir / f"{task_name}-{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
|
305
305
|
|
|
306
|
+
# Remove logs directory before moving (not needed after completion)
|
|
307
|
+
logs_dir = task_dir / "logs"
|
|
308
|
+
if logs_dir.exists():
|
|
309
|
+
shutil.rmtree(logs_dir)
|
|
310
|
+
|
|
306
311
|
report(f"Moving task to {dest.relative_to(project_root)}/...")
|
|
307
312
|
shutil.move(str(task_dir), str(dest))
|
|
308
313
|
clear_active_task()
|
galangal/commands/start.py
CHANGED
|
@@ -130,6 +130,12 @@ def cmd_start(args: argparse.Namespace) -> int:
|
|
|
130
130
|
if not require_initialized():
|
|
131
131
|
return 1
|
|
132
132
|
|
|
133
|
+
# Check for newer version on PyPI
|
|
134
|
+
from galangal.core.version_check import check_and_prompt_update
|
|
135
|
+
|
|
136
|
+
if not check_and_prompt_update():
|
|
137
|
+
return 0 # User chose to quit to update
|
|
138
|
+
|
|
133
139
|
# Check for new/missing config sections - prompt user if any found
|
|
134
140
|
if not _check_config_updates():
|
|
135
141
|
return 0 # User chose to quit and configure
|
galangal/config/schema.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Configuration schema using Pydantic models.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
from pydantic import BaseModel, Field
|
|
5
|
+
from pydantic import BaseModel, Field, field_validator
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class ProjectConfig(BaseModel):
|
|
@@ -59,6 +59,12 @@ class TestGateConfig(BaseModel):
|
|
|
59
59
|
default=True, description="Stop on first test failure instead of running all"
|
|
60
60
|
)
|
|
61
61
|
|
|
62
|
+
@field_validator("tests", mode="before")
|
|
63
|
+
@classmethod
|
|
64
|
+
def tests_none_to_list(cls, v: list[TestGateTest] | None) -> list[TestGateTest]:
|
|
65
|
+
"""Convert None to empty list (YAML 'tests:' with only comments becomes null)."""
|
|
66
|
+
return v if v is not None else []
|
|
67
|
+
|
|
62
68
|
|
|
63
69
|
class ValidationCommand(BaseModel):
|
|
64
70
|
"""A validation command configuration.
|
|
@@ -228,6 +234,12 @@ class LoggingConfig(BaseModel):
|
|
|
228
234
|
default=None,
|
|
229
235
|
description="Log file path (e.g., 'logs/galangal.jsonl'). If not set, logs only to console.",
|
|
230
236
|
)
|
|
237
|
+
activity_file: str | None = Field(
|
|
238
|
+
default=None,
|
|
239
|
+
description=(
|
|
240
|
+
"Optional activity log file path. Supports {task_name} placeholder for per-task logs."
|
|
241
|
+
),
|
|
242
|
+
)
|
|
231
243
|
json_format: bool = Field(
|
|
232
244
|
default=True, description="Output JSON format (False for pretty console format)"
|
|
233
245
|
)
|
galangal/core/state.py
CHANGED
|
@@ -884,6 +884,7 @@ class WorkflowState:
|
|
|
884
884
|
|
|
885
885
|
Called when validation fails and triggers a rollback to an earlier stage.
|
|
886
886
|
The history is used to detect rollback loops and prevent infinite retries.
|
|
887
|
+
Keeps only the last 50 events to prevent state growth.
|
|
887
888
|
|
|
888
889
|
Args:
|
|
889
890
|
from_stage: Stage that failed and triggered the rollback.
|
|
@@ -892,6 +893,9 @@ class WorkflowState:
|
|
|
892
893
|
"""
|
|
893
894
|
event = RollbackEvent.create(from_stage, to_stage, reason)
|
|
894
895
|
self.rollback_history.append(event)
|
|
896
|
+
# Keep only the last 50 events - plenty for the 1-hour window check
|
|
897
|
+
if len(self.rollback_history) > 50:
|
|
898
|
+
self.rollback_history = self.rollback_history[-50:]
|
|
895
899
|
|
|
896
900
|
def should_allow_rollback(self, target_stage: Stage) -> bool:
|
|
897
901
|
"""
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Version checking utilities for galangal-orchestrate.
|
|
3
|
+
|
|
4
|
+
Checks PyPI for newer versions and prompts users to update.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import NamedTuple
|
|
12
|
+
|
|
13
|
+
from galangal import __version__
|
|
14
|
+
|
|
15
|
+
PYPI_PACKAGE_NAME = "galangal-orchestrate"
|
|
16
|
+
PYPI_URL = f"https://pypi.org/pypi/{PYPI_PACKAGE_NAME}/json"
|
|
17
|
+
REQUEST_TIMEOUT = 3 # seconds
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class VersionInfo(NamedTuple):
|
|
21
|
+
"""Result of a version check."""
|
|
22
|
+
|
|
23
|
+
current: str
|
|
24
|
+
latest: str | None
|
|
25
|
+
update_available: bool
|
|
26
|
+
error: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_version(version: str) -> tuple[int, ...]:
|
|
30
|
+
"""
|
|
31
|
+
Parse a version string into a tuple of integers for comparison.
|
|
32
|
+
|
|
33
|
+
Handles versions like "0.13.0", "1.2.3", etc.
|
|
34
|
+
Non-numeric parts (like "rc1", "beta") are ignored.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
version: Version string (e.g., "0.13.0")
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Tuple of integers (e.g., (0, 13, 0))
|
|
41
|
+
"""
|
|
42
|
+
parts = []
|
|
43
|
+
for part in version.split("."):
|
|
44
|
+
# Extract numeric portion only
|
|
45
|
+
numeric = ""
|
|
46
|
+
for char in part:
|
|
47
|
+
if char.isdigit():
|
|
48
|
+
numeric += char
|
|
49
|
+
else:
|
|
50
|
+
break
|
|
51
|
+
if numeric:
|
|
52
|
+
parts.append(int(numeric))
|
|
53
|
+
return tuple(parts)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def compare_versions(current: str, latest: str) -> int:
|
|
57
|
+
"""
|
|
58
|
+
Compare two version strings.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
current: Current version string
|
|
62
|
+
latest: Latest version string
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
-1 if current < latest (update available)
|
|
66
|
+
0 if current == latest (up to date)
|
|
67
|
+
1 if current > latest (ahead, e.g., dev version)
|
|
68
|
+
"""
|
|
69
|
+
current_parts = parse_version(current)
|
|
70
|
+
latest_parts = parse_version(latest)
|
|
71
|
+
|
|
72
|
+
# Pad shorter version with zeros
|
|
73
|
+
max_len = max(len(current_parts), len(latest_parts))
|
|
74
|
+
current_parts = current_parts + (0,) * (max_len - len(current_parts))
|
|
75
|
+
latest_parts = latest_parts + (0,) * (max_len - len(latest_parts))
|
|
76
|
+
|
|
77
|
+
if current_parts < latest_parts:
|
|
78
|
+
return -1
|
|
79
|
+
elif current_parts > latest_parts:
|
|
80
|
+
return 1
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_latest_version() -> str | None:
|
|
85
|
+
"""
|
|
86
|
+
Fetch the latest version from PyPI.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Latest version string, or None if fetch failed.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
request = urllib.request.Request(
|
|
93
|
+
PYPI_URL,
|
|
94
|
+
headers={"Accept": "application/json"},
|
|
95
|
+
)
|
|
96
|
+
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
|
|
97
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
98
|
+
version = data.get("info", {}).get("version")
|
|
99
|
+
return str(version) if version else None
|
|
100
|
+
except Exception:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def check_for_updates() -> VersionInfo:
|
|
105
|
+
"""
|
|
106
|
+
Check if a newer version is available on PyPI.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
VersionInfo with current/latest versions and update status.
|
|
110
|
+
"""
|
|
111
|
+
current = __version__
|
|
112
|
+
latest = get_latest_version()
|
|
113
|
+
|
|
114
|
+
if latest is None:
|
|
115
|
+
return VersionInfo(
|
|
116
|
+
current=current,
|
|
117
|
+
latest=None,
|
|
118
|
+
update_available=False,
|
|
119
|
+
error="Could not check PyPI",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
update_available = compare_versions(current, latest) < 0
|
|
123
|
+
|
|
124
|
+
return VersionInfo(
|
|
125
|
+
current=current,
|
|
126
|
+
latest=latest,
|
|
127
|
+
update_available=update_available,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_update_command() -> str:
|
|
132
|
+
"""
|
|
133
|
+
Get the pip command to update galangal-orchestrate.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Command string for updating the package.
|
|
137
|
+
"""
|
|
138
|
+
return f"pip install --upgrade {PYPI_PACKAGE_NAME}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def check_and_prompt_update() -> bool:
|
|
142
|
+
"""
|
|
143
|
+
Check for updates and prompt user if one is available.
|
|
144
|
+
|
|
145
|
+
This is designed to be called at the start of `galangal start`.
|
|
146
|
+
It's non-blocking on errors and doesn't interrupt workflow if
|
|
147
|
+
the user declines to update.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
True if user wants to continue (or no update needed).
|
|
151
|
+
False if user wants to quit to update.
|
|
152
|
+
"""
|
|
153
|
+
from rich.prompt import Confirm
|
|
154
|
+
|
|
155
|
+
from galangal.ui.console import console, print_info
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
info = check_for_updates()
|
|
159
|
+
|
|
160
|
+
if info.error:
|
|
161
|
+
# Silently skip if we can't reach PyPI
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
if not info.update_available:
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
# Show update notification
|
|
168
|
+
console.print()
|
|
169
|
+
print_info(
|
|
170
|
+
f"New version available: [cyan]{info.latest}[/cyan] "
|
|
171
|
+
f"(you have {info.current})"
|
|
172
|
+
)
|
|
173
|
+
console.print(f"[dim] Update with: {get_update_command()}[/dim]\n")
|
|
174
|
+
|
|
175
|
+
# Ask if they want to continue or quit to update
|
|
176
|
+
continue_anyway = Confirm.ask(
|
|
177
|
+
"Continue without updating?",
|
|
178
|
+
default=True,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if not continue_anyway:
|
|
182
|
+
# Offer to run the update
|
|
183
|
+
run_update = Confirm.ask(
|
|
184
|
+
"Run update now?",
|
|
185
|
+
default=True,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if run_update:
|
|
189
|
+
return _run_update()
|
|
190
|
+
|
|
191
|
+
console.print(f"\n[dim]Run: {get_update_command()}[/dim]")
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
console.print() # Add spacing before TUI starts
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
except Exception:
|
|
198
|
+
# Non-critical, don't interrupt task creation
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _run_update() -> bool:
|
|
203
|
+
"""
|
|
204
|
+
Run pip upgrade and return whether to continue.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
False (user should restart after update).
|
|
208
|
+
"""
|
|
209
|
+
import subprocess
|
|
210
|
+
import sys
|
|
211
|
+
|
|
212
|
+
from galangal.ui.console import console, print_error, print_success
|
|
213
|
+
|
|
214
|
+
console.print("\n[dim]Running update...[/dim]")
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
# Use sys.executable to ensure we use the same Python
|
|
218
|
+
result = subprocess.run(
|
|
219
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", PYPI_PACKAGE_NAME],
|
|
220
|
+
capture_output=True,
|
|
221
|
+
text=True,
|
|
222
|
+
timeout=120,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if result.returncode == 0:
|
|
226
|
+
print_success("Update complete! Please restart galangal.")
|
|
227
|
+
else:
|
|
228
|
+
print_error(f"Update failed: {result.stderr[:200]}")
|
|
229
|
+
console.print(f"[dim]Try manually: {get_update_command()}[/dim]")
|
|
230
|
+
|
|
231
|
+
except subprocess.TimeoutExpired:
|
|
232
|
+
print_error("Update timed out")
|
|
233
|
+
console.print(f"[dim]Try manually: {get_update_command()}[/dim]")
|
|
234
|
+
except Exception as e:
|
|
235
|
+
print_error(f"Update failed: {e}")
|
|
236
|
+
console.print(f"[dim]Try manually: {get_update_command()}[/dim]")
|
|
237
|
+
|
|
238
|
+
return False # Always return False so user restarts
|
galangal/core/workflow/core.py
CHANGED
|
@@ -250,6 +250,8 @@ def _execute_test_gate(
|
|
|
250
250
|
This is a non-AI stage that runs shell commands to verify tests pass.
|
|
251
251
|
All configured tests must pass for the stage to succeed.
|
|
252
252
|
|
|
253
|
+
Output is streamed line-by-line to the TUI for real-time visibility.
|
|
254
|
+
|
|
253
255
|
Args:
|
|
254
256
|
state: Current workflow state.
|
|
255
257
|
tui_app: TUI app for progress display.
|
|
@@ -259,6 +261,9 @@ def _execute_test_gate(
|
|
|
259
261
|
StageResult indicating success or failure with rollback to DEV.
|
|
260
262
|
"""
|
|
261
263
|
import subprocess
|
|
264
|
+
import threading
|
|
265
|
+
from collections import deque
|
|
266
|
+
from queue import Empty, Full, Queue
|
|
262
267
|
|
|
263
268
|
from galangal.config.loader import get_project_root
|
|
264
269
|
from galangal.logging import workflow_logger
|
|
@@ -266,6 +271,15 @@ def _execute_test_gate(
|
|
|
266
271
|
task_name = state.task_name
|
|
267
272
|
test_config = config.test_gate
|
|
268
273
|
project_root = get_project_root()
|
|
274
|
+
logs_dir = get_task_dir(task_name) / "logs"
|
|
275
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
|
|
277
|
+
max_output_chars = 200_000
|
|
278
|
+
output_queue_size = 200
|
|
279
|
+
|
|
280
|
+
def _sanitize_filename(name: str) -> str:
|
|
281
|
+
cleaned = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in name.lower())
|
|
282
|
+
return cleaned.strip("_") or "test"
|
|
269
283
|
|
|
270
284
|
tui_app.add_activity("Running test gate checks...", "🧪")
|
|
271
285
|
|
|
@@ -274,75 +288,172 @@ def _execute_test_gate(
|
|
|
274
288
|
all_passed = True
|
|
275
289
|
failed_tests: list[str] = []
|
|
276
290
|
|
|
291
|
+
def stream_output(
|
|
292
|
+
proc: subprocess.Popen,
|
|
293
|
+
output_tail: deque[str],
|
|
294
|
+
queue: Queue,
|
|
295
|
+
log_handle: Any | None,
|
|
296
|
+
truncated_flag: list[bool],
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Read process output, stream to queue, and keep a bounded tail."""
|
|
299
|
+
output_chars = 0
|
|
300
|
+
try:
|
|
301
|
+
for line in iter(proc.stdout.readline, ""):
|
|
302
|
+
if not line:
|
|
303
|
+
break
|
|
304
|
+
if log_handle:
|
|
305
|
+
try:
|
|
306
|
+
log_handle.write(line)
|
|
307
|
+
except OSError:
|
|
308
|
+
log_handle = None
|
|
309
|
+
|
|
310
|
+
output_tail.append(line)
|
|
311
|
+
output_chars += len(line)
|
|
312
|
+
while output_chars > max_output_chars and output_tail:
|
|
313
|
+
truncated_flag[0] = True
|
|
314
|
+
removed = output_tail.popleft()
|
|
315
|
+
output_chars -= len(removed)
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
queue.put(line.rstrip("\n"), timeout=1)
|
|
319
|
+
except Full:
|
|
320
|
+
pass
|
|
321
|
+
finally:
|
|
322
|
+
try:
|
|
323
|
+
queue.put_nowait(None) # Signal completion
|
|
324
|
+
except Full:
|
|
325
|
+
pass
|
|
326
|
+
|
|
277
327
|
for test in test_config.tests:
|
|
278
328
|
tui_app.add_activity(f"Running: {test.name}", "▶")
|
|
279
329
|
tui_app.show_message(f"Test Gate: {test.name}", "info")
|
|
280
330
|
|
|
331
|
+
output_tail: deque[str] = deque()
|
|
332
|
+
output_truncated = [False]
|
|
333
|
+
exit_code = -1
|
|
334
|
+
timed_out = False
|
|
335
|
+
log_handle = None
|
|
336
|
+
log_path = logs_dir / f"test_gate_{_sanitize_filename(test.name)}.log"
|
|
337
|
+
|
|
281
338
|
try:
|
|
282
|
-
|
|
339
|
+
try:
|
|
340
|
+
log_handle = open(log_path, "w", encoding="utf-8")
|
|
341
|
+
except OSError:
|
|
342
|
+
log_handle = None
|
|
343
|
+
|
|
344
|
+
# Start process with stdout/stderr combined and piped
|
|
345
|
+
proc = subprocess.Popen(
|
|
283
346
|
test.command,
|
|
284
347
|
shell=True,
|
|
285
348
|
cwd=project_root,
|
|
286
|
-
|
|
349
|
+
stdout=subprocess.PIPE,
|
|
350
|
+
stderr=subprocess.STDOUT,
|
|
287
351
|
text=True,
|
|
288
|
-
|
|
352
|
+
bufsize=1, # Line buffered
|
|
289
353
|
)
|
|
290
354
|
|
|
291
|
-
|
|
292
|
-
|
|
355
|
+
# Stream output in background thread
|
|
356
|
+
output_queue: Queue = Queue(maxsize=output_queue_size)
|
|
357
|
+
reader_thread = threading.Thread(
|
|
358
|
+
target=stream_output,
|
|
359
|
+
args=(proc, output_tail, output_queue, log_handle, output_truncated),
|
|
360
|
+
daemon=True,
|
|
361
|
+
)
|
|
362
|
+
reader_thread.start()
|
|
363
|
+
|
|
364
|
+
# Read from queue and display in TUI until process completes or timeout
|
|
365
|
+
start_time = time.time()
|
|
366
|
+
while True:
|
|
367
|
+
try:
|
|
368
|
+
line = output_queue.get(timeout=0.1)
|
|
369
|
+
if line is None:
|
|
370
|
+
break # Reader finished
|
|
371
|
+
# Stream each line to TUI (prefix with test name for context)
|
|
372
|
+
tui_app.add_activity(f" {line[:200]}", "│")
|
|
373
|
+
except Empty:
|
|
374
|
+
pass
|
|
375
|
+
|
|
376
|
+
# Check timeout
|
|
377
|
+
if time.time() - start_time > test.timeout:
|
|
378
|
+
proc.kill()
|
|
379
|
+
timed_out = True
|
|
380
|
+
break
|
|
293
381
|
|
|
294
|
-
|
|
295
|
-
|
|
382
|
+
# Check if process finished
|
|
383
|
+
if proc.poll() is not None and output_queue.empty():
|
|
384
|
+
break
|
|
296
385
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
"command": test.command,
|
|
300
|
-
"passed": passed,
|
|
301
|
-
"exit_code": result.returncode,
|
|
302
|
-
"output": output_preview,
|
|
303
|
-
})
|
|
386
|
+
# Wait for reader thread to finish
|
|
387
|
+
reader_thread.join(timeout=1.0)
|
|
304
388
|
|
|
305
|
-
|
|
306
|
-
|
|
389
|
+
# Get exit code - must call wait() to ensure returncode is populated
|
|
390
|
+
if timed_out:
|
|
391
|
+
exit_code = -1
|
|
307
392
|
else:
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
393
|
+
try:
|
|
394
|
+
proc.wait(timeout=5) # Ensure process fully terminated
|
|
395
|
+
except subprocess.TimeoutExpired:
|
|
396
|
+
proc.kill()
|
|
397
|
+
proc.wait()
|
|
398
|
+
exit_code = proc.returncode if proc.returncode is not None else -1
|
|
311
399
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
400
|
+
except Exception as e:
|
|
401
|
+
output_tail.append(f"Error running command: {e}")
|
|
402
|
+
exit_code = -1
|
|
403
|
+
finally:
|
|
404
|
+
if log_handle:
|
|
405
|
+
try:
|
|
406
|
+
log_handle.close()
|
|
407
|
+
except OSError:
|
|
408
|
+
pass
|
|
409
|
+
|
|
410
|
+
# Process results - include bounded tail for artifact
|
|
411
|
+
output = "".join(output_tail)
|
|
412
|
+
if output_truncated[0]:
|
|
413
|
+
output = (
|
|
414
|
+
f"[Output truncated to last {max_output_chars} characters. "
|
|
415
|
+
f"Full log: {log_path}]\n\n{output}"
|
|
416
|
+
)
|
|
316
417
|
|
|
317
|
-
|
|
318
|
-
results.append(
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
418
|
+
if timed_out:
|
|
419
|
+
results.append(
|
|
420
|
+
{
|
|
421
|
+
"name": test.name,
|
|
422
|
+
"command": test.command,
|
|
423
|
+
"passed": False,
|
|
424
|
+
"exit_code": -1,
|
|
425
|
+
"output": f"Command timed out after {test.timeout} seconds\n\n{output}",
|
|
426
|
+
}
|
|
427
|
+
)
|
|
325
428
|
tui_app.add_activity(f"✗ {test.name} timed out", "⏱️")
|
|
326
429
|
all_passed = False
|
|
327
430
|
failed_tests.append(test.name)
|
|
328
431
|
|
|
329
432
|
if test_config.fail_fast:
|
|
330
433
|
break
|
|
434
|
+
else:
|
|
435
|
+
passed = exit_code == 0
|
|
436
|
+
results.append(
|
|
437
|
+
{
|
|
438
|
+
"name": test.name,
|
|
439
|
+
"command": test.command,
|
|
440
|
+
"passed": passed,
|
|
441
|
+
"exit_code": exit_code,
|
|
442
|
+
"output": output,
|
|
443
|
+
}
|
|
444
|
+
)
|
|
331
445
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
"
|
|
336
|
-
|
|
337
|
-
|
|
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)
|
|
446
|
+
if passed:
|
|
447
|
+
tui_app.add_activity(f"✓ {test.name} passed", "✅")
|
|
448
|
+
else:
|
|
449
|
+
tui_app.add_activity(f"✗ {test.name} failed (exit code {exit_code})", "❌")
|
|
450
|
+
all_passed = False
|
|
451
|
+
failed_tests.append(test.name)
|
|
343
452
|
|
|
344
|
-
|
|
345
|
-
|
|
453
|
+
# Stop on first failure if fail_fast is enabled
|
|
454
|
+
if test_config.fail_fast:
|
|
455
|
+
tui_app.add_activity("Stopping (fail_fast enabled)", "⚠")
|
|
456
|
+
break
|
|
346
457
|
|
|
347
458
|
# Build TEST_GATE_RESULTS.md artifact
|
|
348
459
|
passed_count = sum(1 for r in results if r["passed"])
|
|
@@ -366,7 +477,9 @@ def _execute_test_gate(
|
|
|
366
477
|
artifact_lines.append(f"\n**Command:** `{r['command']}`\n")
|
|
367
478
|
artifact_lines.append(f"**Exit Code:** {r['exit_code']}\n")
|
|
368
479
|
if r["output"]:
|
|
369
|
-
artifact_lines.append(
|
|
480
|
+
artifact_lines.append(
|
|
481
|
+
f"\n<details>\n<summary>Output</summary>\n\n```\n{r['output']}\n```\n\n</details>\n"
|
|
482
|
+
)
|
|
370
483
|
|
|
371
484
|
write_artifact("TEST_GATE_RESULTS.md", "".join(artifact_lines), task_name)
|
|
372
485
|
tui_app.add_activity("Wrote TEST_GATE_RESULTS.md", "📝")
|
|
@@ -80,12 +80,16 @@ def _run_workflow_with_tui(state: WorkflowState) -> str:
|
|
|
80
80
|
|
|
81
81
|
# Compute hidden stages based on task type and config
|
|
82
82
|
hidden_stages = frozenset(get_hidden_stages_for_task_type(state.task_type, config.stages.skip))
|
|
83
|
+
activity_log_path = None
|
|
84
|
+
if config.logging.activity_file:
|
|
85
|
+
activity_log_path = config.logging.activity_file.format(task_name=state.task_name)
|
|
83
86
|
|
|
84
87
|
app = WorkflowTUIApp(
|
|
85
88
|
state.task_name,
|
|
86
89
|
state.stage.value,
|
|
87
90
|
hidden_stages=hidden_stages,
|
|
88
91
|
stage_durations=state.stage_durations,
|
|
92
|
+
activity_log_path=activity_log_path,
|
|
89
93
|
)
|
|
90
94
|
|
|
91
95
|
# Create workflow engine
|
galangal/prompts/defaults/dev.md
CHANGED
|
@@ -12,11 +12,13 @@ Implement all changes described in PLAN.md while satisfying the acceptance crite
|
|
|
12
12
|
|
|
13
13
|
### If ROLLBACK.md exists (Rollback Run):
|
|
14
14
|
1. Read ROLLBACK.md - contains issues that MUST be fixed
|
|
15
|
-
2. Read the relevant report (QA_REPORT.md, SECURITY_CHECKLIST.md, or
|
|
15
|
+
2. Read the relevant report (QA_REPORT.md, SECURITY_CHECKLIST.md, REVIEW_NOTES.md, or TEST_GATE_RESULTS.md)
|
|
16
16
|
3. Fix ALL issues documented in ROLLBACK.md
|
|
17
17
|
4. Update DEVELOPMENT.md with fixes made
|
|
18
18
|
5. Done - workflow continues to re-run validation
|
|
19
19
|
|
|
20
|
+
**CRITICAL - TEST_GATE Rollbacks:** If rolling back from TEST_GATE, you MUST fix ALL failing tests listed in TEST_GATE_RESULTS.md, **even if the tests were not modified by this task and appear to be pre-existing failures**. The workflow cannot proceed until all tests pass. Do not skip fixing a test because it seems "unrelated" - fix it anyway to unblock the pipeline.
|
|
21
|
+
|
|
20
22
|
### If DEVELOPMENT.md exists (Resuming):
|
|
21
23
|
1. Read DEVELOPMENT.md to understand progress so far
|
|
22
24
|
2. Continue from where the previous session left off
|
|
@@ -73,7 +75,7 @@ Update DEVELOPMENT.md after completing each significant piece of work:
|
|
|
73
75
|
## Important Rules
|
|
74
76
|
|
|
75
77
|
- ONLY implement what's in PLAN.md - nothing more
|
|
76
|
-
- Do NOT fix pre-existing issues unrelated to your task
|
|
78
|
+
- Do NOT fix pre-existing issues unrelated to your task (EXCEPTION: TEST_GATE failures - see above)
|
|
77
79
|
- Follow existing patterns in the codebase
|
|
78
80
|
- Keep changes minimal and focused
|
|
79
81
|
- Do NOT write tests - the TEST stage handles that
|
galangal/ui/tui/app.py
CHANGED
|
@@ -22,6 +22,7 @@ Layout:
|
|
|
22
22
|
import asyncio
|
|
23
23
|
import threading
|
|
24
24
|
import time
|
|
25
|
+
from collections import deque
|
|
25
26
|
from collections.abc import Callable
|
|
26
27
|
from pathlib import Path
|
|
27
28
|
|
|
@@ -86,6 +87,8 @@ class WorkflowTUIApp(WidgetAccessMixin, App[None]):
|
|
|
86
87
|
_paused: Set to True when user requests pause.
|
|
87
88
|
_workflow_result: Result string set by workflow thread.
|
|
88
89
|
"""
|
|
90
|
+
ACTIVITY_LOG_MAX_ENTRIES = 5000
|
|
91
|
+
RICH_LOG_MAX_LINES = 1000
|
|
89
92
|
|
|
90
93
|
TITLE = "Galangal"
|
|
91
94
|
CSS_PATH = "styles/app.tcss"
|
|
@@ -107,6 +110,7 @@ class WorkflowTUIApp(WidgetAccessMixin, App[None]):
|
|
|
107
110
|
max_retries: int = 5,
|
|
108
111
|
hidden_stages: frozenset[str] | None = None,
|
|
109
112
|
stage_durations: dict[str, int] | None = None,
|
|
113
|
+
activity_log_path: str | Path | None = None,
|
|
110
114
|
) -> None:
|
|
111
115
|
super().__init__()
|
|
112
116
|
self.task_name = task_name
|
|
@@ -121,7 +125,19 @@ class WorkflowTUIApp(WidgetAccessMixin, App[None]):
|
|
|
121
125
|
|
|
122
126
|
# Raw lines storage for verbose replay
|
|
123
127
|
self._raw_lines: list[str] = []
|
|
124
|
-
self._activity_entries:
|
|
128
|
+
self._activity_entries: deque[ActivityEntry] = deque(
|
|
129
|
+
maxlen=self.ACTIVITY_LOG_MAX_ENTRIES
|
|
130
|
+
)
|
|
131
|
+
self._activity_log_handle = None
|
|
132
|
+
if activity_log_path:
|
|
133
|
+
try:
|
|
134
|
+
log_path = Path(activity_log_path)
|
|
135
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
self._activity_log_handle = open(
|
|
137
|
+
log_path, "a", encoding="utf-8", buffering=1
|
|
138
|
+
)
|
|
139
|
+
except OSError:
|
|
140
|
+
self._activity_log_handle = None
|
|
125
141
|
|
|
126
142
|
# Workflow control
|
|
127
143
|
self._paused = False
|
|
@@ -147,7 +163,12 @@ class WorkflowTUIApp(WidgetAccessMixin, App[None]):
|
|
|
147
163
|
yield ErrorPanelWidget(id="error-panel", classes="hidden")
|
|
148
164
|
with Horizontal(id="content-area"):
|
|
149
165
|
with VerticalScroll(id="activity-container"):
|
|
150
|
-
yield RichLog(
|
|
166
|
+
yield RichLog(
|
|
167
|
+
id="activity-log",
|
|
168
|
+
highlight=True,
|
|
169
|
+
markup=True,
|
|
170
|
+
max_lines=self.RICH_LOG_MAX_LINES,
|
|
171
|
+
)
|
|
151
172
|
yield FilesPanelWidget(id="files-container")
|
|
152
173
|
yield CurrentActionWidget(id="current-action")
|
|
153
174
|
yield Footer()
|
|
@@ -274,7 +295,11 @@ class WorkflowTUIApp(WidgetAccessMixin, App[None]):
|
|
|
274
295
|
details=details,
|
|
275
296
|
)
|
|
276
297
|
self._activity_entries.append(entry)
|
|
277
|
-
|
|
298
|
+
if self._activity_log_handle:
|
|
299
|
+
try:
|
|
300
|
+
self._activity_log_handle.write(entry.format_export() + "\n")
|
|
301
|
+
except OSError:
|
|
302
|
+
self._activity_log_handle = None
|
|
278
303
|
def _add() -> None:
|
|
279
304
|
# Only show activity in compact (non-verbose) mode
|
|
280
305
|
if not self.verbose:
|
|
@@ -804,7 +829,7 @@ class WorkflowTUIApp(WidgetAccessMixin, App[None]):
|
|
|
804
829
|
else:
|
|
805
830
|
log.write("[#b8bb26]Switched to COMPACT mode[/]")
|
|
806
831
|
# Replay recent activity entries
|
|
807
|
-
for entry in self._activity_entries[-30:]:
|
|
832
|
+
for entry in list(self._activity_entries)[-30:]:
|
|
808
833
|
log.write(entry.format_display())
|
|
809
834
|
|
|
810
835
|
def action_toggle_files(self) -> None:
|
|
@@ -827,7 +852,7 @@ class WorkflowTUIApp(WidgetAccessMixin, App[None]):
|
|
|
827
852
|
@property
|
|
828
853
|
def activity_entries(self) -> list[ActivityEntry]:
|
|
829
854
|
"""Get all activity entries for filtering or export."""
|
|
830
|
-
return self._activity_entries
|
|
855
|
+
return list(self._activity_entries)
|
|
831
856
|
|
|
832
857
|
def export_activity_log(self, path: str | Path) -> None:
|
|
833
858
|
"""
|
|
@@ -836,7 +861,7 @@ class WorkflowTUIApp(WidgetAccessMixin, App[None]):
|
|
|
836
861
|
Args:
|
|
837
862
|
path: File path to write the log to.
|
|
838
863
|
"""
|
|
839
|
-
export_activity_log(self._activity_entries, Path(path))
|
|
864
|
+
export_activity_log(list(self._activity_entries), Path(path))
|
|
840
865
|
|
|
841
866
|
def get_entries_by_level(self, level: ActivityLevel) -> list[ActivityEntry]:
|
|
842
867
|
"""Filter entries by severity level."""
|
|
@@ -846,6 +871,13 @@ class WorkflowTUIApp(WidgetAccessMixin, App[None]):
|
|
|
846
871
|
"""Filter entries by category."""
|
|
847
872
|
return [e for e in self._activity_entries if e.category == category]
|
|
848
873
|
|
|
874
|
+
def on_shutdown(self) -> None:
|
|
875
|
+
if self._activity_log_handle:
|
|
876
|
+
try:
|
|
877
|
+
self._activity_log_handle.close()
|
|
878
|
+
except OSError:
|
|
879
|
+
pass
|
|
880
|
+
|
|
849
881
|
|
|
850
882
|
class StageTUIApp(WorkflowTUIApp):
|
|
851
883
|
"""
|
galangal/ui/tui/widgets.py
CHANGED
|
@@ -189,6 +189,8 @@ class CurrentActionWidget(Static):
|
|
|
189
189
|
class FilesPanelWidget(Static):
|
|
190
190
|
"""Panel showing files that have been read/written."""
|
|
191
191
|
|
|
192
|
+
MAX_FILES_HISTORY = 100
|
|
193
|
+
|
|
192
194
|
def __init__(self, **kwargs):
|
|
193
195
|
super().__init__(**kwargs)
|
|
194
196
|
self._files: list[tuple[str, str]] = []
|
|
@@ -198,6 +200,8 @@ class FilesPanelWidget(Static):
|
|
|
198
200
|
entry = (action, path)
|
|
199
201
|
if entry not in self._files:
|
|
200
202
|
self._files.append(entry)
|
|
203
|
+
if len(self._files) > self.MAX_FILES_HISTORY:
|
|
204
|
+
self._files = self._files[-self.MAX_FILES_HISTORY :]
|
|
201
205
|
self.refresh()
|
|
202
206
|
|
|
203
207
|
def render(self) -> Text:
|
galangal/validation/runner.py
CHANGED
|
@@ -239,26 +239,35 @@ class ValidationRunner:
|
|
|
239
239
|
rollback_to="DEV",
|
|
240
240
|
)
|
|
241
241
|
|
|
242
|
-
#
|
|
243
|
-
|
|
244
|
-
|
|
242
|
+
# Decision file checks - these take precedence over artifact markers
|
|
243
|
+
# because backends (like Codex) write explicit decision files
|
|
244
|
+
|
|
245
|
+
# TEST stage: check TEST_DECISION file
|
|
246
|
+
if stage_lower == "test":
|
|
247
|
+
result = validate_stage_decision("TEST", task_name, "TEST_PLAN.md")
|
|
245
248
|
if not result.success:
|
|
246
249
|
return result
|
|
247
250
|
|
|
248
|
-
# QA stage:
|
|
251
|
+
# QA stage: check QA_DECISION file
|
|
249
252
|
if stage_lower == "qa":
|
|
250
253
|
result = self._check_qa_report(task_name)
|
|
251
254
|
if not result.success:
|
|
252
255
|
return result
|
|
253
256
|
|
|
254
|
-
# REVIEW stage: check REVIEW_DECISION file
|
|
257
|
+
# REVIEW stage: check REVIEW_DECISION file (for Codex/independent reviews)
|
|
255
258
|
if stage_lower == "review":
|
|
256
259
|
result = validate_stage_decision("REVIEW", task_name, "REVIEW_NOTES.md")
|
|
257
260
|
if result.success or result.rollback_to:
|
|
258
|
-
#
|
|
261
|
+
# Valid decision found - use it
|
|
259
262
|
return result
|
|
260
263
|
# Fall through to artifact marker check if decision file missing/unclear
|
|
261
264
|
|
|
265
|
+
# Check for pass/fail markers in artifacts (fallback for AI-driven stages)
|
|
266
|
+
if stage_config.artifact and stage_config.pass_marker:
|
|
267
|
+
result = self._check_artifact_markers(stage_config, task_name)
|
|
268
|
+
if not result.success:
|
|
269
|
+
return result
|
|
270
|
+
|
|
262
271
|
# Check required artifacts
|
|
263
272
|
for artifact_name in stage_config.artifacts_required:
|
|
264
273
|
if not artifact_exists(artifact_name, task_name):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: galangal-orchestrate
|
|
3
|
-
Version: 0.13.
|
|
3
|
+
Version: 0.13.4
|
|
4
4
|
Summary: AI-driven development workflow orchestrator
|
|
5
5
|
Project-URL: Homepage, https://github.com/Galangal-Media/galangal-orchestrate
|
|
6
6
|
Project-URL: Repository, https://github.com/Galangal-Media/galangal-orchestrate
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
galangal/__init__.py,sha256=
|
|
1
|
+
galangal/__init__.py,sha256=0C6zySvsBdJkFxn1BwmA6sO2V2WSSUVcUd2nXUHZhh4,727
|
|
2
2
|
galangal/__main__.py,sha256=zd1kDtEp65NKzxMYIf7eg48aXFxR3FlQgCcaVP5hGbg,114
|
|
3
3
|
galangal/cli.py,sha256=cBPgMpxQD691l5Xs3GKXg-QbNS5YRnnmWhPK83DHgCY,11990
|
|
4
4
|
galangal/exceptions.py,sha256=skJSzdtrB58J_d7qqIuDyDlc1DlhPF-kV8xbgGTmIwA,697
|
|
@@ -6,12 +6,12 @@ galangal/logging.py,sha256=rCgAW9Eux4-AmK2nsBKtWD-w_6jydPaG3YD9RxYvZ2g,9798
|
|
|
6
6
|
galangal/results.py,sha256=Q6kINppudmM8fMYkLZxpqqbzGd5oClURk_vV_fYnkgM,5277
|
|
7
7
|
galangal/ai/__init__.py,sha256=JewgRcJ_N9RkoyM-lC9wUOA_uS-ZeTTviLBep-0hN5E,4663
|
|
8
8
|
galangal/ai/base.py,sha256=GPshLQbNwaPw2ROxiSwM805sxNDv7Vz6Oh-bWdhy9Sw,4993
|
|
9
|
-
galangal/ai/claude.py,sha256=
|
|
10
|
-
galangal/ai/codex.py,sha256=
|
|
9
|
+
galangal/ai/claude.py,sha256=7la3zQL7EiCfXj3QwwgH1zkJvVArVqOBJ2sA0BS5gxU,11176
|
|
10
|
+
galangal/ai/codex.py,sha256=5_84f42zUkTNfn3X-yiXqo9-NyaLMERrq4I9pnHMb6g,13441
|
|
11
11
|
galangal/ai/gemini.py,sha256=cqjYvu1cawC_Cn5ETAmaCn1CZBYO1HjbwpY8gjFGjBQ,1097
|
|
12
|
-
galangal/ai/subprocess.py,sha256=
|
|
12
|
+
galangal/ai/subprocess.py,sha256=rxaMyqtE_WJKmiMWCbLX69C3yzMMICnsElrCISGKnUE,9542
|
|
13
13
|
galangal/commands/__init__.py,sha256=zYzp6PFc3tIVQXuufv9xZG0LNsmq5Eu9lQb3mDT-WrI,750
|
|
14
|
-
galangal/commands/complete.py,sha256=
|
|
14
|
+
galangal/commands/complete.py,sha256=Xff1-P2I0saUJAga2pVZoJ8VXiCBCt7cIf_XVNdqlCA,11862
|
|
15
15
|
galangal/commands/github.py,sha256=W7hA7qMuTsJThcLudOFvONZr5L4EC-8rKf69EK6pCYM,12553
|
|
16
16
|
galangal/commands/init.py,sha256=TWpUO-ofjHC7htPyXrl5CI6b05MzcwJm3A9GR_T6gwI,6549
|
|
17
17
|
galangal/commands/init_wizard.py,sha256=v-h0fsdVPaOqCWUEzD2Qje-bEp17qwVtq6bRBZ7gFuo,24596
|
|
@@ -21,23 +21,24 @@ galangal/commands/prompts.py,sha256=xj4wv5QV1o1z0-4sLZDeO7l4AJYc26724we6jXFD1U8,
|
|
|
21
21
|
galangal/commands/reset.py,sha256=9D1X0okJ_8IjzJXTpKn6xRBxokp4CCJSzJ3fzKNpBBE,1038
|
|
22
22
|
galangal/commands/resume.py,sha256=EOc0HME1B7CeF_tFnNrB7L_8A4GgmwKQK8Rpbv-glSo,921
|
|
23
23
|
galangal/commands/skip.py,sha256=dXVLx6Ek6lPBO8qPWtNXDpTVQ_C6EyU9jlF4xRSy0_w,2040
|
|
24
|
-
galangal/commands/start.py,sha256=
|
|
24
|
+
galangal/commands/start.py,sha256=59NFWc5xfPC6mWyEHX6GgcrRkIgf6qUViyBYs8wtx-Y,22078
|
|
25
25
|
galangal/commands/status.py,sha256=OyW4OIaC6t_YksXZhRNn0rooZFR2B3UPnQRKg9MylPk,1278
|
|
26
26
|
galangal/commands/switch.py,sha256=DMSZOs0052Ixwj1WbEdPOk6ymiCyJ1tn57kx2Pkk2KY,873
|
|
27
27
|
galangal/config/__init__.py,sha256=o8JFiRGhfcGx0SZOn9TMEzbFQ3ZpIEHEY2lEaQZ-XMw,389
|
|
28
28
|
galangal/config/defaults.py,sha256=NSopfnc1xV2u84NaCyG3k5mf_5P6RSKBRAIHIoZUDEY,5116
|
|
29
29
|
galangal/config/loader.py,sha256=qBj73K7WpIn6Fx4aSZ2Qq4AIxhMfXQFvzdZdUwpug4I,4503
|
|
30
|
-
galangal/config/schema.py,sha256=
|
|
30
|
+
galangal/config/schema.py,sha256=0XS3QY8lKiG2364jqJK9BBBnVWeWVyYH1ztx-ljLYq4,12679
|
|
31
31
|
galangal/core/__init__.py,sha256=xwdbMC3dRVRva7nx6Z-ldvsHJpRQ0AuJ2y7BDty-KXk,752
|
|
32
32
|
galangal/core/artifacts.py,sha256=ESDjRR-W6gxJw-w7M1dzvjf1-AObeAnqvMrWsDfNp1c,4101
|
|
33
|
-
galangal/core/state.py,sha256=
|
|
33
|
+
galangal/core/state.py,sha256=ASyXwd9rPjl7RCb-DCCc3etIwPdmrCul3UNN3iMgKOk,35804
|
|
34
34
|
galangal/core/tasks.py,sha256=nf5S959ZxmLINuOml-KppxLrq__2BjyirZZ9LNwbDtE,14251
|
|
35
35
|
galangal/core/utils.py,sha256=_x-IUKQIMfyVUrdXNEkOO_86Cu3U3RaYb6tk6cud4pI,3168
|
|
36
|
+
galangal/core/version_check.py,sha256=06_FTIj31AQeEpRoJSgJ4j8ZvMbHiFzDZjXWuGHRA8c,6422
|
|
36
37
|
galangal/core/workflow/__init__.py,sha256=czmKbueeASxSroOtlImSE86AOikmtNkpnGCUSP7JgSw,1879
|
|
37
|
-
galangal/core/workflow/core.py,sha256=
|
|
38
|
+
galangal/core/workflow/core.py,sha256=1iJdUxhbk6_qM-F1DYlbfqyCEzXS82358lbLbROgeaE,31872
|
|
38
39
|
galangal/core/workflow/engine.py,sha256=XJKSkvRAQgeMIQLOs0Q_7dL8RnnVmfkIOezTcABIVRw,26506
|
|
39
40
|
galangal/core/workflow/pause.py,sha256=N9lpNIWLW7B7hOYcAyV_UgCXMNnKtj9jDkk5x_fE-zE,1258
|
|
40
|
-
galangal/core/workflow/tui_runner.py,sha256=
|
|
41
|
+
galangal/core/workflow/tui_runner.py,sha256=ansGUiBzydBkOXyMnfZg-5J8DmcQsf0-92SfZjJY34U,49035
|
|
41
42
|
galangal/github/__init__.py,sha256=CwSVvTQhVpwAJjyNsc7mXTKgNMFtZy7Eav7m_9ivG7o,765
|
|
42
43
|
galangal/github/client.py,sha256=zY7yrFJLp9Cn079fQDz5aIj5ddB7XOfbf75sY0RBUe8,12563
|
|
43
44
|
galangal/github/images.py,sha256=belPmFLClmwxjcV5dRbm8Dt3HUz8LnHZxOIHmwa5glY,9769
|
|
@@ -47,7 +48,7 @@ galangal/prompts/builder.py,sha256=ljuxp3-oBihebzI7Vr8HQLBhG6_98rYx9kObdCnehik,2
|
|
|
47
48
|
galangal/prompts/defaults/benchmark.md,sha256=xWIinvjn04gLVSoPkeNWZqvPB33iwUP9RCpIH5bsySA,951
|
|
48
49
|
galangal/prompts/defaults/contract.md,sha256=sYc8Rf5Qr7Ql_D5euzVAZDypH1NocRVAERtc37g82Zk,1085
|
|
49
50
|
galangal/prompts/defaults/design.md,sha256=8Wa_saExSlWsBMG78GFSQAC2OoiNIC4Mgg-RPYoBqEE,1280
|
|
50
|
-
galangal/prompts/defaults/dev.md,sha256=
|
|
51
|
+
galangal/prompts/defaults/dev.md,sha256=KBpjemCKL7lr7pEYIRCNPamJfDSbWu8gZTs1ewCW5eI,3355
|
|
51
52
|
galangal/prompts/defaults/docs.md,sha256=myjqZtOdthZo2go_vCa4zcylKZj44bmyVLeoVSOhnGA,3348
|
|
52
53
|
galangal/prompts/defaults/migration.md,sha256=4Gu8V8b0qp9_UAdo1eabl-HLRJ2Y_ohRL1SLHNaiuHo,1615
|
|
53
54
|
galangal/prompts/defaults/pm.md,sha256=Moj7jw978HeesTowxPIoJxtbLRdI1eyIdQY-wcmO370,2982
|
|
@@ -62,18 +63,18 @@ galangal/ui/__init__.py,sha256=B-VP2_Fsnc_W-sbg39pjo933wynRhFG5wJFEBohNLyk,203
|
|
|
62
63
|
galangal/ui/console.py,sha256=dBUd9ANZvPHefnQGSFLmDBPUrJ7MyRcW1_dq_NAgl20,3859
|
|
63
64
|
galangal/ui/tui/__init__.py,sha256=iCRDcdcRPoXMbVg6tsMqShZH47usbztKwU6FDly9QQY,1474
|
|
64
65
|
galangal/ui/tui/adapters.py,sha256=aM9T2x5ru8-ZFYSOzGf3qAHi2bVAnR7nzQsp1Oky_iE,5920
|
|
65
|
-
galangal/ui/tui/app.py,sha256=
|
|
66
|
+
galangal/ui/tui/app.py,sha256=zwRi9YA7UwF50l2JhMfiuX22thKB5t2ddHC2Ek1bssQ,34779
|
|
66
67
|
galangal/ui/tui/entry.py,sha256=Y3hKKxfyFstt6BvEdyAaKQyyvUZnccZVXK1d-RtwuIA,463
|
|
67
68
|
galangal/ui/tui/mixins.py,sha256=6UBbuRCGG8r9YEF_7mgYbToE4sfTGrcU7oQHF1o6I-o,5770
|
|
68
69
|
galangal/ui/tui/modals.py,sha256=8njkQYeYKZ7r8aIhde82yaW4cxBrSZGSuQEEbUb6tFA,11308
|
|
69
70
|
galangal/ui/tui/types.py,sha256=I6p6FNOUZxYM3o2x3qiBeuIA1IWYOF3FQ_QaqloN1CA,3251
|
|
70
|
-
galangal/ui/tui/widgets.py,sha256=
|
|
71
|
+
galangal/ui/tui/widgets.py,sha256=BbhE4yxW1QSwDH2QSjfb04Cq6ug1t7FyFyz-0bU0TBY,9013
|
|
71
72
|
galangal/ui/tui/styles/app.tcss,sha256=qxPdBLQLrDAOoEN-v0nk7EydvkIlk0wd5oTEY4PdsWg,1236
|
|
72
73
|
galangal/ui/tui/styles/modals.tcss,sha256=IxJcvs9gPuK9JDXTQgiYlcZaa_Kz3wbc_mrOOGvfgXA,3404
|
|
73
74
|
galangal/validation/__init__.py,sha256=mS82eDVJXG32rMB9-fI2gMHlKXHVE8KrcvwFH8VHrmU,152
|
|
74
|
-
galangal/validation/runner.py,sha256=
|
|
75
|
-
galangal_orchestrate-0.13.
|
|
76
|
-
galangal_orchestrate-0.13.
|
|
77
|
-
galangal_orchestrate-0.13.
|
|
78
|
-
galangal_orchestrate-0.13.
|
|
79
|
-
galangal_orchestrate-0.13.
|
|
75
|
+
galangal/validation/runner.py,sha256=gMGXyQtp5tP6nbogO2r1KvuVpdvF9vqLUX5AMxhbGIE,41688
|
|
76
|
+
galangal_orchestrate-0.13.4.dist-info/METADATA,sha256=PcAfVDYFR226nNi3XdkccO7A9SiBs0N1bW2bjFojQEY,29547
|
|
77
|
+
galangal_orchestrate-0.13.4.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
78
|
+
galangal_orchestrate-0.13.4.dist-info/entry_points.txt,sha256=ePx9z6dVezeZmGtiuGb2MfQmmXpcYfawF6HtP0WKNfk,47
|
|
79
|
+
galangal_orchestrate-0.13.4.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
80
|
+
galangal_orchestrate-0.13.4.dist-info/RECORD,,
|
|
File without changes
|
{galangal_orchestrate-0.13.0.dist-info → galangal_orchestrate-0.13.4.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{galangal_orchestrate-0.13.0.dist-info → galangal_orchestrate-0.13.4.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|