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.
Files changed (79) hide show
  1. galangal/__init__.py +36 -0
  2. galangal/__main__.py +6 -0
  3. galangal/ai/__init__.py +167 -0
  4. galangal/ai/base.py +159 -0
  5. galangal/ai/claude.py +352 -0
  6. galangal/ai/codex.py +370 -0
  7. galangal/ai/gemini.py +43 -0
  8. galangal/ai/subprocess.py +254 -0
  9. galangal/cli.py +371 -0
  10. galangal/commands/__init__.py +27 -0
  11. galangal/commands/complete.py +367 -0
  12. galangal/commands/github.py +355 -0
  13. galangal/commands/init.py +177 -0
  14. galangal/commands/init_wizard.py +762 -0
  15. galangal/commands/list.py +20 -0
  16. galangal/commands/pause.py +34 -0
  17. galangal/commands/prompts.py +89 -0
  18. galangal/commands/reset.py +41 -0
  19. galangal/commands/resume.py +30 -0
  20. galangal/commands/skip.py +62 -0
  21. galangal/commands/start.py +530 -0
  22. galangal/commands/status.py +44 -0
  23. galangal/commands/switch.py +28 -0
  24. galangal/config/__init__.py +15 -0
  25. galangal/config/defaults.py +183 -0
  26. galangal/config/loader.py +163 -0
  27. galangal/config/schema.py +330 -0
  28. galangal/core/__init__.py +33 -0
  29. galangal/core/artifacts.py +136 -0
  30. galangal/core/state.py +1097 -0
  31. galangal/core/tasks.py +454 -0
  32. galangal/core/utils.py +116 -0
  33. galangal/core/workflow/__init__.py +68 -0
  34. galangal/core/workflow/core.py +789 -0
  35. galangal/core/workflow/engine.py +781 -0
  36. galangal/core/workflow/pause.py +35 -0
  37. galangal/core/workflow/tui_runner.py +1322 -0
  38. galangal/exceptions.py +36 -0
  39. galangal/github/__init__.py +31 -0
  40. galangal/github/client.py +427 -0
  41. galangal/github/images.py +324 -0
  42. galangal/github/issues.py +298 -0
  43. galangal/logging.py +364 -0
  44. galangal/prompts/__init__.py +5 -0
  45. galangal/prompts/builder.py +527 -0
  46. galangal/prompts/defaults/benchmark.md +34 -0
  47. galangal/prompts/defaults/contract.md +35 -0
  48. galangal/prompts/defaults/design.md +54 -0
  49. galangal/prompts/defaults/dev.md +89 -0
  50. galangal/prompts/defaults/docs.md +104 -0
  51. galangal/prompts/defaults/migration.md +59 -0
  52. galangal/prompts/defaults/pm.md +110 -0
  53. galangal/prompts/defaults/pm_questions.md +53 -0
  54. galangal/prompts/defaults/preflight.md +32 -0
  55. galangal/prompts/defaults/qa.md +65 -0
  56. galangal/prompts/defaults/review.md +90 -0
  57. galangal/prompts/defaults/review_codex.md +99 -0
  58. galangal/prompts/defaults/security.md +84 -0
  59. galangal/prompts/defaults/test.md +91 -0
  60. galangal/results.py +176 -0
  61. galangal/ui/__init__.py +5 -0
  62. galangal/ui/console.py +126 -0
  63. galangal/ui/tui/__init__.py +56 -0
  64. galangal/ui/tui/adapters.py +168 -0
  65. galangal/ui/tui/app.py +902 -0
  66. galangal/ui/tui/entry.py +24 -0
  67. galangal/ui/tui/mixins.py +196 -0
  68. galangal/ui/tui/modals.py +339 -0
  69. galangal/ui/tui/styles/app.tcss +86 -0
  70. galangal/ui/tui/styles/modals.tcss +197 -0
  71. galangal/ui/tui/types.py +107 -0
  72. galangal/ui/tui/widgets.py +263 -0
  73. galangal/validation/__init__.py +5 -0
  74. galangal/validation/runner.py +1072 -0
  75. galangal_orchestrate-0.13.0.dist-info/METADATA +985 -0
  76. galangal_orchestrate-0.13.0.dist-info/RECORD +79 -0
  77. galangal_orchestrate-0.13.0.dist-info/WHEEL +4 -0
  78. galangal_orchestrate-0.13.0.dist-info/entry_points.txt +2 -0
  79. galangal_orchestrate-0.13.0.dist-info/licenses/LICENSE +674 -0
galangal/core/tasks.py ADDED
@@ -0,0 +1,454 @@
1
+ """
2
+ Task directory management - creating, listing, and switching tasks.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import re
9
+ import subprocess
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+ from typing import TYPE_CHECKING
13
+
14
+ from galangal.config.loader import (
15
+ get_active_file,
16
+ get_config,
17
+ get_done_dir,
18
+ get_project_root,
19
+ get_tasks_dir,
20
+ )
21
+ from galangal.core.artifacts import run_command
22
+
23
+ if TYPE_CHECKING:
24
+ from galangal.core.state import WorkflowState
25
+
26
+
27
+ def get_active_task() -> str | None:
28
+ """Get the currently active task name."""
29
+ active_file = get_active_file()
30
+ if active_file.exists():
31
+ return active_file.read_text().strip()
32
+ return None
33
+
34
+
35
+ def set_active_task(task_name: str) -> None:
36
+ """Set the active task."""
37
+ tasks_dir = get_tasks_dir()
38
+ tasks_dir.mkdir(parents=True, exist_ok=True)
39
+ get_active_file().write_text(task_name)
40
+
41
+
42
+ def clear_active_task() -> None:
43
+ """Clear the active task."""
44
+ active_file = get_active_file()
45
+ if active_file.exists():
46
+ active_file.unlink()
47
+
48
+
49
+ def list_tasks() -> list[tuple[str, str, str, str]]:
50
+ """List all tasks. Returns [(name, stage, task_type, description), ...]."""
51
+ tasks = []
52
+ tasks_dir = get_tasks_dir()
53
+ if not tasks_dir.exists():
54
+ return tasks
55
+
56
+ for task_dir in tasks_dir.iterdir():
57
+ if task_dir.is_dir() and not task_dir.name.startswith(".") and task_dir.name != "done":
58
+ state_file = task_dir / "state.json"
59
+ if state_file.exists():
60
+ try:
61
+ with open(state_file) as f:
62
+ data = json.load(f)
63
+ tasks.append(
64
+ (
65
+ task_dir.name,
66
+ data.get("stage", "?"),
67
+ data.get("task_type", "feature"),
68
+ data.get("task_description", "")[:50],
69
+ )
70
+ )
71
+ except (json.JSONDecodeError, KeyError):
72
+ tasks.append((task_dir.name, "?", "?", "(invalid state)"))
73
+ return sorted(tasks)
74
+
75
+
76
+ def generate_task_name_ai(description: str) -> str | None:
77
+ """Use AI to generate a concise, meaningful task name.
78
+
79
+ Uses the configured AI backend from config.ai.default with fallback support.
80
+ """
81
+ from galangal.ai import get_backend_with_fallback
82
+
83
+ prompt = f"""Generate a short task name for this description. Rules:
84
+ - 2-4 words, kebab-case (e.g., fix-auth-bug, add-user-export)
85
+ - No prefix, just the name itself
86
+ - Capture the essence of the task
87
+ - Use action verbs (fix, add, update, refactor, implement)
88
+
89
+ Description: {description}
90
+
91
+ Reply with ONLY the task name, nothing else."""
92
+
93
+ try:
94
+ config = get_config()
95
+ backend = get_backend_with_fallback(config.ai.default, config=config)
96
+ result = backend.generate_text(prompt, timeout=30)
97
+
98
+ if result:
99
+ # Clean the response - extract just the task name
100
+ name = result.strip().lower()
101
+ # Remove any quotes, backticks, or extra text
102
+ name = re.sub(r"[`\"']", "", name)
103
+ # Take only first line if multiple
104
+ name = name.split("\n")[0].strip()
105
+ # Validate it looks like a task name (kebab-case, reasonable length)
106
+ if re.match(r"^[a-z][a-z0-9-]{2,40}$", name) and name.count("-") <= 5:
107
+ return name
108
+ except (ValueError, Exception):
109
+ # ValueError: no backend available; Exception: other errors
110
+ pass
111
+ return None
112
+
113
+
114
+ def generate_task_name_fallback(description: str) -> str:
115
+ """Fallback: Generate task name from description using simple word extraction."""
116
+ words = description.lower().split()[:4]
117
+ cleaned = [re.sub(r"[^a-z0-9]", "", w) for w in words]
118
+ cleaned = [w for w in cleaned if w]
119
+ name = "-".join(cleaned)
120
+ return name if name else f"task-{datetime.now().strftime('%Y%m%d%H%M%S')}"
121
+
122
+
123
+ def generate_task_name(description: str) -> str:
124
+ """Generate a task name from description using AI with fallback."""
125
+ # Try AI-generated name first
126
+ ai_name = generate_task_name_ai(description)
127
+ if ai_name:
128
+ return ai_name
129
+
130
+ # Fallback to simple extraction
131
+ return generate_task_name_fallback(description)
132
+
133
+
134
+ # Safe pattern for task names: alphanumeric, hyphens, underscores
135
+ # Must start with letter/number, max 60 chars
136
+ TASK_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,59}$")
137
+
138
+
139
+ def is_valid_task_name(name: str) -> tuple[bool, str]:
140
+ """
141
+ Validate a task name for safety and format.
142
+
143
+ Task names must:
144
+ - Start with a letter or number
145
+ - Contain only alphanumeric characters, hyphens, and underscores
146
+ - Be 1-60 characters long
147
+
148
+ This prevents shell injection via task names used in validation commands.
149
+
150
+ Args:
151
+ name: The task name to validate.
152
+
153
+ Returns:
154
+ Tuple of (is_valid, error_message). Error message is empty if valid.
155
+ """
156
+ if not name:
157
+ return False, "Task name cannot be empty"
158
+
159
+ if len(name) > 60:
160
+ return False, "Task name must be 60 characters or less"
161
+
162
+ if not TASK_NAME_PATTERN.match(name):
163
+ return (
164
+ False,
165
+ "Task name must start with letter/number and contain only alphanumeric, hyphens, underscores",
166
+ )
167
+
168
+ return True, ""
169
+
170
+
171
+ def task_name_exists(name: str) -> bool:
172
+ """Check if task name exists in active or done folders."""
173
+ return (get_tasks_dir() / name).exists() or (get_done_dir() / name).exists()
174
+
175
+
176
+ def generate_unique_task_name(
177
+ description: str,
178
+ prefix: str | None = None,
179
+ ) -> str:
180
+ """Generate a unique task name with automatic suffix if needed.
181
+
182
+ Uses AI to generate a meaningful task name from the description,
183
+ then ensures uniqueness by appending a numeric suffix if the name
184
+ already exists.
185
+
186
+ Args:
187
+ description: Task description to generate name from.
188
+ prefix: Optional prefix (e.g., "issue-123") to prepend to the name.
189
+
190
+ Returns:
191
+ A unique task name that doesn't conflict with existing tasks.
192
+ """
193
+ base_name = generate_task_name(description)
194
+
195
+ if prefix:
196
+ base_name = f"{prefix}-{base_name}"
197
+
198
+ # Find unique name with suffix if needed
199
+ final_name = base_name
200
+ suffix = 2
201
+ while task_name_exists(final_name):
202
+ final_name = f"{base_name}-{suffix}"
203
+ suffix += 1
204
+
205
+ return final_name
206
+
207
+
208
+ def create_task_branch(task_name: str) -> tuple[bool, str]:
209
+ """Create a git branch for the task."""
210
+ config = get_config()
211
+ branch_name = config.branch_pattern.format(task_name=task_name)
212
+
213
+ # Check if branch already exists
214
+ code, out, _ = run_command(["git", "branch", "--list", branch_name])
215
+ if out.strip():
216
+ # Branch exists - check it out to ensure we're on it
217
+ code, out, err = run_command(["git", "checkout", branch_name])
218
+ if code != 0:
219
+ return False, f"Branch {branch_name} exists but checkout failed: {err}"
220
+ return True, f"Checked out existing branch: {branch_name}"
221
+
222
+ # Create and checkout new branch
223
+ code, out, err = run_command(["git", "checkout", "-b", branch_name])
224
+ if code != 0:
225
+ return False, f"Failed to create branch: {err}"
226
+
227
+ return True, f"Created branch: {branch_name}"
228
+
229
+
230
+ def get_current_branch() -> str:
231
+ """Get the current git branch name."""
232
+ try:
233
+ result = subprocess.run(
234
+ ["git", "branch", "--show-current"],
235
+ cwd=get_project_root(),
236
+ capture_output=True,
237
+ text=True,
238
+ timeout=5,
239
+ )
240
+ return result.stdout.strip() or "unknown"
241
+ except Exception:
242
+ return "unknown"
243
+
244
+
245
+ def is_on_base_branch() -> tuple[bool, str, str]:
246
+ """
247
+ Check if the repo is on the configured base branch.
248
+
249
+ Returns:
250
+ Tuple of (is_on_base, current_branch, base_branch)
251
+ """
252
+ config = get_config()
253
+ base_branch = config.pr.base_branch
254
+ current_branch = get_current_branch()
255
+ return current_branch == base_branch, current_branch, base_branch
256
+
257
+
258
+ def switch_to_base_branch() -> tuple[bool, str]:
259
+ """
260
+ Switch to the configured base branch.
261
+
262
+ Returns:
263
+ Tuple of (success, message)
264
+ """
265
+ config = get_config()
266
+ base_branch = config.pr.base_branch
267
+
268
+ code, out, err = run_command(["git", "checkout", base_branch])
269
+ if code != 0:
270
+ return False, f"Failed to switch to {base_branch}: {err}"
271
+
272
+ return True, f"Switched to {base_branch}"
273
+
274
+
275
+ def pull_base_branch() -> tuple[bool, str]:
276
+ """
277
+ Pull the latest changes from the remote base branch.
278
+
279
+ Returns:
280
+ Tuple of (success, message)
281
+ """
282
+ config = get_config()
283
+ base_branch = config.pr.base_branch
284
+
285
+ code, out, err = run_command(["git", "pull", "origin", base_branch])
286
+ if code != 0:
287
+ # Check if it's a "no tracking" error - try without remote name
288
+ if "no tracking information" in err.lower():
289
+ code, out, err = run_command(["git", "pull"])
290
+ if code != 0:
291
+ return False, f"Failed to pull: {err}"
292
+ else:
293
+ return False, f"Failed to pull from origin/{base_branch}: {err}"
294
+
295
+ return True, f"Pulled latest from {base_branch}"
296
+
297
+
298
+ def ensure_active_task_with_state(
299
+ no_task_msg: str = "No active task.",
300
+ no_state_msg: str = "Could not load state for '{task}'.",
301
+ ) -> tuple[str, WorkflowState] | tuple[None, None]:
302
+ """Load active task and its state, with error handling.
303
+
304
+ This helper consolidates the common pattern of loading the active task
305
+ and its state, with appropriate error messages for each failure case.
306
+
307
+ Args:
308
+ no_task_msg: Message to print if no active task.
309
+ no_state_msg: Message template if state can't be loaded.
310
+ Use {task} placeholder for task name.
311
+
312
+ Returns:
313
+ Tuple of (task_name, state) if successful,
314
+ or (None, None) with error printed if failed.
315
+ """
316
+ from galangal.core.state import load_state
317
+ from galangal.ui.console import print_error
318
+
319
+ active = get_active_task()
320
+ if not active:
321
+ print_error(no_task_msg)
322
+ return None, None
323
+
324
+ state = load_state(active)
325
+ if state is None:
326
+ print_error(no_state_msg.format(task=active))
327
+ return None, None
328
+
329
+ return active, state
330
+
331
+
332
+ @dataclass
333
+ class TaskFromIssueResult:
334
+ """Result of creating a task from a GitHub issue."""
335
+
336
+ success: bool
337
+ message: str
338
+ task_name: str | None = None
339
+ screenshots: list[str] | None = None
340
+
341
+
342
+ def create_task_from_issue(
343
+ issue: GitHubIssue,
344
+ repo_name: str | None = None,
345
+ task_name_override: str | None = None,
346
+ mark_in_progress: bool = True,
347
+ ) -> TaskFromIssueResult:
348
+ """
349
+ Create a task from a GitHub issue with all associated setup.
350
+
351
+ This consolidates the task creation flow that was duplicated across
352
+ start.py, tui_runner.py, and github.py. Handles:
353
+ - Task name generation and validation
354
+ - Task creation with GitHub metadata
355
+ - Screenshot download (after task creation)
356
+ - Marking issue as in-progress
357
+
358
+ Args:
359
+ issue: The GitHubIssue to create a task from
360
+ repo_name: Optional repo name (owner/repo), fetched if not provided
361
+ task_name_override: Optional override for task name (will be validated)
362
+ mark_in_progress: Whether to mark the issue as in-progress
363
+
364
+ Returns:
365
+ TaskFromIssueResult with success status, message, and task details
366
+ """
367
+ from galangal.commands.start import create_task
368
+ from galangal.core.state import TaskType, get_task_dir, load_state, save_state
369
+ from galangal.github.issues import (
370
+ download_issue_screenshots,
371
+ mark_issue_in_progress,
372
+ prepare_issue_for_task,
373
+ )
374
+
375
+ # Step 1: Extract issue data using existing helper
376
+ issue_data = prepare_issue_for_task(issue, repo_name)
377
+
378
+ # Step 2: Infer task type from labels
379
+ task_type = (
380
+ TaskType.from_str(issue_data.task_type_hint)
381
+ if issue_data.task_type_hint
382
+ else TaskType.FEATURE
383
+ )
384
+
385
+ # Step 3: Generate or validate task name
386
+ if task_name_override:
387
+ # Validate provided name
388
+ valid, error_msg = is_valid_task_name(task_name_override)
389
+ if not valid:
390
+ return TaskFromIssueResult(
391
+ success=False,
392
+ message=f"Invalid task name: {error_msg}",
393
+ )
394
+ if task_name_exists(task_name_override):
395
+ return TaskFromIssueResult(
396
+ success=False,
397
+ message=f"Task '{task_name_override}' already exists",
398
+ )
399
+ task_name = task_name_override
400
+ else:
401
+ # Generate unique name with issue prefix
402
+ prefix = f"issue-{issue.number}"
403
+ task_name = generate_unique_task_name(issue_data.description, prefix)
404
+
405
+ # Step 4: Create the task
406
+ success, message = create_task(
407
+ task_name,
408
+ issue_data.description,
409
+ task_type,
410
+ github_issue=issue.number,
411
+ github_repo=issue_data.github_repo,
412
+ )
413
+
414
+ if not success:
415
+ return TaskFromIssueResult(
416
+ success=False,
417
+ message=message,
418
+ )
419
+
420
+ # Step 5: Download screenshots AFTER task creation
421
+ screenshots = []
422
+ if issue_data.issue_body:
423
+ try:
424
+ task_dir = get_task_dir(task_name)
425
+ screenshots = download_issue_screenshots(issue_data.issue_body, task_dir)
426
+ if screenshots:
427
+ # Update state with screenshot paths
428
+ state = load_state(task_name)
429
+ if state:
430
+ state.screenshots = screenshots
431
+ save_state(state)
432
+ except Exception:
433
+ # Non-critical - continue without screenshots
434
+ pass
435
+
436
+ # Step 6: Mark issue as in-progress
437
+ if mark_in_progress:
438
+ try:
439
+ mark_issue_in_progress(issue.number)
440
+ except Exception:
441
+ # Non-critical - continue anyway
442
+ pass
443
+
444
+ return TaskFromIssueResult(
445
+ success=True,
446
+ message=message,
447
+ task_name=task_name,
448
+ screenshots=screenshots,
449
+ )
450
+
451
+
452
+ # Type hint import for type checking only
453
+ if TYPE_CHECKING:
454
+ from galangal.github.issues import GitHubIssue
galangal/core/utils.py ADDED
@@ -0,0 +1,116 @@
1
+ """
2
+ Common utility functions to avoid code duplication.
3
+ """
4
+
5
+ import os
6
+ import traceback
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ # Debug log file path (lazily initialized)
11
+ _debug_file: Path | None = None
12
+
13
+
14
+ def is_debug_enabled() -> bool:
15
+ """Check if debug mode is enabled.
16
+
17
+ Always checks the environment variable (no caching) to handle
18
+ cases where debug mode is enabled after initial import.
19
+ """
20
+ return os.environ.get("GALANGAL_DEBUG", "").lower() in ("1", "true", "yes")
21
+
22
+
23
+ def reset_debug_state() -> None:
24
+ """Reset debug file path. Called when debug mode is enabled via CLI."""
25
+ global _debug_file
26
+ _debug_file = None
27
+
28
+
29
+ def debug_log(message: str, **context: object) -> None:
30
+ """Log a debug message if debug mode is enabled.
31
+
32
+ Writes to logs/galangal_debug.log with timestamp.
33
+
34
+ Args:
35
+ message: The message to log.
36
+ **context: Additional key-value pairs to include in the log.
37
+
38
+ Example:
39
+ debug_log("GitHub API call failed", error=str(e), issue=123)
40
+ """
41
+ if not is_debug_enabled():
42
+ return
43
+
44
+ global _debug_file
45
+ if _debug_file is None:
46
+ from galangal.config.loader import get_project_root
47
+
48
+ logs_dir = get_project_root() / "logs"
49
+ logs_dir.mkdir(exist_ok=True)
50
+ _debug_file = logs_dir / "galangal_debug.log"
51
+
52
+ timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
53
+ context_str = " ".join(f"{k}={v}" for k, v in context.items()) if context else ""
54
+ line = f"[{timestamp}] {message}"
55
+ if context_str:
56
+ line += f" | {context_str}"
57
+
58
+ try:
59
+ with open(_debug_file, "a") as f:
60
+ f.write(line + "\n")
61
+ except Exception:
62
+ pass # Don't fail if we can't write debug log
63
+
64
+
65
+ def debug_exception(message: str, exc: Exception) -> None:
66
+ """Log an exception with full traceback if debug mode is enabled.
67
+
68
+ Args:
69
+ message: Context message about what failed.
70
+ exc: The exception that was caught.
71
+ """
72
+ if not is_debug_enabled():
73
+ return
74
+
75
+ debug_log(f"{message}: {type(exc).__name__}: {exc}")
76
+ debug_log(f"Traceback:\n{traceback.format_exc()}")
77
+
78
+
79
+ def now_iso() -> str:
80
+ """Return current UTC datetime as ISO format string.
81
+
82
+ This is the canonical way to get timestamps throughout the codebase.
83
+
84
+ Returns:
85
+ ISO format string, e.g., "2024-01-15T10:30:00+00:00"
86
+ """
87
+ return datetime.now(timezone.utc).isoformat()
88
+
89
+
90
+ def now_formatted() -> str:
91
+ """Return current UTC datetime in human-readable format.
92
+
93
+ Returns:
94
+ Formatted string, e.g., "2024-01-15 10:30 UTC"
95
+ """
96
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
97
+
98
+
99
+ def truncate_text(
100
+ text: str,
101
+ max_length: int = 1500,
102
+ suffix: str = "...",
103
+ ) -> str:
104
+ """Truncate text to max_length, adding suffix if truncated.
105
+
106
+ Args:
107
+ text: Text to truncate.
108
+ max_length: Maximum length before truncation.
109
+ suffix: String to append if truncated.
110
+
111
+ Returns:
112
+ Original text if within limit, or truncated text with suffix.
113
+ """
114
+ if len(text) <= max_length:
115
+ return text
116
+ return text[:max_length] + suffix
@@ -0,0 +1,68 @@
1
+ """
2
+ Workflow execution - stage execution, rollback, loop handling.
3
+
4
+ This package provides:
5
+ - run_workflow: Main entry point for workflow execution
6
+ - get_next_stage: Get the next stage in the workflow
7
+ - execute_stage: Execute a single stage
8
+ - handle_rollback: Handle rollback signals from validators
9
+ """
10
+
11
+ from galangal.core.state import WorkflowState
12
+ from galangal.core.workflow.core import (
13
+ archive_rollback_if_exists,
14
+ execute_stage,
15
+ get_next_stage,
16
+ handle_rollback,
17
+ )
18
+
19
+ __all__ = [
20
+ "run_workflow",
21
+ "get_next_stage",
22
+ "execute_stage",
23
+ "handle_rollback",
24
+ "archive_rollback_if_exists",
25
+ ]
26
+
27
+
28
+ def _init_logging() -> None:
29
+ """Initialize structured logging from config."""
30
+ from galangal.config.loader import get_config
31
+ from galangal.logging import configure_logging
32
+
33
+ config = get_config()
34
+ log_config = config.logging
35
+
36
+ if log_config.enabled:
37
+ configure_logging(
38
+ level=log_config.level, # type: ignore
39
+ log_file=log_config.file,
40
+ json_format=log_config.json_format,
41
+ console_output=log_config.console,
42
+ )
43
+
44
+
45
+ def run_workflow(state: WorkflowState) -> None:
46
+ """Run the workflow from current state to completion or failure."""
47
+ from galangal.core.workflow.tui_runner import _run_workflow_with_tui
48
+ from galangal.logging import workflow_logger
49
+
50
+ # Initialize logging if configured
51
+ _init_logging()
52
+
53
+ # Log workflow start
54
+ workflow_logger.workflow_started(
55
+ task_name=state.task_name,
56
+ task_type=state.task_type.value,
57
+ stage=state.stage.value,
58
+ )
59
+
60
+ try:
61
+ _run_workflow_with_tui(state)
62
+ finally:
63
+ # Log workflow end
64
+ workflow_logger.workflow_completed(
65
+ task_name=state.task_name,
66
+ task_type=state.task_type.value,
67
+ success=(state.stage.value == "COMPLETE"),
68
+ )