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,1322 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TUI-based workflow runner using persistent Textual app with async/await.
|
|
3
|
+
|
|
4
|
+
This module uses Textual's async capabilities for cleaner coordination
|
|
5
|
+
between UI events and workflow logic, eliminating manual threading.Event
|
|
6
|
+
coordination in favor of asyncio.Future-based prompts.
|
|
7
|
+
|
|
8
|
+
The workflow logic is delegated to WorkflowEngine, which handles all state
|
|
9
|
+
transitions. This module is responsible for:
|
|
10
|
+
- UI orchestration (displaying events, collecting input)
|
|
11
|
+
- Translating engine events to visual updates
|
|
12
|
+
- Collecting user input and passing actions to the engine
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
|
|
20
|
+
from galangal.config.loader import get_config
|
|
21
|
+
from galangal.config.schema import GalangalConfig
|
|
22
|
+
from galangal.core.artifacts import parse_stage_plan, write_artifact
|
|
23
|
+
from galangal.core.state import (
|
|
24
|
+
STAGE_ORDER,
|
|
25
|
+
TASK_TYPE_SKIP_STAGES,
|
|
26
|
+
Stage,
|
|
27
|
+
TaskType,
|
|
28
|
+
WorkflowState,
|
|
29
|
+
get_conditional_stages,
|
|
30
|
+
get_hidden_stages_for_task_type,
|
|
31
|
+
get_task_dir,
|
|
32
|
+
save_state,
|
|
33
|
+
)
|
|
34
|
+
from galangal.core.workflow.engine import (
|
|
35
|
+
ActionType,
|
|
36
|
+
EventType,
|
|
37
|
+
WorkflowEngine,
|
|
38
|
+
WorkflowEvent,
|
|
39
|
+
action,
|
|
40
|
+
)
|
|
41
|
+
from galangal.core.workflow.pause import _handle_pause
|
|
42
|
+
from galangal.prompts.builder import PromptBuilder
|
|
43
|
+
from galangal.ui.tui import PromptType, WorkflowTUIApp
|
|
44
|
+
from galangal.validation.runner import ValidationRunner
|
|
45
|
+
|
|
46
|
+
console = Console()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _run_workflow_with_tui(state: WorkflowState) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Execute the workflow loop with a persistent Textual TUI.
|
|
52
|
+
|
|
53
|
+
This is the main entry point for running workflows interactively. It creates
|
|
54
|
+
a WorkflowTUIApp and runs the stage pipeline using async/await for clean
|
|
55
|
+
coordination between UI and workflow logic.
|
|
56
|
+
|
|
57
|
+
The workflow logic is delegated to WorkflowEngine. This function handles:
|
|
58
|
+
- UI orchestration
|
|
59
|
+
- Translating engine events to visual updates
|
|
60
|
+
- Collecting user input for interactive prompts
|
|
61
|
+
|
|
62
|
+
Threading Model (Async):
|
|
63
|
+
- Main thread: Runs the Textual TUI event loop
|
|
64
|
+
- Async worker: Executes workflow logic using Textual's run_worker()
|
|
65
|
+
- Blocking operations (execute_stage) run in thread executor
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
state: Current workflow state containing task info, current stage,
|
|
69
|
+
attempt count, and failure information.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Result string indicating outcome:
|
|
73
|
+
- "done": Workflow completed successfully and user chose to exit
|
|
74
|
+
- "new_task": User chose to create a new task after completion
|
|
75
|
+
- "paused": Workflow was paused (Ctrl+C or user quit)
|
|
76
|
+
- "back_to_dev": User requested changes at completion, rolling back
|
|
77
|
+
- "error": An exception occurred during execution
|
|
78
|
+
"""
|
|
79
|
+
config = get_config()
|
|
80
|
+
|
|
81
|
+
# Compute hidden stages based on task type and config
|
|
82
|
+
hidden_stages = frozenset(get_hidden_stages_for_task_type(state.task_type, config.stages.skip))
|
|
83
|
+
|
|
84
|
+
app = WorkflowTUIApp(
|
|
85
|
+
state.task_name,
|
|
86
|
+
state.stage.value,
|
|
87
|
+
hidden_stages=hidden_stages,
|
|
88
|
+
stage_durations=state.stage_durations,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Create workflow engine
|
|
92
|
+
engine = WorkflowEngine(state, config)
|
|
93
|
+
|
|
94
|
+
async def workflow_loop() -> None:
|
|
95
|
+
"""Async workflow loop running within Textual's event loop."""
|
|
96
|
+
try:
|
|
97
|
+
while not engine.is_complete and not app._paused:
|
|
98
|
+
# Check GitHub issue status
|
|
99
|
+
github_event = await asyncio.to_thread(engine.check_github_issue)
|
|
100
|
+
if github_event:
|
|
101
|
+
app.show_message(github_event.message, "warning")
|
|
102
|
+
app.add_activity(
|
|
103
|
+
f"Issue #{state.github_issue} closed externally - pausing", "⚠"
|
|
104
|
+
)
|
|
105
|
+
app._workflow_result = "paused"
|
|
106
|
+
break
|
|
107
|
+
|
|
108
|
+
app.update_stage(engine.current_stage.value, state.attempt)
|
|
109
|
+
app.set_status("running", f"executing {engine.current_stage.value}")
|
|
110
|
+
|
|
111
|
+
# Start stage timer
|
|
112
|
+
engine.start_stage_timer()
|
|
113
|
+
|
|
114
|
+
# Run PM discovery Q&A before PM stage execution
|
|
115
|
+
if engine.current_stage == Stage.PM and not state.qa_complete:
|
|
116
|
+
skip_discovery = getattr(state, "_skip_discovery", False)
|
|
117
|
+
discovery_ok = await _run_pm_discovery(app, state, skip_discovery)
|
|
118
|
+
if not discovery_ok:
|
|
119
|
+
app._workflow_result = "paused"
|
|
120
|
+
break
|
|
121
|
+
app.set_status("running", f"executing {engine.current_stage.value}")
|
|
122
|
+
|
|
123
|
+
# Execute stage in thread executor
|
|
124
|
+
workflow_event = await asyncio.to_thread(
|
|
125
|
+
engine.execute_current_stage,
|
|
126
|
+
app,
|
|
127
|
+
lambda: app._paused,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Handle user interrupt requests (Ctrl+I, Ctrl+N, Ctrl+B, Ctrl+E)
|
|
131
|
+
interrupt_result = await _handle_user_interrupts(app, engine)
|
|
132
|
+
if interrupt_result == "continue":
|
|
133
|
+
continue
|
|
134
|
+
elif interrupt_result == "paused":
|
|
135
|
+
app._workflow_result = "paused"
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
if app._paused:
|
|
139
|
+
app._workflow_result = "paused"
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
# Handle the workflow event
|
|
143
|
+
result = await _handle_workflow_event(app, engine, workflow_event, config)
|
|
144
|
+
if result == "break":
|
|
145
|
+
break
|
|
146
|
+
elif result == "continue":
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
# Workflow complete
|
|
150
|
+
if engine.is_complete:
|
|
151
|
+
await _handle_workflow_complete(app, state)
|
|
152
|
+
|
|
153
|
+
except Exception as e:
|
|
154
|
+
from galangal.core.utils import debug_exception
|
|
155
|
+
|
|
156
|
+
debug_exception("Workflow execution failed", e)
|
|
157
|
+
app.show_error("Workflow error", str(e))
|
|
158
|
+
app._workflow_result = "error"
|
|
159
|
+
await app.ask_yes_no_async(
|
|
160
|
+
"An error occurred. Press Enter to exit and see details in the debug log."
|
|
161
|
+
)
|
|
162
|
+
app.set_timer(0.5, app.exit)
|
|
163
|
+
return
|
|
164
|
+
finally:
|
|
165
|
+
if app._workflow_result != "error":
|
|
166
|
+
app.set_timer(0.5, app.exit)
|
|
167
|
+
|
|
168
|
+
# Start workflow as async worker
|
|
169
|
+
app.call_later(lambda: app.run_worker(workflow_loop(), exclusive=True))
|
|
170
|
+
app.run()
|
|
171
|
+
|
|
172
|
+
# Handle result
|
|
173
|
+
result = app._workflow_result or "paused"
|
|
174
|
+
|
|
175
|
+
if result == "new_task":
|
|
176
|
+
return _start_new_task_tui()
|
|
177
|
+
elif result == "done":
|
|
178
|
+
console.print("\n[green]✓ All done![/green]")
|
|
179
|
+
return result
|
|
180
|
+
elif result == "back_to_dev":
|
|
181
|
+
return _run_workflow_with_tui(state)
|
|
182
|
+
elif result == "paused":
|
|
183
|
+
_handle_pause(state)
|
|
184
|
+
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# =============================================================================
|
|
189
|
+
# Event Handlers - translate engine events to UI updates
|
|
190
|
+
# =============================================================================
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def _handle_workflow_event(
|
|
194
|
+
app: WorkflowTUIApp,
|
|
195
|
+
engine: WorkflowEngine,
|
|
196
|
+
event: WorkflowEvent,
|
|
197
|
+
config: GalangalConfig,
|
|
198
|
+
) -> str:
|
|
199
|
+
"""
|
|
200
|
+
Handle a workflow event from the engine.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
"continue" to continue the loop
|
|
204
|
+
"break" to exit the loop
|
|
205
|
+
"advance" to advance to next stage (handled by caller)
|
|
206
|
+
"""
|
|
207
|
+
state = engine.state
|
|
208
|
+
|
|
209
|
+
if event.type == EventType.WORKFLOW_PAUSED:
|
|
210
|
+
app._workflow_result = "paused"
|
|
211
|
+
return "break"
|
|
212
|
+
|
|
213
|
+
if event.type == EventType.STAGE_COMPLETED:
|
|
214
|
+
app.clear_error()
|
|
215
|
+
duration = state.record_stage_duration()
|
|
216
|
+
app.show_stage_complete(state.stage.value, True, duration)
|
|
217
|
+
if state.stage_durations:
|
|
218
|
+
app.update_stage_durations(state.stage_durations)
|
|
219
|
+
|
|
220
|
+
# Advance to next stage via engine
|
|
221
|
+
advance_event = engine.handle_action(action(ActionType.CONTINUE), tui_app=app)
|
|
222
|
+
return await _handle_advance_event(app, engine, advance_event, config)
|
|
223
|
+
|
|
224
|
+
if event.type == EventType.APPROVAL_REQUIRED:
|
|
225
|
+
should_continue = await _handle_stage_approval(
|
|
226
|
+
app, state, config, event.data.get("artifact_name", "APPROVAL.md")
|
|
227
|
+
)
|
|
228
|
+
if not should_continue:
|
|
229
|
+
if app._workflow_result == "paused":
|
|
230
|
+
return "break"
|
|
231
|
+
return "continue" # Rejected - loop back to stage
|
|
232
|
+
|
|
233
|
+
# After approval, advance
|
|
234
|
+
advance_event = engine.handle_action(action(ActionType.CONTINUE), tui_app=app)
|
|
235
|
+
return await _handle_advance_event(app, engine, advance_event, config)
|
|
236
|
+
|
|
237
|
+
if event.type == EventType.PREFLIGHT_FAILED:
|
|
238
|
+
app.show_stage_complete(state.stage.value, False)
|
|
239
|
+
modal_message = _build_preflight_error_message(event.message, event.data.get("details", ""))
|
|
240
|
+
choice = await app.prompt_async(PromptType.PREFLIGHT_RETRY, modal_message)
|
|
241
|
+
|
|
242
|
+
if choice == "retry":
|
|
243
|
+
app.show_message("Retrying preflight checks...", "info")
|
|
244
|
+
return "continue"
|
|
245
|
+
else:
|
|
246
|
+
save_state(state)
|
|
247
|
+
app._workflow_result = "paused"
|
|
248
|
+
return "break"
|
|
249
|
+
|
|
250
|
+
if event.type == EventType.CLARIFICATION_REQUIRED:
|
|
251
|
+
app.show_stage_complete(state.stage.value, False)
|
|
252
|
+
questions = event.data.get("questions", [])
|
|
253
|
+
if questions:
|
|
254
|
+
app.show_message(f"Stage has {len(questions)} clarifying question(s)", "warning")
|
|
255
|
+
answers = await app.question_answer_session_async(questions)
|
|
256
|
+
if answers:
|
|
257
|
+
engine.handle_clarification_answers(questions, answers)
|
|
258
|
+
app.show_message("Answers saved - resuming stage", "success")
|
|
259
|
+
return "continue"
|
|
260
|
+
else:
|
|
261
|
+
app.show_message("Answers cancelled - pausing workflow", "warning")
|
|
262
|
+
save_state(state)
|
|
263
|
+
app._workflow_result = "paused"
|
|
264
|
+
return "break"
|
|
265
|
+
else:
|
|
266
|
+
app.show_message("QUESTIONS.md exists but couldn't parse questions", "error")
|
|
267
|
+
save_state(state)
|
|
268
|
+
app._workflow_result = "paused"
|
|
269
|
+
return "break"
|
|
270
|
+
|
|
271
|
+
if event.type == EventType.USER_DECISION_REQUIRED:
|
|
272
|
+
app.show_stage_complete(state.stage.value, False)
|
|
273
|
+
artifact_preview = event.data.get("artifact_preview", "")
|
|
274
|
+
full_content = event.data.get("full_content", "")
|
|
275
|
+
|
|
276
|
+
while True:
|
|
277
|
+
choice = await app.prompt_async(
|
|
278
|
+
PromptType.USER_DECISION,
|
|
279
|
+
f"Decision file missing for {state.stage.value} stage.\n\n"
|
|
280
|
+
f"Report preview:\n{artifact_preview}\n\n"
|
|
281
|
+
"Please review and decide:",
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if choice == "view":
|
|
285
|
+
app.add_activity("--- Full Report ---", "📄")
|
|
286
|
+
for line in (full_content or "No content").split("\n")[:50]:
|
|
287
|
+
app.add_activity(line, "")
|
|
288
|
+
app.add_activity("--- End Report ---", "📄")
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
result_event = engine.handle_user_decision(choice, tui_app=app)
|
|
292
|
+
|
|
293
|
+
if result_event.type == EventType.WORKFLOW_PAUSED:
|
|
294
|
+
app._workflow_result = "paused"
|
|
295
|
+
return "break"
|
|
296
|
+
|
|
297
|
+
if result_event.type == EventType.WORKFLOW_COMPLETE:
|
|
298
|
+
app.show_workflow_complete()
|
|
299
|
+
app._workflow_result = "complete"
|
|
300
|
+
return "break"
|
|
301
|
+
|
|
302
|
+
if result_event.type == EventType.ROLLBACK_TRIGGERED:
|
|
303
|
+
app.show_message("Rolling back to DEV per user decision", "warning")
|
|
304
|
+
app.update_stage(state.stage.value, state.attempt)
|
|
305
|
+
return "continue"
|
|
306
|
+
|
|
307
|
+
if result_event.type == EventType.STAGE_STARTED:
|
|
308
|
+
# Advanced to next stage
|
|
309
|
+
app.update_stage(state.stage.value, state.attempt)
|
|
310
|
+
return "continue"
|
|
311
|
+
|
|
312
|
+
return "continue"
|
|
313
|
+
|
|
314
|
+
if event.type == EventType.ROLLBACK_TRIGGERED:
|
|
315
|
+
target = event.data.get("to_stage")
|
|
316
|
+
app.add_activity(f"Rolling back to {target.value if target else 'unknown'}", "⚠")
|
|
317
|
+
app.show_message(f"Rolling back: {event.message[:60]}", "warning")
|
|
318
|
+
app.update_stage(state.stage.value, state.attempt)
|
|
319
|
+
return "continue"
|
|
320
|
+
|
|
321
|
+
if event.type == EventType.ROLLBACK_BLOCKED:
|
|
322
|
+
app.show_stage_complete(state.stage.value, False)
|
|
323
|
+
block_reason = event.data.get("block_reason", "")
|
|
324
|
+
target = event.data.get("target_stage", "unknown")
|
|
325
|
+
|
|
326
|
+
app.add_activity(f"Rollback blocked: {block_reason}", "⚠")
|
|
327
|
+
app.show_error(f"Rollback blocked: {block_reason}", event.message[:500])
|
|
328
|
+
|
|
329
|
+
choice = await app.prompt_async(
|
|
330
|
+
PromptType.STAGE_FAILURE,
|
|
331
|
+
f"Rollback to {target} was blocked.\n\n"
|
|
332
|
+
f"Reason: {block_reason}\n\n"
|
|
333
|
+
f"Error: {event.message[:300]}\n\n"
|
|
334
|
+
"What would you like to do?",
|
|
335
|
+
)
|
|
336
|
+
app.clear_error()
|
|
337
|
+
|
|
338
|
+
if choice == "retry":
|
|
339
|
+
state.reset_attempts()
|
|
340
|
+
app.show_message("Retrying stage...", "info")
|
|
341
|
+
save_state(state)
|
|
342
|
+
return "continue"
|
|
343
|
+
elif choice == "fix_in_dev":
|
|
344
|
+
result_event = engine.handle_action(
|
|
345
|
+
action(ActionType.FIX_IN_DEV, error=event.message),
|
|
346
|
+
tui_app=app,
|
|
347
|
+
)
|
|
348
|
+
app.show_message("Rolling back to DEV (manual override)", "warning")
|
|
349
|
+
app.update_stage(state.stage.value, state.attempt)
|
|
350
|
+
return "continue"
|
|
351
|
+
else:
|
|
352
|
+
save_state(state)
|
|
353
|
+
app._workflow_result = "paused"
|
|
354
|
+
return "break"
|
|
355
|
+
|
|
356
|
+
if event.type == EventType.MAX_RETRIES_EXCEEDED:
|
|
357
|
+
app.show_stage_complete(state.stage.value, False)
|
|
358
|
+
max_retries = event.data.get("max_retries", config.stages.max_retries)
|
|
359
|
+
choice = await _handle_max_retries_exceeded(app, state, event.message, max_retries)
|
|
360
|
+
|
|
361
|
+
if choice == "retry":
|
|
362
|
+
state.reset_attempts()
|
|
363
|
+
app.show_message("Retrying stage...", "info")
|
|
364
|
+
save_state(state)
|
|
365
|
+
return "continue"
|
|
366
|
+
elif choice == "fix_in_dev":
|
|
367
|
+
# Already handled in _handle_max_retries_exceeded
|
|
368
|
+
return "continue"
|
|
369
|
+
else:
|
|
370
|
+
save_state(state)
|
|
371
|
+
app._workflow_result = "paused"
|
|
372
|
+
return "break"
|
|
373
|
+
|
|
374
|
+
if event.type == EventType.STAGE_FAILED:
|
|
375
|
+
app.show_stage_complete(state.stage.value, False)
|
|
376
|
+
app.show_message(
|
|
377
|
+
f"Retrying (attempt {state.attempt}/{engine.max_retries})...",
|
|
378
|
+
"warning",
|
|
379
|
+
)
|
|
380
|
+
save_state(state)
|
|
381
|
+
return "continue"
|
|
382
|
+
|
|
383
|
+
if event.type == EventType.WORKFLOW_COMPLETE:
|
|
384
|
+
return "break"
|
|
385
|
+
|
|
386
|
+
# Unknown event - continue
|
|
387
|
+
app.add_activity(f"Unknown event: {event.type.name}", "⚙")
|
|
388
|
+
return "continue"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
async def _handle_advance_event(
|
|
392
|
+
app: WorkflowTUIApp,
|
|
393
|
+
engine: WorkflowEngine,
|
|
394
|
+
event: WorkflowEvent,
|
|
395
|
+
config: GalangalConfig,
|
|
396
|
+
) -> str:
|
|
397
|
+
"""Handle the event from advancing to next stage."""
|
|
398
|
+
state = engine.state
|
|
399
|
+
|
|
400
|
+
if event.type == EventType.WORKFLOW_COMPLETE:
|
|
401
|
+
return "break" # Will be handled in main loop
|
|
402
|
+
|
|
403
|
+
if event.type == EventType.STAGE_STARTED:
|
|
404
|
+
# Show skipped stages if any
|
|
405
|
+
skipped = event.data.get("skipped_stages", [])
|
|
406
|
+
for s in skipped:
|
|
407
|
+
app.show_message(f"Skipped {s.value} (condition not met)", "info")
|
|
408
|
+
|
|
409
|
+
app.update_stage(state.stage.value, state.attempt)
|
|
410
|
+
|
|
411
|
+
# After PM approval, show stage preview
|
|
412
|
+
if event.data.get("show_preview"):
|
|
413
|
+
preview_result = await _show_stage_preview(app, state, config)
|
|
414
|
+
if preview_result == "quit":
|
|
415
|
+
app._workflow_result = "paused"
|
|
416
|
+
return "break"
|
|
417
|
+
|
|
418
|
+
return "continue"
|
|
419
|
+
|
|
420
|
+
return "continue"
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
async def _handle_user_interrupts(app: WorkflowTUIApp, engine: WorkflowEngine) -> str:
|
|
424
|
+
"""
|
|
425
|
+
Handle user interrupt requests (Ctrl+I, Ctrl+N, Ctrl+B, Ctrl+E).
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
"continue" if an interrupt was handled and loop should continue
|
|
429
|
+
"paused" if workflow should pause
|
|
430
|
+
"none" if no interrupt was requested
|
|
431
|
+
"""
|
|
432
|
+
state = engine.state
|
|
433
|
+
|
|
434
|
+
# Handle interrupt with feedback (Ctrl+I)
|
|
435
|
+
if app._interrupt_requested:
|
|
436
|
+
app.add_activity("Interrupted by user", "⏸️")
|
|
437
|
+
|
|
438
|
+
# Get feedback
|
|
439
|
+
feedback = await app.multiline_input_async(
|
|
440
|
+
"What needs to be fixed? (Ctrl+S to submit):", ""
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
# Get rollback target
|
|
444
|
+
valid_targets = engine.get_valid_interrupt_targets()
|
|
445
|
+
default_target = engine.get_default_interrupt_target()
|
|
446
|
+
|
|
447
|
+
if len(valid_targets) > 1:
|
|
448
|
+
options_text = "\n".join(
|
|
449
|
+
f" [{i + 1}] {s.value}" + (" (recommended)" if s == default_target else "")
|
|
450
|
+
for i, s in enumerate(valid_targets)
|
|
451
|
+
)
|
|
452
|
+
target_input = await app.text_input_async(
|
|
453
|
+
f"Roll back to which stage?\n\n{options_text}\n\nEnter number:", "1"
|
|
454
|
+
)
|
|
455
|
+
try:
|
|
456
|
+
target_idx = int(target_input or "1") - 1
|
|
457
|
+
target_stage = (
|
|
458
|
+
valid_targets[target_idx]
|
|
459
|
+
if 0 <= target_idx < len(valid_targets)
|
|
460
|
+
else default_target
|
|
461
|
+
)
|
|
462
|
+
except (ValueError, TypeError):
|
|
463
|
+
target_stage = default_target
|
|
464
|
+
else:
|
|
465
|
+
target_stage = valid_targets[0] if valid_targets else state.stage
|
|
466
|
+
|
|
467
|
+
# Send action to engine
|
|
468
|
+
result_event = engine.handle_action(
|
|
469
|
+
action(ActionType.INTERRUPT, feedback=feedback or "", target_stage=target_stage)
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
app._interrupt_requested = False
|
|
473
|
+
app._paused = False
|
|
474
|
+
app.show_message(
|
|
475
|
+
f"Interrupted - rolling back to {target_stage.value}",
|
|
476
|
+
"warning",
|
|
477
|
+
)
|
|
478
|
+
app.update_stage(state.stage.value, state.attempt)
|
|
479
|
+
return "continue"
|
|
480
|
+
|
|
481
|
+
# Handle skip stage (Ctrl+N)
|
|
482
|
+
if app._skip_stage_requested:
|
|
483
|
+
app.add_activity(f"Skipping {state.stage.value} stage", "⏭️")
|
|
484
|
+
skipped_stage = state.stage
|
|
485
|
+
|
|
486
|
+
result_event = engine.handle_action(action(ActionType.SKIP))
|
|
487
|
+
|
|
488
|
+
if result_event.type == EventType.WORKFLOW_COMPLETE:
|
|
489
|
+
app.show_message("Skipped to COMPLETE", "info")
|
|
490
|
+
else:
|
|
491
|
+
app.show_message(f"Skipped {skipped_stage.value} → {state.stage.value}", "info")
|
|
492
|
+
app.update_stage(state.stage.value, state.attempt)
|
|
493
|
+
|
|
494
|
+
app._skip_stage_requested = False
|
|
495
|
+
app._paused = False
|
|
496
|
+
return "continue"
|
|
497
|
+
|
|
498
|
+
# Handle back stage (Ctrl+B)
|
|
499
|
+
if app._back_stage_requested:
|
|
500
|
+
current_idx = STAGE_ORDER.index(state.stage)
|
|
501
|
+
if current_idx > 0:
|
|
502
|
+
result_event = engine.handle_action(action(ActionType.BACK))
|
|
503
|
+
app.add_activity(f"Going back to {state.stage.value}", "⏮️")
|
|
504
|
+
app.show_message(f"Back to {state.stage.value}", "info")
|
|
505
|
+
app.update_stage(state.stage.value, state.attempt)
|
|
506
|
+
else:
|
|
507
|
+
app.show_message("Already at first stage", "warning")
|
|
508
|
+
|
|
509
|
+
app._back_stage_requested = False
|
|
510
|
+
app._paused = False
|
|
511
|
+
return "continue"
|
|
512
|
+
|
|
513
|
+
# Handle manual edit pause (Ctrl+E)
|
|
514
|
+
if app._manual_edit_requested:
|
|
515
|
+
app.add_activity("Paused for manual editing", "✏️")
|
|
516
|
+
app.show_message("Workflow paused - make your edits, then press Enter to continue", "info")
|
|
517
|
+
|
|
518
|
+
await app.text_input_async("Press Enter when ready to continue...", "")
|
|
519
|
+
|
|
520
|
+
app.add_activity("Resuming workflow", "▶️")
|
|
521
|
+
app.show_message("Resuming...", "info")
|
|
522
|
+
|
|
523
|
+
app._manual_edit_requested = False
|
|
524
|
+
app._paused = False
|
|
525
|
+
return "continue"
|
|
526
|
+
|
|
527
|
+
return "none"
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# =============================================================================
|
|
531
|
+
# PM Discovery Q&A functions
|
|
532
|
+
# =============================================================================
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
async def _run_pm_discovery(
|
|
536
|
+
app: WorkflowTUIApp,
|
|
537
|
+
state: WorkflowState,
|
|
538
|
+
skip_discovery: bool = False,
|
|
539
|
+
) -> bool:
|
|
540
|
+
"""
|
|
541
|
+
Run the PM discovery Q&A loop to refine the brief.
|
|
542
|
+
|
|
543
|
+
This function handles the interactive Q&A process before PM stage execution:
|
|
544
|
+
1. Generate clarifying questions from the AI
|
|
545
|
+
2. Present questions to user via TUI
|
|
546
|
+
3. Collect answers
|
|
547
|
+
4. Loop until user is satisfied
|
|
548
|
+
5. Write DISCOVERY_LOG.md artifact
|
|
549
|
+
|
|
550
|
+
Args:
|
|
551
|
+
app: TUI application for user interaction.
|
|
552
|
+
state: Current workflow state to update with Q&A progress.
|
|
553
|
+
skip_discovery: If True, skip the Q&A loop entirely.
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
True if discovery completed (or was skipped), False if user cancelled/quit.
|
|
557
|
+
"""
|
|
558
|
+
# Check if discovery should be skipped
|
|
559
|
+
if skip_discovery or state.qa_complete:
|
|
560
|
+
if state.qa_complete:
|
|
561
|
+
app.show_message("Discovery Q&A already completed", "info")
|
|
562
|
+
return True
|
|
563
|
+
|
|
564
|
+
# Check if task type should skip discovery
|
|
565
|
+
config = get_config()
|
|
566
|
+
task_type_settings = config.task_type_settings.get(state.task_type.value)
|
|
567
|
+
if task_type_settings and task_type_settings.skip_discovery:
|
|
568
|
+
app.show_message(f"Discovery skipped for {state.task_type.display_name()} tasks", "info")
|
|
569
|
+
state.qa_complete = True
|
|
570
|
+
save_state(state)
|
|
571
|
+
return True
|
|
572
|
+
|
|
573
|
+
app.show_message("Starting brief discovery Q&A...", "info")
|
|
574
|
+
app.set_status("discovery", "refining brief")
|
|
575
|
+
|
|
576
|
+
qa_rounds: list[dict[str, Any]] = state.qa_rounds or []
|
|
577
|
+
builder = PromptBuilder()
|
|
578
|
+
|
|
579
|
+
while True:
|
|
580
|
+
# Generate questions
|
|
581
|
+
app.add_activity("Analyzing brief for clarifying questions...", "🔍")
|
|
582
|
+
questions = await _generate_discovery_questions(app, state, builder, qa_rounds)
|
|
583
|
+
|
|
584
|
+
if questions is None:
|
|
585
|
+
# AI invocation failed
|
|
586
|
+
app.show_message("Failed to generate questions", "error")
|
|
587
|
+
return False
|
|
588
|
+
|
|
589
|
+
if not questions:
|
|
590
|
+
# AI found no gaps in the brief - continue automatically
|
|
591
|
+
app.show_message("No clarifying questions needed", "success")
|
|
592
|
+
break
|
|
593
|
+
|
|
594
|
+
# Present questions and collect answers
|
|
595
|
+
app.add_activity(f"Asking {len(questions)} clarifying questions...", "❓")
|
|
596
|
+
answers = await app.question_answer_session_async(questions)
|
|
597
|
+
|
|
598
|
+
if answers is None:
|
|
599
|
+
# User cancelled
|
|
600
|
+
app.show_message("Discovery cancelled", "warning")
|
|
601
|
+
return False
|
|
602
|
+
|
|
603
|
+
# Store round
|
|
604
|
+
qa_rounds.append({"questions": questions, "answers": answers})
|
|
605
|
+
state.qa_rounds = qa_rounds
|
|
606
|
+
save_state(state)
|
|
607
|
+
|
|
608
|
+
# Update discovery log
|
|
609
|
+
_write_discovery_log(state.task_name, qa_rounds)
|
|
610
|
+
|
|
611
|
+
app.show_message(
|
|
612
|
+
f"Round {len(qa_rounds)} complete - {len(questions)} Q&As recorded", "success"
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
# Ask if user wants more questions
|
|
616
|
+
more_questions = await app.ask_yes_no_async("Got more questions?")
|
|
617
|
+
if not more_questions:
|
|
618
|
+
break
|
|
619
|
+
|
|
620
|
+
# Mark discovery complete
|
|
621
|
+
state.qa_complete = True
|
|
622
|
+
save_state(state)
|
|
623
|
+
|
|
624
|
+
if qa_rounds:
|
|
625
|
+
app.show_message(f"Discovery complete - {len(qa_rounds)} rounds of Q&A", "success")
|
|
626
|
+
else:
|
|
627
|
+
app.show_message("Discovery complete - no questions needed", "info")
|
|
628
|
+
|
|
629
|
+
return True
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
async def _generate_discovery_questions(
|
|
633
|
+
app: WorkflowTUIApp,
|
|
634
|
+
state: WorkflowState,
|
|
635
|
+
builder: PromptBuilder,
|
|
636
|
+
qa_history: list[dict[str, Any]],
|
|
637
|
+
) -> list[str] | None:
|
|
638
|
+
"""
|
|
639
|
+
Generate discovery questions by invoking the AI.
|
|
640
|
+
|
|
641
|
+
Returns:
|
|
642
|
+
List of questions, empty list if AI found no gaps, or None if failed.
|
|
643
|
+
"""
|
|
644
|
+
from galangal.ai import get_backend_with_fallback
|
|
645
|
+
from galangal.config.loader import get_config
|
|
646
|
+
from galangal.ui.tui import TUIAdapter
|
|
647
|
+
|
|
648
|
+
prompt = builder.build_discovery_prompt(state, qa_history)
|
|
649
|
+
config = get_config()
|
|
650
|
+
|
|
651
|
+
# Log the prompt
|
|
652
|
+
logs_dir = get_task_dir(state.task_name) / "logs"
|
|
653
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
654
|
+
round_num = len(qa_history) + 1
|
|
655
|
+
log_file = logs_dir / f"discovery_{round_num}.log"
|
|
656
|
+
with open(log_file, "w") as f:
|
|
657
|
+
f.write(f"=== Discovery Prompt (Round {round_num}) ===\n{prompt}\n\n")
|
|
658
|
+
|
|
659
|
+
# Run AI with fallback support
|
|
660
|
+
backend = get_backend_with_fallback(config.ai.default, config=config)
|
|
661
|
+
ui = TUIAdapter(app)
|
|
662
|
+
result = await asyncio.to_thread(
|
|
663
|
+
backend.invoke,
|
|
664
|
+
prompt=prompt,
|
|
665
|
+
timeout=300, # 5 minutes for question generation
|
|
666
|
+
max_turns=10,
|
|
667
|
+
ui=ui,
|
|
668
|
+
pause_check=lambda: app._paused,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
# Log output
|
|
672
|
+
with open(log_file, "a") as f:
|
|
673
|
+
f.write(f"=== Output ===\n{result.output or result.message}\n")
|
|
674
|
+
|
|
675
|
+
if not result.success:
|
|
676
|
+
return None
|
|
677
|
+
|
|
678
|
+
# Parse questions from output
|
|
679
|
+
return _parse_discovery_questions(result.output or "")
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _parse_discovery_questions(output: str) -> list[str]:
|
|
683
|
+
"""Parse questions from AI output."""
|
|
684
|
+
# Check for NO_QUESTIONS marker
|
|
685
|
+
if "# NO_QUESTIONS" in output or "#NO_QUESTIONS" in output:
|
|
686
|
+
return []
|
|
687
|
+
|
|
688
|
+
questions = []
|
|
689
|
+
lines = output.split("\n")
|
|
690
|
+
in_questions = False
|
|
691
|
+
|
|
692
|
+
for line in lines:
|
|
693
|
+
line = line.strip()
|
|
694
|
+
|
|
695
|
+
# Start capturing after DISCOVERY_QUESTIONS header
|
|
696
|
+
if "DISCOVERY_QUESTIONS" in line:
|
|
697
|
+
in_questions = True
|
|
698
|
+
continue
|
|
699
|
+
|
|
700
|
+
if in_questions and line:
|
|
701
|
+
# Match numbered questions (1. Question text)
|
|
702
|
+
import re
|
|
703
|
+
|
|
704
|
+
match = re.match(r"^\d+[\.\)]\s*(.+)$", line)
|
|
705
|
+
if match:
|
|
706
|
+
questions.append(match.group(1))
|
|
707
|
+
elif line.startswith("-"):
|
|
708
|
+
# Also accept bullet points
|
|
709
|
+
questions.append(line[1:].strip())
|
|
710
|
+
|
|
711
|
+
return questions
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _write_discovery_log(task_name: str, qa_rounds: list[dict[str, Any]]) -> None:
|
|
715
|
+
"""Write or update DISCOVERY_LOG.md artifact."""
|
|
716
|
+
content_parts = ["# Discovery Log\n"]
|
|
717
|
+
content_parts.append("This log captures the Q&A from brief refinement.\n")
|
|
718
|
+
|
|
719
|
+
for i, round_data in enumerate(qa_rounds, 1):
|
|
720
|
+
content_parts.append(f"\n## Round {i}\n")
|
|
721
|
+
content_parts.append("\n### Questions\n")
|
|
722
|
+
for j, q in enumerate(round_data.get("questions", []), 1):
|
|
723
|
+
content_parts.append(f"{j}. {q}\n")
|
|
724
|
+
content_parts.append("\n### Answers\n")
|
|
725
|
+
for j, a in enumerate(round_data.get("answers", []), 1):
|
|
726
|
+
content_parts.append(f"{j}. {a}\n")
|
|
727
|
+
|
|
728
|
+
write_artifact("DISCOVERY_LOG.md", "".join(content_parts), task_name)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
# =============================================================================
|
|
732
|
+
# Helper functions for workflow logic
|
|
733
|
+
# =============================================================================
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _build_preflight_error_message(message: str, details: str) -> str:
|
|
737
|
+
"""Build error message for preflight failure modal."""
|
|
738
|
+
failed_lines = []
|
|
739
|
+
for line in details.split("\n"):
|
|
740
|
+
if line.strip().startswith("✗") or "Failed" in line or "Missing" in line or "Error" in line:
|
|
741
|
+
failed_lines.append(line.strip())
|
|
742
|
+
|
|
743
|
+
modal_message = "Preflight checks failed:\n\n"
|
|
744
|
+
if failed_lines:
|
|
745
|
+
modal_message += "\n".join(failed_lines[:10])
|
|
746
|
+
else:
|
|
747
|
+
modal_message += details[:500]
|
|
748
|
+
modal_message += "\n\nFix issues and retry?"
|
|
749
|
+
|
|
750
|
+
return modal_message
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _get_skip_reasons(
|
|
754
|
+
state: WorkflowState,
|
|
755
|
+
config: GalangalConfig,
|
|
756
|
+
) -> dict[str, str]:
|
|
757
|
+
"""
|
|
758
|
+
Get a mapping of stage names to their skip reasons.
|
|
759
|
+
|
|
760
|
+
Checks multiple skip sources in order of precedence:
|
|
761
|
+
1. Task type skips (from TASK_TYPE_SKIP_STAGES)
|
|
762
|
+
2. Config skips (from config.stages.skip)
|
|
763
|
+
3. PM stage plan skips (from STAGE_PLAN.md)
|
|
764
|
+
4. skip_if conditions (glob patterns for conditional stages)
|
|
765
|
+
|
|
766
|
+
Args:
|
|
767
|
+
state: Current workflow state with task_type and stage_plan.
|
|
768
|
+
config: Configuration with stages.skip list.
|
|
769
|
+
|
|
770
|
+
Returns:
|
|
771
|
+
Dict mapping stage name -> reason string (e.g., "task type: bug_fix")
|
|
772
|
+
"""
|
|
773
|
+
skip_reasons: dict[str, str] = {}
|
|
774
|
+
task_type = state.task_type
|
|
775
|
+
|
|
776
|
+
# 1. Task type skips
|
|
777
|
+
for stage in TASK_TYPE_SKIP_STAGES.get(task_type, set()):
|
|
778
|
+
skip_reasons[stage.value] = f"task type: {task_type.value}"
|
|
779
|
+
|
|
780
|
+
# 2. Config skips
|
|
781
|
+
if config.stages.skip:
|
|
782
|
+
for stage_name in config.stages.skip:
|
|
783
|
+
stage_upper = stage_name.upper()
|
|
784
|
+
if stage_upper not in skip_reasons:
|
|
785
|
+
skip_reasons[stage_upper] = "config: stages.skip"
|
|
786
|
+
|
|
787
|
+
# 3. PM stage plan skips
|
|
788
|
+
if state.stage_plan:
|
|
789
|
+
for stage_name, plan_entry in state.stage_plan.items():
|
|
790
|
+
if plan_entry.get("action") == "skip":
|
|
791
|
+
reason = plan_entry.get("reason", "PM recommendation")
|
|
792
|
+
if stage_name not in skip_reasons:
|
|
793
|
+
skip_reasons[stage_name] = f"PM: {reason}"
|
|
794
|
+
|
|
795
|
+
# 4. skip_if conditions for conditional stages
|
|
796
|
+
# Only check stages not already skipped by other means
|
|
797
|
+
conditional_stages = get_conditional_stages()
|
|
798
|
+
runner = ValidationRunner()
|
|
799
|
+
|
|
800
|
+
for stage in conditional_stages:
|
|
801
|
+
if stage.value not in skip_reasons:
|
|
802
|
+
if runner.should_skip_stage(stage.value, state.task_name):
|
|
803
|
+
# Get the skip_if pattern for context
|
|
804
|
+
stage_config = getattr(config.validation, stage.value.lower(), None)
|
|
805
|
+
if stage_config and stage_config.skip_if and stage_config.skip_if.no_files_match:
|
|
806
|
+
patterns = stage_config.skip_if.no_files_match
|
|
807
|
+
if isinstance(patterns, str):
|
|
808
|
+
patterns = [patterns]
|
|
809
|
+
pattern_str = ", ".join(patterns[:2])
|
|
810
|
+
if len(patterns) > 2:
|
|
811
|
+
pattern_str += "..."
|
|
812
|
+
skip_reasons[stage.value] = f"no files match: {pattern_str}"
|
|
813
|
+
else:
|
|
814
|
+
skip_reasons[stage.value] = "skip_if condition"
|
|
815
|
+
|
|
816
|
+
return skip_reasons
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
async def _show_stage_preview(
|
|
820
|
+
app: WorkflowTUIApp,
|
|
821
|
+
state: WorkflowState,
|
|
822
|
+
config: GalangalConfig,
|
|
823
|
+
) -> str:
|
|
824
|
+
"""
|
|
825
|
+
Show a preview of stages to run before continuing.
|
|
826
|
+
|
|
827
|
+
Displays which stages will run and which will be skipped, with
|
|
828
|
+
annotated reasons for each skip (task type, config, PM plan, skip_if).
|
|
829
|
+
|
|
830
|
+
Returns "continue" or "quit".
|
|
831
|
+
"""
|
|
832
|
+
# Get all skip reasons (includes skip_if conditions)
|
|
833
|
+
skip_reasons = _get_skip_reasons(state, config)
|
|
834
|
+
|
|
835
|
+
# Update hidden stages to include skip_if-based skips
|
|
836
|
+
# This ensures the progress bar reflects the preview
|
|
837
|
+
current_hidden = set(app._hidden_stages)
|
|
838
|
+
new_hidden = current_hidden | set(skip_reasons.keys())
|
|
839
|
+
if new_hidden != current_hidden:
|
|
840
|
+
app.update_hidden_stages(frozenset(new_hidden))
|
|
841
|
+
|
|
842
|
+
# Calculate stages to run vs skip
|
|
843
|
+
all_stages = [s for s in STAGE_ORDER if s != Stage.COMPLETE]
|
|
844
|
+
stages_to_run = [s for s in all_stages if s.value not in new_hidden]
|
|
845
|
+
stages_skipped = [s for s in all_stages if s.value in new_hidden]
|
|
846
|
+
|
|
847
|
+
# Build preview message
|
|
848
|
+
run_str = " → ".join(s.value for s in stages_to_run)
|
|
849
|
+
|
|
850
|
+
# Build annotated skip list
|
|
851
|
+
if stages_skipped:
|
|
852
|
+
skip_lines = []
|
|
853
|
+
for stage in stages_skipped:
|
|
854
|
+
reason = skip_reasons.get(stage.value, "")
|
|
855
|
+
if reason:
|
|
856
|
+
skip_lines.append(f" {stage.value} ({reason})")
|
|
857
|
+
else:
|
|
858
|
+
skip_lines.append(f" {stage.value}")
|
|
859
|
+
skip_str = "\n".join(skip_lines)
|
|
860
|
+
else:
|
|
861
|
+
skip_str = " None"
|
|
862
|
+
|
|
863
|
+
# Build a nice preview
|
|
864
|
+
preview = f"""Workflow Preview
|
|
865
|
+
|
|
866
|
+
Stages to run:
|
|
867
|
+
{run_str}
|
|
868
|
+
|
|
869
|
+
Skipping:
|
|
870
|
+
{skip_str}
|
|
871
|
+
|
|
872
|
+
Controls during execution:
|
|
873
|
+
^N Skip stage ^B Back ^E Pause for edit ^I Interrupt"""
|
|
874
|
+
|
|
875
|
+
return await app.prompt_async(PromptType.STAGE_PREVIEW, preview)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
async def _handle_max_retries_exceeded(
|
|
879
|
+
app: WorkflowTUIApp,
|
|
880
|
+
state: WorkflowState,
|
|
881
|
+
error_message: str,
|
|
882
|
+
max_retries: int,
|
|
883
|
+
) -> str:
|
|
884
|
+
"""Handle stage failure after max retries exceeded."""
|
|
885
|
+
error_preview = error_message[:800].strip()
|
|
886
|
+
if len(error_message) > 800:
|
|
887
|
+
error_preview += "..."
|
|
888
|
+
|
|
889
|
+
# Show error prominently in error panel
|
|
890
|
+
app.show_error(
|
|
891
|
+
f"Stage {state.stage.value} failed after {max_retries} attempts",
|
|
892
|
+
error_preview,
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
modal_message = (
|
|
896
|
+
f"Stage {state.stage.value} failed after {max_retries} attempts.\n\n"
|
|
897
|
+
f"Error:\n{error_preview}\n\n"
|
|
898
|
+
"What would you like to do?"
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
choice = await app.prompt_async(PromptType.STAGE_FAILURE, modal_message)
|
|
902
|
+
|
|
903
|
+
# Clear error panel when user makes a choice
|
|
904
|
+
app.clear_error()
|
|
905
|
+
|
|
906
|
+
if choice == "fix_in_dev":
|
|
907
|
+
feedback = await app.multiline_input_async(
|
|
908
|
+
"Describe what needs to be fixed (Ctrl+S to submit):", ""
|
|
909
|
+
)
|
|
910
|
+
feedback = feedback or "Fix the failing stage"
|
|
911
|
+
|
|
912
|
+
failing_stage = state.stage.value
|
|
913
|
+
state.stage = Stage.DEV
|
|
914
|
+
state.last_failure = (
|
|
915
|
+
f"Feedback from {failing_stage} failure: {feedback}\n\n"
|
|
916
|
+
f"Original error:\n{error_message[:1500]}"
|
|
917
|
+
)
|
|
918
|
+
state.reset_attempts(clear_failure=False)
|
|
919
|
+
app.show_message("Rolling back to DEV with feedback", "warning")
|
|
920
|
+
save_state(state)
|
|
921
|
+
|
|
922
|
+
return choice
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
async def _handle_stage_approval(
|
|
926
|
+
app: WorkflowTUIApp,
|
|
927
|
+
state: WorkflowState,
|
|
928
|
+
config: GalangalConfig,
|
|
929
|
+
approval_artifact: str,
|
|
930
|
+
) -> bool:
|
|
931
|
+
"""
|
|
932
|
+
Handle approval gate after a stage that requires approval.
|
|
933
|
+
|
|
934
|
+
Args:
|
|
935
|
+
app: TUI application for user interaction.
|
|
936
|
+
state: Current workflow state.
|
|
937
|
+
config: Galangal configuration.
|
|
938
|
+
approval_artifact: Name of the approval artifact to create (e.g., "APPROVAL.md").
|
|
939
|
+
|
|
940
|
+
Returns:
|
|
941
|
+
True if workflow should continue, False if rejected/quit.
|
|
942
|
+
"""
|
|
943
|
+
default_approver = config.project.approver_name or ""
|
|
944
|
+
stage_name = state.stage.value
|
|
945
|
+
|
|
946
|
+
# Determine prompt type based on stage
|
|
947
|
+
prompt_type = PromptType.PLAN_APPROVAL # Default, works for most stages
|
|
948
|
+
|
|
949
|
+
choice = await app.prompt_async(prompt_type, f"Approve {stage_name} to continue?")
|
|
950
|
+
|
|
951
|
+
if choice == "yes":
|
|
952
|
+
name = await app.text_input_async("Enter approver name:", default_approver)
|
|
953
|
+
if name:
|
|
954
|
+
from galangal.core.utils import now_formatted
|
|
955
|
+
|
|
956
|
+
approval_content = f"""# {stage_name} Approval
|
|
957
|
+
|
|
958
|
+
- **Status:** Approved
|
|
959
|
+
- **Approved By:** {name}
|
|
960
|
+
- **Date:** {now_formatted()}
|
|
961
|
+
"""
|
|
962
|
+
write_artifact(approval_artifact, approval_content, state.task_name)
|
|
963
|
+
app.show_message(f"{stage_name} approved by {name}", "success")
|
|
964
|
+
|
|
965
|
+
# PM-specific: Parse and store stage plan
|
|
966
|
+
if state.stage == Stage.PM:
|
|
967
|
+
stage_plan = parse_stage_plan(state.task_name)
|
|
968
|
+
if stage_plan:
|
|
969
|
+
state.stage_plan = stage_plan
|
|
970
|
+
save_state(state)
|
|
971
|
+
# Update progress bar to hide PM-skipped stages
|
|
972
|
+
skipped = [s for s, v in stage_plan.items() if v["action"] == "skip"]
|
|
973
|
+
if skipped:
|
|
974
|
+
new_hidden = set(app._hidden_stages) | set(skipped)
|
|
975
|
+
app.update_hidden_stages(frozenset(new_hidden))
|
|
976
|
+
|
|
977
|
+
# Show stage preview after PM approval
|
|
978
|
+
preview_result = await _show_stage_preview(app, state, config)
|
|
979
|
+
if preview_result == "quit":
|
|
980
|
+
app._workflow_result = "paused"
|
|
981
|
+
return False
|
|
982
|
+
|
|
983
|
+
return True
|
|
984
|
+
else:
|
|
985
|
+
# Cancelled - ask again
|
|
986
|
+
return await _handle_stage_approval(app, state, config, approval_artifact)
|
|
987
|
+
|
|
988
|
+
elif choice == "no":
|
|
989
|
+
reason = await app.multiline_input_async(
|
|
990
|
+
"Enter rejection reason (Ctrl+S to submit):", "Needs revision"
|
|
991
|
+
)
|
|
992
|
+
if reason:
|
|
993
|
+
state.last_failure = f"{stage_name} rejected: {reason}"
|
|
994
|
+
state.reset_attempts(clear_failure=False)
|
|
995
|
+
save_state(state)
|
|
996
|
+
app.show_message(f"{stage_name} rejected: {reason}", "warning")
|
|
997
|
+
app.show_message(f"Restarting {stage_name} stage with feedback...", "info")
|
|
998
|
+
return False
|
|
999
|
+
else:
|
|
1000
|
+
# Cancelled - ask again
|
|
1001
|
+
return await _handle_stage_approval(app, state, config, approval_artifact)
|
|
1002
|
+
|
|
1003
|
+
else: # quit
|
|
1004
|
+
app._workflow_result = "paused"
|
|
1005
|
+
return False
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
async def _handle_workflow_complete(app: WorkflowTUIApp, state: WorkflowState) -> None:
|
|
1009
|
+
"""Handle workflow completion - finalization and post-completion options."""
|
|
1010
|
+
# Clear fast-track state on completion
|
|
1011
|
+
state.clear_fast_track()
|
|
1012
|
+
state.clear_passed_stages()
|
|
1013
|
+
save_state(state)
|
|
1014
|
+
|
|
1015
|
+
app.show_workflow_complete()
|
|
1016
|
+
app.update_stage("COMPLETE")
|
|
1017
|
+
app.set_status("complete", "workflow finished")
|
|
1018
|
+
|
|
1019
|
+
choice = await app.prompt_async(PromptType.COMPLETION, "Workflow complete!")
|
|
1020
|
+
|
|
1021
|
+
if choice == "yes":
|
|
1022
|
+
# Run finalization
|
|
1023
|
+
app.set_status("finalizing", "creating PR...")
|
|
1024
|
+
|
|
1025
|
+
def progress_callback(message: str, status: str) -> None:
|
|
1026
|
+
app.show_message(message, status)
|
|
1027
|
+
|
|
1028
|
+
from galangal.commands.complete import finalize_task
|
|
1029
|
+
|
|
1030
|
+
success, pr_url = await asyncio.to_thread(
|
|
1031
|
+
finalize_task,
|
|
1032
|
+
state.task_name,
|
|
1033
|
+
state,
|
|
1034
|
+
force=True,
|
|
1035
|
+
progress_callback=progress_callback,
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
if success:
|
|
1039
|
+
app.add_activity("")
|
|
1040
|
+
app.add_activity("[bold #b8bb26]Task completed successfully![/]", "✓")
|
|
1041
|
+
if pr_url and pr_url != "PR already exists":
|
|
1042
|
+
app.add_activity(f"[#83a598]PR: {pr_url}[/]", "")
|
|
1043
|
+
app.add_activity("")
|
|
1044
|
+
|
|
1045
|
+
# Show post-completion options
|
|
1046
|
+
completion_msg = "Task completed successfully!"
|
|
1047
|
+
if pr_url and pr_url.startswith("http"):
|
|
1048
|
+
completion_msg += f"\n\nPull Request:\n{pr_url}"
|
|
1049
|
+
completion_msg += "\n\nWhat would you like to do next?"
|
|
1050
|
+
|
|
1051
|
+
post_choice = await app.prompt_async(PromptType.POST_COMPLETION, completion_msg)
|
|
1052
|
+
|
|
1053
|
+
if post_choice == "new_task":
|
|
1054
|
+
app._workflow_result = "new_task"
|
|
1055
|
+
else:
|
|
1056
|
+
app._workflow_result = "done"
|
|
1057
|
+
|
|
1058
|
+
elif choice == "no":
|
|
1059
|
+
# Ask for feedback
|
|
1060
|
+
app.set_status("feedback", "waiting for input")
|
|
1061
|
+
feedback = await app.multiline_input_async(
|
|
1062
|
+
"What needs to be fixed? (Ctrl+S to submit):", ""
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
if feedback:
|
|
1066
|
+
# Append to ROLLBACK.md (preserves history from earlier failures)
|
|
1067
|
+
from galangal.core.workflow.core import append_rollback_entry
|
|
1068
|
+
|
|
1069
|
+
append_rollback_entry(
|
|
1070
|
+
task_name=state.task_name,
|
|
1071
|
+
source="Manual review at COMPLETE stage",
|
|
1072
|
+
from_stage="COMPLETE",
|
|
1073
|
+
target_stage="DEV",
|
|
1074
|
+
reason=feedback,
|
|
1075
|
+
)
|
|
1076
|
+
state.last_failure = f"Manual review feedback: {feedback}"
|
|
1077
|
+
app.show_message("Feedback recorded, rolling back to DEV", "warning")
|
|
1078
|
+
else:
|
|
1079
|
+
state.last_failure = "Manual review requested changes (no details provided)"
|
|
1080
|
+
app.show_message("Rolling back to DEV (no feedback provided)", "warning")
|
|
1081
|
+
|
|
1082
|
+
state.stage = Stage.DEV
|
|
1083
|
+
state.reset_attempts(clear_failure=False)
|
|
1084
|
+
save_state(state)
|
|
1085
|
+
app._workflow_result = "back_to_dev"
|
|
1086
|
+
|
|
1087
|
+
else:
|
|
1088
|
+
app._workflow_result = "paused"
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _start_new_task_tui() -> str:
|
|
1092
|
+
"""
|
|
1093
|
+
Create a new task using TUI prompts for task type and description.
|
|
1094
|
+
|
|
1095
|
+
Returns:
|
|
1096
|
+
Result string indicating outcome.
|
|
1097
|
+
"""
|
|
1098
|
+
app = WorkflowTUIApp("New Task", "SETUP", hidden_stages=frozenset())
|
|
1099
|
+
|
|
1100
|
+
task_info: dict[str, Any] = {
|
|
1101
|
+
"type": None,
|
|
1102
|
+
"description": None,
|
|
1103
|
+
"name": None,
|
|
1104
|
+
"github_issue": None,
|
|
1105
|
+
"github_repo": None,
|
|
1106
|
+
"screenshots": None,
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
async def task_creation_loop() -> None:
|
|
1110
|
+
"""Async task creation flow."""
|
|
1111
|
+
try:
|
|
1112
|
+
app.add_activity("[bold]Starting new task...[/bold]", "🆕")
|
|
1113
|
+
|
|
1114
|
+
# Step 0: Choose task source (manual or GitHub)
|
|
1115
|
+
app.set_status("setup", "select task source")
|
|
1116
|
+
source_choice = await app.prompt_async(PromptType.TASK_SOURCE, "Create task from:")
|
|
1117
|
+
|
|
1118
|
+
if source_choice == "quit":
|
|
1119
|
+
app._workflow_result = "cancelled"
|
|
1120
|
+
app.set_timer(0.5, app.exit)
|
|
1121
|
+
return
|
|
1122
|
+
|
|
1123
|
+
issue_body_for_screenshots = None
|
|
1124
|
+
|
|
1125
|
+
if source_choice == "github":
|
|
1126
|
+
# Handle GitHub issue selection
|
|
1127
|
+
app.set_status("setup", "checking GitHub")
|
|
1128
|
+
app.show_message("Checking GitHub setup...", "info")
|
|
1129
|
+
|
|
1130
|
+
try:
|
|
1131
|
+
from galangal.github.client import ensure_github_ready
|
|
1132
|
+
from galangal.github.issues import list_issues
|
|
1133
|
+
|
|
1134
|
+
check = await asyncio.to_thread(ensure_github_ready)
|
|
1135
|
+
if not check:
|
|
1136
|
+
app.show_message("GitHub not ready. Run 'galangal github check'", "error")
|
|
1137
|
+
app._workflow_result = "error"
|
|
1138
|
+
app.set_timer(0.5, app.exit)
|
|
1139
|
+
return
|
|
1140
|
+
|
|
1141
|
+
task_info["github_repo"] = check.repo_name
|
|
1142
|
+
|
|
1143
|
+
# List issues with galangal label
|
|
1144
|
+
app.set_status("setup", "fetching issues")
|
|
1145
|
+
app.show_message("Fetching issues...", "info")
|
|
1146
|
+
|
|
1147
|
+
issues = await asyncio.to_thread(list_issues)
|
|
1148
|
+
if not issues:
|
|
1149
|
+
app.show_message("No issues with 'galangal' label found", "warning")
|
|
1150
|
+
app._workflow_result = "cancelled"
|
|
1151
|
+
app.set_timer(0.5, app.exit)
|
|
1152
|
+
return
|
|
1153
|
+
|
|
1154
|
+
# Show issue selection
|
|
1155
|
+
app.set_status("setup", "select issue")
|
|
1156
|
+
issue_options = [(i.number, i.title) for i in issues]
|
|
1157
|
+
issue_num = await app.select_github_issue_async(issue_options)
|
|
1158
|
+
|
|
1159
|
+
if issue_num is None:
|
|
1160
|
+
app._workflow_result = "cancelled"
|
|
1161
|
+
app.set_timer(0.5, app.exit)
|
|
1162
|
+
return
|
|
1163
|
+
|
|
1164
|
+
# Get the selected issue details
|
|
1165
|
+
selected_issue = next((i for i in issues if i.number == issue_num), None)
|
|
1166
|
+
if selected_issue:
|
|
1167
|
+
task_info["github_issue"] = selected_issue.number
|
|
1168
|
+
task_info["description"] = (
|
|
1169
|
+
f"{selected_issue.title}\n\n{selected_issue.body}"
|
|
1170
|
+
)
|
|
1171
|
+
app.show_message(f"Selected issue #{selected_issue.number}", "success")
|
|
1172
|
+
|
|
1173
|
+
# Check for screenshots
|
|
1174
|
+
from galangal.github.images import extract_image_urls
|
|
1175
|
+
|
|
1176
|
+
images = extract_image_urls(selected_issue.body)
|
|
1177
|
+
if images:
|
|
1178
|
+
app.show_message(
|
|
1179
|
+
f"Found {len(images)} screenshot(s) in issue...", "info"
|
|
1180
|
+
)
|
|
1181
|
+
issue_body_for_screenshots = selected_issue.body
|
|
1182
|
+
|
|
1183
|
+
# Try to infer task type from labels
|
|
1184
|
+
type_hint = selected_issue.get_task_type_hint()
|
|
1185
|
+
if type_hint:
|
|
1186
|
+
task_info["type"] = TaskType.from_str(type_hint)
|
|
1187
|
+
app.show_message(
|
|
1188
|
+
f"Inferred type: {task_info['type'].display_name()}",
|
|
1189
|
+
"info",
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
except Exception as e:
|
|
1193
|
+
from galangal.core.utils import debug_exception
|
|
1194
|
+
|
|
1195
|
+
debug_exception("GitHub integration failed in new task flow", e)
|
|
1196
|
+
app.show_message(f"GitHub error: {e}", "error")
|
|
1197
|
+
app._workflow_result = "error"
|
|
1198
|
+
app.set_timer(0.5, app.exit)
|
|
1199
|
+
return
|
|
1200
|
+
|
|
1201
|
+
# Step 1: Get task type (if not already set from GitHub labels)
|
|
1202
|
+
if task_info["type"] is None:
|
|
1203
|
+
app.set_status("setup", "select task type")
|
|
1204
|
+
type_choice = await app.prompt_async(PromptType.TASK_TYPE, "Select task type:")
|
|
1205
|
+
|
|
1206
|
+
if type_choice == "quit":
|
|
1207
|
+
app._workflow_result = "cancelled"
|
|
1208
|
+
app.set_timer(0.5, app.exit)
|
|
1209
|
+
return
|
|
1210
|
+
|
|
1211
|
+
# Map selection to TaskType
|
|
1212
|
+
task_info["type"] = TaskType.from_str(type_choice)
|
|
1213
|
+
|
|
1214
|
+
app.show_message(f"Task type: {task_info['type'].display_name()}", "success")
|
|
1215
|
+
|
|
1216
|
+
# Step 2: Get task description (if not from GitHub)
|
|
1217
|
+
if not task_info["description"]:
|
|
1218
|
+
app.set_status("setup", "enter description")
|
|
1219
|
+
description = await app.multiline_input_async(
|
|
1220
|
+
"Enter task description (Ctrl+S to submit):", ""
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
if not description:
|
|
1224
|
+
app.show_message("Task creation cancelled", "warning")
|
|
1225
|
+
app._workflow_result = "cancelled"
|
|
1226
|
+
app.set_timer(0.5, app.exit)
|
|
1227
|
+
return
|
|
1228
|
+
|
|
1229
|
+
task_info["description"] = description
|
|
1230
|
+
|
|
1231
|
+
# Step 3: Generate task name
|
|
1232
|
+
app.set_status("setup", "generating task name")
|
|
1233
|
+
from galangal.commands.start import create_task
|
|
1234
|
+
from galangal.core.tasks import generate_unique_task_name
|
|
1235
|
+
|
|
1236
|
+
# Use prefix for GitHub issues
|
|
1237
|
+
prefix = f"issue-{task_info['github_issue']}" if task_info["github_issue"] else None
|
|
1238
|
+
task_info["name"] = await asyncio.to_thread(
|
|
1239
|
+
generate_unique_task_name, task_info["description"], prefix
|
|
1240
|
+
)
|
|
1241
|
+
app.show_message(f"Task name: {task_info['name']}", "info")
|
|
1242
|
+
|
|
1243
|
+
# Step 3.5: Download screenshots if from GitHub issue
|
|
1244
|
+
if issue_body_for_screenshots:
|
|
1245
|
+
app.set_status("setup", "downloading screenshots")
|
|
1246
|
+
try:
|
|
1247
|
+
from galangal.github.issues import download_issue_screenshots
|
|
1248
|
+
|
|
1249
|
+
task_dir = get_task_dir(task_info["name"])
|
|
1250
|
+
screenshot_paths = await asyncio.to_thread(
|
|
1251
|
+
download_issue_screenshots,
|
|
1252
|
+
issue_body_for_screenshots,
|
|
1253
|
+
task_dir,
|
|
1254
|
+
)
|
|
1255
|
+
if screenshot_paths:
|
|
1256
|
+
task_info["screenshots"] = screenshot_paths
|
|
1257
|
+
app.show_message(
|
|
1258
|
+
f"Downloaded {len(screenshot_paths)} screenshot(s)",
|
|
1259
|
+
"success",
|
|
1260
|
+
)
|
|
1261
|
+
except Exception as e:
|
|
1262
|
+
from galangal.core.utils import debug_exception
|
|
1263
|
+
|
|
1264
|
+
debug_exception("Screenshot download failed", e)
|
|
1265
|
+
app.show_message(f"Screenshot download failed: {e}", "warning")
|
|
1266
|
+
# Non-critical - continue without screenshots
|
|
1267
|
+
|
|
1268
|
+
# Step 4: Create the task
|
|
1269
|
+
app.set_status("setup", "creating task")
|
|
1270
|
+
success, message = await asyncio.to_thread(
|
|
1271
|
+
create_task,
|
|
1272
|
+
task_info["name"],
|
|
1273
|
+
task_info["description"],
|
|
1274
|
+
task_info["type"],
|
|
1275
|
+
task_info["github_issue"],
|
|
1276
|
+
task_info["github_repo"],
|
|
1277
|
+
task_info["screenshots"],
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
if success:
|
|
1281
|
+
app.show_message(message, "success")
|
|
1282
|
+
app._workflow_result = "task_created"
|
|
1283
|
+
|
|
1284
|
+
# Mark issue as in-progress if from GitHub
|
|
1285
|
+
if task_info["github_issue"]:
|
|
1286
|
+
try:
|
|
1287
|
+
from galangal.github.issues import mark_issue_in_progress
|
|
1288
|
+
|
|
1289
|
+
await asyncio.to_thread(mark_issue_in_progress, task_info["github_issue"])
|
|
1290
|
+
app.show_message("Marked issue as in-progress", "info")
|
|
1291
|
+
except Exception as e:
|
|
1292
|
+
from galangal.core.utils import debug_exception
|
|
1293
|
+
|
|
1294
|
+
debug_exception("Failed to mark issue as in-progress", e)
|
|
1295
|
+
# Non-critical - continue anyway
|
|
1296
|
+
else:
|
|
1297
|
+
app.show_error("Task creation failed", message)
|
|
1298
|
+
app._workflow_result = "error"
|
|
1299
|
+
|
|
1300
|
+
except Exception as e:
|
|
1301
|
+
from galangal.core.utils import debug_exception
|
|
1302
|
+
|
|
1303
|
+
debug_exception("Task creation failed in new task flow", e)
|
|
1304
|
+
app.show_error("Task creation error", str(e))
|
|
1305
|
+
app._workflow_result = "error"
|
|
1306
|
+
finally:
|
|
1307
|
+
app.set_timer(0.5, app.exit)
|
|
1308
|
+
|
|
1309
|
+
# Start creation as async worker
|
|
1310
|
+
app.call_later(lambda: app.run_worker(task_creation_loop(), exclusive=True))
|
|
1311
|
+
app.run()
|
|
1312
|
+
|
|
1313
|
+
result = app._workflow_result or "cancelled"
|
|
1314
|
+
|
|
1315
|
+
if result == "task_created" and task_info["name"]:
|
|
1316
|
+
from galangal.core.state import load_state
|
|
1317
|
+
|
|
1318
|
+
new_state = load_state(task_info["name"])
|
|
1319
|
+
if new_state:
|
|
1320
|
+
return _run_workflow_with_tui(new_state)
|
|
1321
|
+
|
|
1322
|
+
return result
|