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,781 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Workflow engine - pure state machine logic separated from UI concerns.
|
|
3
|
+
|
|
4
|
+
The WorkflowEngine encapsulates all workflow state transitions and decision
|
|
5
|
+
logic, emitting events that describe what happened. The TUI layer subscribes
|
|
6
|
+
to these events and translates them to visual updates.
|
|
7
|
+
|
|
8
|
+
This separation enables:
|
|
9
|
+
- Testing workflow logic without UI
|
|
10
|
+
- Alternative UIs (CLI, web, etc.)
|
|
11
|
+
- Clearer reasoning about state transitions
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from enum import Enum, auto
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
from galangal.config.loader import get_config
|
|
21
|
+
from galangal.config.schema import GalangalConfig
|
|
22
|
+
from galangal.core.artifacts import artifact_exists, parse_stage_plan, read_artifact, write_artifact
|
|
23
|
+
from galangal.core.state import (
|
|
24
|
+
STAGE_ORDER,
|
|
25
|
+
Stage,
|
|
26
|
+
WorkflowState,
|
|
27
|
+
get_decision_file_name,
|
|
28
|
+
get_decision_words,
|
|
29
|
+
save_state,
|
|
30
|
+
)
|
|
31
|
+
from galangal.core.workflow.core import (
|
|
32
|
+
append_rollback_entry,
|
|
33
|
+
archive_rollback_if_exists,
|
|
34
|
+
get_next_stage,
|
|
35
|
+
handle_rollback,
|
|
36
|
+
)
|
|
37
|
+
from galangal.core.workflow.core import (
|
|
38
|
+
execute_stage as _execute_stage,
|
|
39
|
+
)
|
|
40
|
+
from galangal.results import StageResult, StageResultType
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from galangal.ai.base import PauseCheck
|
|
44
|
+
from galangal.ui.tui import WorkflowTUIApp
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# =============================================================================
|
|
48
|
+
# Workflow Events - what the engine tells the UI
|
|
49
|
+
# =============================================================================
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class EventType(Enum):
|
|
53
|
+
"""Types of events emitted by the workflow engine."""
|
|
54
|
+
|
|
55
|
+
# Stage lifecycle
|
|
56
|
+
STAGE_STARTED = auto()
|
|
57
|
+
STAGE_COMPLETED = auto()
|
|
58
|
+
STAGE_FAILED = auto()
|
|
59
|
+
|
|
60
|
+
# User interaction required
|
|
61
|
+
APPROVAL_REQUIRED = auto()
|
|
62
|
+
CLARIFICATION_REQUIRED = auto()
|
|
63
|
+
USER_DECISION_REQUIRED = auto()
|
|
64
|
+
MAX_RETRIES_EXCEEDED = auto()
|
|
65
|
+
PREFLIGHT_FAILED = auto()
|
|
66
|
+
ROLLBACK_BLOCKED = auto()
|
|
67
|
+
|
|
68
|
+
# State changes
|
|
69
|
+
ROLLBACK_TRIGGERED = auto()
|
|
70
|
+
STAGE_SKIPPED = auto()
|
|
71
|
+
WORKFLOW_COMPLETE = auto()
|
|
72
|
+
WORKFLOW_PAUSED = auto()
|
|
73
|
+
|
|
74
|
+
# Discovery Q&A
|
|
75
|
+
DISCOVERY_QUESTIONS = auto()
|
|
76
|
+
DISCOVERY_COMPLETE = auto()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class WorkflowEvent:
|
|
81
|
+
"""
|
|
82
|
+
Event emitted by the workflow engine.
|
|
83
|
+
|
|
84
|
+
Events describe what happened in the workflow. The TUI layer
|
|
85
|
+
subscribes to these and translates them to visual updates.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
type: EventType
|
|
89
|
+
stage: Stage | None = None
|
|
90
|
+
message: str = ""
|
|
91
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def event(
|
|
95
|
+
event_type: EventType,
|
|
96
|
+
stage: Stage | None = None,
|
|
97
|
+
message: str = "",
|
|
98
|
+
**kwargs: Any,
|
|
99
|
+
) -> WorkflowEvent:
|
|
100
|
+
"""Factory function for creating events."""
|
|
101
|
+
return WorkflowEvent(type=event_type, stage=stage, message=message, data=kwargs)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# =============================================================================
|
|
105
|
+
# User Actions - what the UI tells the engine
|
|
106
|
+
# =============================================================================
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ActionType(Enum):
|
|
110
|
+
"""Types of user actions sent to the workflow engine."""
|
|
111
|
+
|
|
112
|
+
# Stage control
|
|
113
|
+
CONTINUE = auto() # Proceed with workflow
|
|
114
|
+
RETRY = auto() # Retry current stage
|
|
115
|
+
SKIP = auto() # Skip current stage (Ctrl+N)
|
|
116
|
+
BACK = auto() # Go back to previous stage (Ctrl+B)
|
|
117
|
+
|
|
118
|
+
# Interrupt
|
|
119
|
+
INTERRUPT = auto() # Interrupt with feedback (Ctrl+I)
|
|
120
|
+
MANUAL_EDIT = auto() # Pause for editing (Ctrl+E)
|
|
121
|
+
|
|
122
|
+
# Approval/Decision
|
|
123
|
+
APPROVE = auto() # Approve stage
|
|
124
|
+
REJECT = auto() # Reject stage
|
|
125
|
+
VIEW_ARTIFACT = auto() # View full artifact content
|
|
126
|
+
|
|
127
|
+
# Workflow control
|
|
128
|
+
QUIT = auto() # Stop workflow
|
|
129
|
+
FIX_IN_DEV = auto() # Force rollback to DEV
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class UserAction:
|
|
134
|
+
"""
|
|
135
|
+
Action from the user to the workflow engine.
|
|
136
|
+
|
|
137
|
+
Actions tell the engine what the user wants to do.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
type: ActionType
|
|
141
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def action(action_type: ActionType, **kwargs: Any) -> UserAction:
|
|
145
|
+
"""Factory function for creating actions."""
|
|
146
|
+
return UserAction(type=action_type, data=kwargs)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# =============================================================================
|
|
150
|
+
# Workflow Engine
|
|
151
|
+
# =============================================================================
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class WorkflowEngine:
|
|
155
|
+
"""
|
|
156
|
+
Pure state machine for workflow execution.
|
|
157
|
+
|
|
158
|
+
The engine encapsulates all workflow logic without any UI knowledge.
|
|
159
|
+
It receives user actions and emits events describing what happened.
|
|
160
|
+
|
|
161
|
+
Usage:
|
|
162
|
+
engine = WorkflowEngine(state)
|
|
163
|
+
|
|
164
|
+
# Execute a stage
|
|
165
|
+
event = engine.execute_current_stage(tui_app, pause_check)
|
|
166
|
+
|
|
167
|
+
# Handle the event based on its type
|
|
168
|
+
if event.type == EventType.APPROVAL_REQUIRED:
|
|
169
|
+
# Collect approval from user via UI
|
|
170
|
+
user_action = action(ActionType.APPROVE, approver="John")
|
|
171
|
+
event = engine.handle_action(user_action)
|
|
172
|
+
|
|
173
|
+
The TUI layer is responsible for:
|
|
174
|
+
- Displaying events visually
|
|
175
|
+
- Collecting user input
|
|
176
|
+
- Translating input to UserAction objects
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(self, state: WorkflowState, config: GalangalConfig | None = None):
|
|
180
|
+
"""
|
|
181
|
+
Initialize the workflow engine.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
state: Current workflow state.
|
|
185
|
+
config: Optional config, loaded from project if not provided.
|
|
186
|
+
"""
|
|
187
|
+
self.state = state
|
|
188
|
+
self.config = config or get_config()
|
|
189
|
+
self._pending_result: StageResult | None = None
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def current_stage(self) -> Stage:
|
|
193
|
+
"""Get the current workflow stage."""
|
|
194
|
+
return self.state.stage
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def is_complete(self) -> bool:
|
|
198
|
+
"""Check if workflow is complete."""
|
|
199
|
+
return self.state.stage == Stage.COMPLETE
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def max_retries(self) -> int:
|
|
203
|
+
"""Get max retries from config."""
|
|
204
|
+
return self.config.stages.max_retries
|
|
205
|
+
|
|
206
|
+
def check_github_issue(self) -> WorkflowEvent | None:
|
|
207
|
+
"""
|
|
208
|
+
Check if linked GitHub issue is still open.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
WorkflowEvent if issue was closed, None otherwise.
|
|
212
|
+
"""
|
|
213
|
+
if not self.state.github_issue:
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
from galangal.github.issues import is_issue_open
|
|
218
|
+
|
|
219
|
+
if is_issue_open(self.state.github_issue) is False:
|
|
220
|
+
return event(
|
|
221
|
+
EventType.WORKFLOW_PAUSED,
|
|
222
|
+
message=f"GitHub issue #{self.state.github_issue} has been closed",
|
|
223
|
+
reason="github_issue_closed",
|
|
224
|
+
)
|
|
225
|
+
except Exception:
|
|
226
|
+
pass # Non-critical
|
|
227
|
+
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
def start_stage_timer(self) -> None:
|
|
231
|
+
"""Start timing the current stage if not already started."""
|
|
232
|
+
if not self.state.stage_start_time:
|
|
233
|
+
self.state.start_stage_timer()
|
|
234
|
+
save_state(self.state)
|
|
235
|
+
|
|
236
|
+
def execute_current_stage(
|
|
237
|
+
self,
|
|
238
|
+
tui_app: WorkflowTUIApp,
|
|
239
|
+
pause_check: PauseCheck | None = None,
|
|
240
|
+
) -> WorkflowEvent:
|
|
241
|
+
"""
|
|
242
|
+
Execute the current stage and return an event describing the result.
|
|
243
|
+
|
|
244
|
+
This is a blocking operation that invokes the AI backend.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
tui_app: TUI app for progress display (passed to execute_stage).
|
|
248
|
+
pause_check: Callback returning True if pause requested.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
WorkflowEvent describing the execution result.
|
|
252
|
+
"""
|
|
253
|
+
result = _execute_stage(self.state, tui_app=tui_app, pause_check=pause_check)
|
|
254
|
+
self._pending_result = result
|
|
255
|
+
return self._process_stage_result(result)
|
|
256
|
+
|
|
257
|
+
def _process_stage_result(self, result: StageResult) -> WorkflowEvent:
|
|
258
|
+
"""Convert a StageResult to a WorkflowEvent."""
|
|
259
|
+
stage = self.state.stage
|
|
260
|
+
|
|
261
|
+
if result.type == StageResultType.PAUSED:
|
|
262
|
+
return event(EventType.WORKFLOW_PAUSED, stage=stage, reason="user_paused")
|
|
263
|
+
|
|
264
|
+
if result.success:
|
|
265
|
+
# Check if approval is needed
|
|
266
|
+
metadata = stage.metadata
|
|
267
|
+
if metadata.requires_approval and metadata.approval_artifact:
|
|
268
|
+
if not artifact_exists(metadata.approval_artifact, self.state.task_name):
|
|
269
|
+
return event(
|
|
270
|
+
EventType.APPROVAL_REQUIRED,
|
|
271
|
+
stage=stage,
|
|
272
|
+
artifact_name=metadata.approval_artifact,
|
|
273
|
+
)
|
|
274
|
+
return event(EventType.STAGE_COMPLETED, stage=stage, message=result.message)
|
|
275
|
+
|
|
276
|
+
# Handle different failure types
|
|
277
|
+
if result.type == StageResultType.PREFLIGHT_FAILED:
|
|
278
|
+
return event(
|
|
279
|
+
EventType.PREFLIGHT_FAILED,
|
|
280
|
+
stage=stage,
|
|
281
|
+
message=result.message,
|
|
282
|
+
details=result.output or "",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if result.type == StageResultType.CLARIFICATION_NEEDED:
|
|
286
|
+
questions = self._parse_questions()
|
|
287
|
+
return event(
|
|
288
|
+
EventType.CLARIFICATION_REQUIRED,
|
|
289
|
+
stage=stage,
|
|
290
|
+
questions=questions,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if result.type == StageResultType.USER_DECISION_NEEDED:
|
|
294
|
+
return event(
|
|
295
|
+
EventType.USER_DECISION_REQUIRED,
|
|
296
|
+
stage=stage,
|
|
297
|
+
message=result.message,
|
|
298
|
+
artifact_preview=(result.output or "")[:500],
|
|
299
|
+
full_content=result.output or "",
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
if result.type == StageResultType.ROLLBACK_REQUIRED:
|
|
303
|
+
# Try to process rollback
|
|
304
|
+
if handle_rollback(self.state, result):
|
|
305
|
+
return event(
|
|
306
|
+
EventType.ROLLBACK_TRIGGERED,
|
|
307
|
+
stage=stage,
|
|
308
|
+
message=result.message,
|
|
309
|
+
from_stage=stage,
|
|
310
|
+
to_stage=result.rollback_to,
|
|
311
|
+
)
|
|
312
|
+
else:
|
|
313
|
+
# Rollback was blocked (loop detection)
|
|
314
|
+
rollback_count = (
|
|
315
|
+
self.state.get_rollback_count(result.rollback_to) if result.rollback_to else 0
|
|
316
|
+
)
|
|
317
|
+
target = result.rollback_to.value if result.rollback_to else "None"
|
|
318
|
+
|
|
319
|
+
if rollback_count >= 3:
|
|
320
|
+
block_reason = f"Too many rollbacks to {target} ({rollback_count} in last hour)"
|
|
321
|
+
elif result.rollback_to is None:
|
|
322
|
+
block_reason = "Rollback target not specified in validation"
|
|
323
|
+
else:
|
|
324
|
+
block_reason = "Rollback blocked (unknown reason)"
|
|
325
|
+
|
|
326
|
+
return event(
|
|
327
|
+
EventType.ROLLBACK_BLOCKED,
|
|
328
|
+
stage=stage,
|
|
329
|
+
message=result.message,
|
|
330
|
+
block_reason=block_reason,
|
|
331
|
+
target_stage=target,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Generic failure - check retries
|
|
335
|
+
error_message = result.output or result.message
|
|
336
|
+
self.state.record_failure(error_message)
|
|
337
|
+
|
|
338
|
+
if not self.state.can_retry(self.max_retries):
|
|
339
|
+
return event(
|
|
340
|
+
EventType.MAX_RETRIES_EXCEEDED,
|
|
341
|
+
stage=stage,
|
|
342
|
+
message=error_message,
|
|
343
|
+
attempts=self.state.attempt,
|
|
344
|
+
max_retries=self.max_retries,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
return event(
|
|
348
|
+
EventType.STAGE_FAILED,
|
|
349
|
+
stage=stage,
|
|
350
|
+
message=error_message,
|
|
351
|
+
attempt=self.state.attempt,
|
|
352
|
+
max_retries=self.max_retries,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def _parse_questions(self) -> list[str]:
|
|
356
|
+
"""Parse questions from QUESTIONS.md artifact."""
|
|
357
|
+
content = read_artifact("QUESTIONS.md", self.state.task_name)
|
|
358
|
+
if not content:
|
|
359
|
+
return []
|
|
360
|
+
|
|
361
|
+
import re
|
|
362
|
+
|
|
363
|
+
questions = []
|
|
364
|
+
for line in content.split("\n"):
|
|
365
|
+
line = line.strip()
|
|
366
|
+
if not line or line.startswith("# "):
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
# Numbered or bulleted questions
|
|
370
|
+
match = re.match(r"^\d+[\.\)]\s*(.+)$", line)
|
|
371
|
+
if match:
|
|
372
|
+
questions.append(match.group(1))
|
|
373
|
+
elif line.startswith("- ") or line.startswith("* "):
|
|
374
|
+
questions.append(line[2:].strip())
|
|
375
|
+
elif line.startswith("## "):
|
|
376
|
+
questions.append(line[3:].strip())
|
|
377
|
+
|
|
378
|
+
return questions
|
|
379
|
+
|
|
380
|
+
def handle_action(
|
|
381
|
+
self, user_action: UserAction, tui_app: WorkflowTUIApp | None = None
|
|
382
|
+
) -> WorkflowEvent:
|
|
383
|
+
"""
|
|
384
|
+
Handle a user action and return the resulting event.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
user_action: The action from the user.
|
|
388
|
+
tui_app: Optional TUI app for archive notifications.
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
WorkflowEvent describing what happened.
|
|
392
|
+
"""
|
|
393
|
+
action_type = user_action.type
|
|
394
|
+
data = user_action.data
|
|
395
|
+
|
|
396
|
+
if action_type == ActionType.CONTINUE:
|
|
397
|
+
return self._advance_to_next_stage(tui_app)
|
|
398
|
+
|
|
399
|
+
if action_type == ActionType.RETRY:
|
|
400
|
+
# Just continue - the loop will retry the same stage
|
|
401
|
+
save_state(self.state)
|
|
402
|
+
return event(
|
|
403
|
+
EventType.STAGE_STARTED,
|
|
404
|
+
stage=self.state.stage,
|
|
405
|
+
attempt=self.state.attempt,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
if action_type == ActionType.SKIP:
|
|
409
|
+
return self._handle_skip()
|
|
410
|
+
|
|
411
|
+
if action_type == ActionType.BACK:
|
|
412
|
+
return self._handle_back()
|
|
413
|
+
|
|
414
|
+
if action_type == ActionType.INTERRUPT:
|
|
415
|
+
return self._handle_interrupt(
|
|
416
|
+
feedback=data.get("feedback", ""),
|
|
417
|
+
target_stage=data.get("target_stage"),
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
if action_type == ActionType.MANUAL_EDIT:
|
|
421
|
+
# Just return an event - TUI handles the pause
|
|
422
|
+
return event(EventType.WORKFLOW_PAUSED, reason="manual_edit")
|
|
423
|
+
|
|
424
|
+
if action_type == ActionType.APPROVE:
|
|
425
|
+
return self._handle_approval(
|
|
426
|
+
approved=True,
|
|
427
|
+
approver=data.get("approver", ""),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
if action_type == ActionType.REJECT:
|
|
431
|
+
return self._handle_approval(
|
|
432
|
+
approved=False,
|
|
433
|
+
reason=data.get("reason", ""),
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
if action_type == ActionType.QUIT:
|
|
437
|
+
save_state(self.state)
|
|
438
|
+
return event(EventType.WORKFLOW_PAUSED, reason="user_quit")
|
|
439
|
+
|
|
440
|
+
if action_type == ActionType.FIX_IN_DEV:
|
|
441
|
+
return self._handle_fix_in_dev(data.get("error", ""), data.get("feedback", ""))
|
|
442
|
+
|
|
443
|
+
# Unknown action
|
|
444
|
+
return event(EventType.WORKFLOW_PAUSED, reason="unknown_action")
|
|
445
|
+
|
|
446
|
+
def _advance_to_next_stage(self, tui_app: WorkflowTUIApp | None = None) -> WorkflowEvent:
|
|
447
|
+
"""Advance to the next stage after success."""
|
|
448
|
+
current = self.state.stage
|
|
449
|
+
|
|
450
|
+
# Record duration and passed stage
|
|
451
|
+
duration = self.state.record_stage_duration()
|
|
452
|
+
self.state.record_passed_stage(current)
|
|
453
|
+
|
|
454
|
+
# Archive rollback after successful DEV
|
|
455
|
+
if current == Stage.DEV and tui_app:
|
|
456
|
+
archive_rollback_if_exists(self.state.task_name, tui_app)
|
|
457
|
+
self.state.clear_passed_stages()
|
|
458
|
+
|
|
459
|
+
# Find next stage
|
|
460
|
+
next_stage = get_next_stage(current, self.state)
|
|
461
|
+
skipped_stages = self._get_skipped_stages(current, next_stage)
|
|
462
|
+
|
|
463
|
+
if next_stage:
|
|
464
|
+
self.state.stage = next_stage
|
|
465
|
+
self.state.reset_attempts()
|
|
466
|
+
self.state.awaiting_approval = False
|
|
467
|
+
self.state.clarification_required = False
|
|
468
|
+
save_state(self.state)
|
|
469
|
+
|
|
470
|
+
return event(
|
|
471
|
+
EventType.STAGE_STARTED,
|
|
472
|
+
stage=next_stage,
|
|
473
|
+
attempt=self.state.attempt,
|
|
474
|
+
skipped_stages=skipped_stages,
|
|
475
|
+
duration=duration,
|
|
476
|
+
)
|
|
477
|
+
else:
|
|
478
|
+
self.state.stage = Stage.COMPLETE
|
|
479
|
+
self.state.clear_fast_track()
|
|
480
|
+
self.state.clear_passed_stages()
|
|
481
|
+
save_state(self.state)
|
|
482
|
+
return event(EventType.WORKFLOW_COMPLETE, duration=duration)
|
|
483
|
+
|
|
484
|
+
def _get_skipped_stages(self, current: Stage, next_stage: Stage | None) -> list[Stage]:
|
|
485
|
+
"""Get list of stages that were skipped between current and next."""
|
|
486
|
+
if next_stage is None:
|
|
487
|
+
return []
|
|
488
|
+
|
|
489
|
+
current_idx = STAGE_ORDER.index(current)
|
|
490
|
+
next_idx = STAGE_ORDER.index(next_stage)
|
|
491
|
+
|
|
492
|
+
if next_idx > current_idx + 1:
|
|
493
|
+
return STAGE_ORDER[current_idx + 1 : next_idx]
|
|
494
|
+
return []
|
|
495
|
+
|
|
496
|
+
def _handle_skip(self) -> WorkflowEvent:
|
|
497
|
+
"""Handle skip stage action (Ctrl+N)."""
|
|
498
|
+
skipped_stage = self.state.stage
|
|
499
|
+
next_stage = get_next_stage(self.state.stage, self.state)
|
|
500
|
+
|
|
501
|
+
if next_stage:
|
|
502
|
+
self.state.stage = next_stage
|
|
503
|
+
self.state.reset_attempts()
|
|
504
|
+
save_state(self.state)
|
|
505
|
+
return event(
|
|
506
|
+
EventType.STAGE_SKIPPED,
|
|
507
|
+
stage=skipped_stage,
|
|
508
|
+
next_stage=next_stage,
|
|
509
|
+
)
|
|
510
|
+
else:
|
|
511
|
+
self.state.stage = Stage.COMPLETE
|
|
512
|
+
save_state(self.state)
|
|
513
|
+
return event(EventType.WORKFLOW_COMPLETE)
|
|
514
|
+
|
|
515
|
+
def _handle_back(self) -> WorkflowEvent:
|
|
516
|
+
"""Handle back stage action (Ctrl+B)."""
|
|
517
|
+
current_idx = STAGE_ORDER.index(self.state.stage)
|
|
518
|
+
if current_idx > 0:
|
|
519
|
+
prev_stage = STAGE_ORDER[current_idx - 1]
|
|
520
|
+
self.state.stage = prev_stage
|
|
521
|
+
self.state.reset_attempts()
|
|
522
|
+
save_state(self.state)
|
|
523
|
+
return event(
|
|
524
|
+
EventType.STAGE_STARTED,
|
|
525
|
+
stage=prev_stage,
|
|
526
|
+
attempt=self.state.attempt,
|
|
527
|
+
)
|
|
528
|
+
else:
|
|
529
|
+
# Already at first stage
|
|
530
|
+
return event(
|
|
531
|
+
EventType.STAGE_STARTED,
|
|
532
|
+
stage=self.state.stage,
|
|
533
|
+
message="Already at first stage",
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
def _handle_interrupt(self, feedback: str, target_stage: Stage | None) -> WorkflowEvent:
|
|
537
|
+
"""Handle interrupt with feedback (Ctrl+I)."""
|
|
538
|
+
interrupted_stage = self.state.stage
|
|
539
|
+
|
|
540
|
+
# Determine valid rollback targets
|
|
541
|
+
current_idx = STAGE_ORDER.index(interrupted_stage)
|
|
542
|
+
valid_targets = [s for s in STAGE_ORDER[:current_idx] if s != Stage.PREFLIGHT]
|
|
543
|
+
|
|
544
|
+
# Use provided target or determine default
|
|
545
|
+
if target_stage is None:
|
|
546
|
+
if interrupted_stage == Stage.PM:
|
|
547
|
+
target_stage = Stage.PM
|
|
548
|
+
elif interrupted_stage == Stage.DESIGN:
|
|
549
|
+
target_stage = Stage.PM
|
|
550
|
+
else:
|
|
551
|
+
target_stage = Stage.DEV
|
|
552
|
+
|
|
553
|
+
# Validate target is in valid targets
|
|
554
|
+
if valid_targets and target_stage not in valid_targets:
|
|
555
|
+
target_stage = valid_targets[0] if valid_targets else interrupted_stage
|
|
556
|
+
|
|
557
|
+
# Append to ROLLBACK.md
|
|
558
|
+
append_rollback_entry(
|
|
559
|
+
task_name=self.state.task_name,
|
|
560
|
+
source=f"User interrupt (Ctrl+I) during {interrupted_stage.value}",
|
|
561
|
+
from_stage=interrupted_stage.value,
|
|
562
|
+
target_stage=target_stage.value,
|
|
563
|
+
reason=feedback or "No details provided",
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
self.state.stage = target_stage
|
|
567
|
+
self.state.last_failure = f"Interrupt feedback from {interrupted_stage.value}: {feedback}"
|
|
568
|
+
self.state.reset_attempts(clear_failure=False)
|
|
569
|
+
save_state(self.state)
|
|
570
|
+
|
|
571
|
+
return event(
|
|
572
|
+
EventType.ROLLBACK_TRIGGERED,
|
|
573
|
+
stage=interrupted_stage,
|
|
574
|
+
message=f"Interrupted - rolling back to {target_stage.value}",
|
|
575
|
+
from_stage=interrupted_stage,
|
|
576
|
+
to_stage=target_stage,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
def _handle_approval(
|
|
580
|
+
self, approved: bool, approver: str = "", reason: str = ""
|
|
581
|
+
) -> WorkflowEvent:
|
|
582
|
+
"""Handle approval decision."""
|
|
583
|
+
stage = self.state.stage
|
|
584
|
+
metadata = stage.metadata
|
|
585
|
+
approval_artifact = metadata.approval_artifact
|
|
586
|
+
|
|
587
|
+
if approved and approver:
|
|
588
|
+
from galangal.core.utils import now_formatted
|
|
589
|
+
|
|
590
|
+
content = f"""# {stage.value} Approval
|
|
591
|
+
|
|
592
|
+
- **Status:** Approved
|
|
593
|
+
- **Approved By:** {approver}
|
|
594
|
+
- **Date:** {now_formatted()}
|
|
595
|
+
"""
|
|
596
|
+
if approval_artifact:
|
|
597
|
+
write_artifact(approval_artifact, content, self.state.task_name)
|
|
598
|
+
|
|
599
|
+
# PM-specific: Parse and store stage plan
|
|
600
|
+
if stage == Stage.PM:
|
|
601
|
+
stage_plan = parse_stage_plan(self.state.task_name)
|
|
602
|
+
if stage_plan:
|
|
603
|
+
self.state.stage_plan = stage_plan
|
|
604
|
+
save_state(self.state)
|
|
605
|
+
|
|
606
|
+
return event(
|
|
607
|
+
EventType.STAGE_COMPLETED,
|
|
608
|
+
stage=stage,
|
|
609
|
+
message=f"Approved by {approver}",
|
|
610
|
+
approver=approver,
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
else:
|
|
614
|
+
# Rejected
|
|
615
|
+
self.state.last_failure = f"{stage.value} rejected: {reason}"
|
|
616
|
+
self.state.reset_attempts(clear_failure=False)
|
|
617
|
+
save_state(self.state)
|
|
618
|
+
|
|
619
|
+
return event(
|
|
620
|
+
EventType.STAGE_FAILED,
|
|
621
|
+
stage=stage,
|
|
622
|
+
message=f"Rejected: {reason}",
|
|
623
|
+
reason=reason,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
def _handle_fix_in_dev(self, error: str, feedback: str = "") -> WorkflowEvent:
|
|
627
|
+
"""Force rollback to DEV with feedback."""
|
|
628
|
+
original_stage = self.state.stage.value
|
|
629
|
+
|
|
630
|
+
# Clear rollback history for DEV to allow rollback
|
|
631
|
+
self.state.rollback_history = [
|
|
632
|
+
r for r in self.state.rollback_history if r.to_stage != Stage.DEV.value
|
|
633
|
+
]
|
|
634
|
+
|
|
635
|
+
self.state.stage = Stage.DEV
|
|
636
|
+
self.state.last_failure = (
|
|
637
|
+
f"Manual rollback from {original_stage}: {feedback or error[:500]}"
|
|
638
|
+
)
|
|
639
|
+
self.state.reset_attempts(clear_failure=False)
|
|
640
|
+
save_state(self.state)
|
|
641
|
+
|
|
642
|
+
return event(
|
|
643
|
+
EventType.ROLLBACK_TRIGGERED,
|
|
644
|
+
message="Manual rollback to DEV",
|
|
645
|
+
from_stage=Stage.from_str(original_stage),
|
|
646
|
+
to_stage=Stage.DEV,
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
def handle_user_decision(
|
|
650
|
+
self,
|
|
651
|
+
choice: str,
|
|
652
|
+
tui_app: WorkflowTUIApp | None = None,
|
|
653
|
+
) -> WorkflowEvent:
|
|
654
|
+
"""
|
|
655
|
+
Handle user decision for stages requiring manual approval.
|
|
656
|
+
|
|
657
|
+
Args:
|
|
658
|
+
choice: "approve", "reject", or "quit"
|
|
659
|
+
tui_app: Optional TUI app for archive notifications.
|
|
660
|
+
|
|
661
|
+
Returns:
|
|
662
|
+
WorkflowEvent describing the result.
|
|
663
|
+
"""
|
|
664
|
+
from galangal.logging import workflow_logger
|
|
665
|
+
|
|
666
|
+
stage = self.state.stage
|
|
667
|
+
|
|
668
|
+
if choice == "approve":
|
|
669
|
+
# Write decision file
|
|
670
|
+
decision_file = get_decision_file_name(stage)
|
|
671
|
+
approve_word, _ = get_decision_words(stage)
|
|
672
|
+
if decision_file and approve_word:
|
|
673
|
+
write_artifact(decision_file, approve_word, self.state.task_name)
|
|
674
|
+
else:
|
|
675
|
+
decision_file = f"{stage.value.upper()}_DECISION"
|
|
676
|
+
write_artifact(decision_file, "APPROVE", self.state.task_name)
|
|
677
|
+
|
|
678
|
+
workflow_logger.user_decision(
|
|
679
|
+
stage=stage.value,
|
|
680
|
+
task_name=self.state.task_name,
|
|
681
|
+
decision="approve",
|
|
682
|
+
reason="decision file missing",
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
# Record and advance
|
|
686
|
+
self.state.record_stage_duration()
|
|
687
|
+
self.state.record_passed_stage(stage)
|
|
688
|
+
|
|
689
|
+
return self._advance_to_next_stage(tui_app)
|
|
690
|
+
|
|
691
|
+
elif choice == "reject":
|
|
692
|
+
# Write rejection decision
|
|
693
|
+
original_stage = stage.value
|
|
694
|
+
decision_file = get_decision_file_name(stage)
|
|
695
|
+
_, reject_word = get_decision_words(stage)
|
|
696
|
+
if decision_file and reject_word:
|
|
697
|
+
write_artifact(decision_file, reject_word, self.state.task_name)
|
|
698
|
+
else:
|
|
699
|
+
decision_file = f"{original_stage.upper()}_DECISION"
|
|
700
|
+
write_artifact(decision_file, "REQUEST_CHANGES", self.state.task_name)
|
|
701
|
+
|
|
702
|
+
workflow_logger.user_decision(
|
|
703
|
+
stage=original_stage,
|
|
704
|
+
task_name=self.state.task_name,
|
|
705
|
+
decision="reject",
|
|
706
|
+
reason="decision file missing",
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
# Rollback to DEV
|
|
710
|
+
self.state.last_failure = f"User rejected {original_stage} stage"
|
|
711
|
+
self.state.stage = Stage.DEV
|
|
712
|
+
self.state.reset_attempts(clear_failure=False)
|
|
713
|
+
save_state(self.state)
|
|
714
|
+
|
|
715
|
+
return event(
|
|
716
|
+
EventType.ROLLBACK_TRIGGERED,
|
|
717
|
+
stage=Stage.from_str(original_stage),
|
|
718
|
+
message="User rejected - rolling back to DEV",
|
|
719
|
+
from_stage=Stage.from_str(original_stage),
|
|
720
|
+
to_stage=Stage.DEV,
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
else: # quit
|
|
724
|
+
workflow_logger.user_decision(
|
|
725
|
+
stage=stage.value,
|
|
726
|
+
task_name=self.state.task_name,
|
|
727
|
+
decision="quit",
|
|
728
|
+
reason="decision file missing",
|
|
729
|
+
)
|
|
730
|
+
save_state(self.state)
|
|
731
|
+
return event(EventType.WORKFLOW_PAUSED, reason="user_quit")
|
|
732
|
+
|
|
733
|
+
def handle_clarification_answers(
|
|
734
|
+
self, questions: list[str], answers: list[str]
|
|
735
|
+
) -> WorkflowEvent:
|
|
736
|
+
"""
|
|
737
|
+
Handle answers to clarification questions.
|
|
738
|
+
|
|
739
|
+
Args:
|
|
740
|
+
questions: The questions that were asked.
|
|
741
|
+
answers: User's answers.
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
WorkflowEvent to continue workflow.
|
|
745
|
+
"""
|
|
746
|
+
# Write ANSWERS.md
|
|
747
|
+
lines = ["# Answers\n", "Responses to clarifying questions.\n\n"]
|
|
748
|
+
for i, (q, a) in enumerate(zip(questions, answers), 1):
|
|
749
|
+
lines.append(f"## Question {i}\n")
|
|
750
|
+
lines.append(f"**Q:** {q}\n\n")
|
|
751
|
+
lines.append(f"**A:** {a}\n\n")
|
|
752
|
+
|
|
753
|
+
write_artifact("ANSWERS.md", "".join(lines), self.state.task_name)
|
|
754
|
+
|
|
755
|
+
# Clear clarification flag
|
|
756
|
+
self.state.clarification_required = False
|
|
757
|
+
save_state(self.state)
|
|
758
|
+
|
|
759
|
+
return event(
|
|
760
|
+
EventType.STAGE_STARTED,
|
|
761
|
+
stage=self.state.stage,
|
|
762
|
+
message="Answers saved - resuming stage",
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
def get_valid_interrupt_targets(self) -> list[Stage]:
|
|
766
|
+
"""Get valid rollback targets for interrupt."""
|
|
767
|
+
current_idx = STAGE_ORDER.index(self.state.stage)
|
|
768
|
+
valid: list[Stage] = [s for s in STAGE_ORDER[:current_idx] if s != Stage.PREFLIGHT]
|
|
769
|
+
|
|
770
|
+
if self.state.stage == Stage.PM:
|
|
771
|
+
return [Stage.PM]
|
|
772
|
+
return valid if valid else [self.state.stage]
|
|
773
|
+
|
|
774
|
+
def get_default_interrupt_target(self) -> Stage:
|
|
775
|
+
"""Get default rollback target for interrupt."""
|
|
776
|
+
if self.state.stage == Stage.PM:
|
|
777
|
+
return Stage.PM
|
|
778
|
+
elif self.state.stage == Stage.DESIGN:
|
|
779
|
+
return Stage.PM
|
|
780
|
+
else:
|
|
781
|
+
return Stage.DEV
|