galangal-orchestrate 0.2.11__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.
Potentially problematic release.
This version of galangal-orchestrate might be problematic. Click here for more details.
- galangal/__init__.py +8 -0
- galangal/__main__.py +6 -0
- galangal/ai/__init__.py +6 -0
- galangal/ai/base.py +55 -0
- galangal/ai/claude.py +278 -0
- galangal/ai/gemini.py +38 -0
- galangal/cli.py +296 -0
- galangal/commands/__init__.py +42 -0
- galangal/commands/approve.py +187 -0
- galangal/commands/complete.py +268 -0
- galangal/commands/init.py +173 -0
- galangal/commands/list.py +20 -0
- galangal/commands/pause.py +40 -0
- galangal/commands/prompts.py +98 -0
- galangal/commands/reset.py +43 -0
- galangal/commands/resume.py +29 -0
- galangal/commands/skip.py +216 -0
- galangal/commands/start.py +144 -0
- galangal/commands/status.py +62 -0
- galangal/commands/switch.py +28 -0
- galangal/config/__init__.py +13 -0
- galangal/config/defaults.py +133 -0
- galangal/config/loader.py +113 -0
- galangal/config/schema.py +155 -0
- galangal/core/__init__.py +18 -0
- galangal/core/artifacts.py +66 -0
- galangal/core/state.py +248 -0
- galangal/core/tasks.py +170 -0
- galangal/core/workflow.py +835 -0
- galangal/prompts/__init__.py +5 -0
- galangal/prompts/builder.py +166 -0
- galangal/prompts/defaults/design.md +54 -0
- galangal/prompts/defaults/dev.md +39 -0
- galangal/prompts/defaults/docs.md +46 -0
- galangal/prompts/defaults/pm.md +75 -0
- galangal/prompts/defaults/qa.md +49 -0
- galangal/prompts/defaults/review.md +65 -0
- galangal/prompts/defaults/security.md +68 -0
- galangal/prompts/defaults/test.md +59 -0
- galangal/ui/__init__.py +5 -0
- galangal/ui/console.py +123 -0
- galangal/ui/tui.py +1065 -0
- galangal/validation/__init__.py +5 -0
- galangal/validation/runner.py +395 -0
- galangal_orchestrate-0.2.11.dist-info/METADATA +278 -0
- galangal_orchestrate-0.2.11.dist-info/RECORD +49 -0
- galangal_orchestrate-0.2.11.dist-info/WHEEL +4 -0
- galangal_orchestrate-0.2.11.dist-info/entry_points.txt +2 -0
- galangal_orchestrate-0.2.11.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Default configuration values and templates.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
DEFAULT_CONFIG_YAML = """\
|
|
6
|
+
# Galangal Orchestrate Configuration
|
|
7
|
+
# https://github.com/Galangal-Media/galangal-orchestrate
|
|
8
|
+
|
|
9
|
+
project:
|
|
10
|
+
name: "{project_name}"
|
|
11
|
+
|
|
12
|
+
# Technology stacks in this project
|
|
13
|
+
stacks:
|
|
14
|
+
{stacks_yaml}
|
|
15
|
+
|
|
16
|
+
# Task storage location
|
|
17
|
+
tasks_dir: galangal-tasks
|
|
18
|
+
|
|
19
|
+
# Git branch naming pattern
|
|
20
|
+
branch_pattern: "task/{{task_name}}"
|
|
21
|
+
|
|
22
|
+
# =============================================================================
|
|
23
|
+
# Stage Configuration
|
|
24
|
+
# =============================================================================
|
|
25
|
+
|
|
26
|
+
stages:
|
|
27
|
+
# Stages to always skip for this project
|
|
28
|
+
skip:
|
|
29
|
+
- BENCHMARK # Enable if you have performance requirements
|
|
30
|
+
# - CONTRACT # Enable if you have OpenAPI contract testing
|
|
31
|
+
# - MIGRATION # Enable if you have database migrations
|
|
32
|
+
|
|
33
|
+
# Stage timeout in seconds (default: 4 hours)
|
|
34
|
+
timeout: 14400
|
|
35
|
+
|
|
36
|
+
# Max retries per stage
|
|
37
|
+
max_retries: 5
|
|
38
|
+
|
|
39
|
+
# =============================================================================
|
|
40
|
+
# Validation Commands
|
|
41
|
+
# =============================================================================
|
|
42
|
+
# Configure how each stage validates its outputs.
|
|
43
|
+
# Use {{task_dir}} placeholder for the task artifacts directory.
|
|
44
|
+
|
|
45
|
+
validation:
|
|
46
|
+
# Preflight - environment checks (runs directly, no AI)
|
|
47
|
+
preflight:
|
|
48
|
+
checks:
|
|
49
|
+
- name: "Git clean"
|
|
50
|
+
command: "git status --porcelain"
|
|
51
|
+
expect_empty: true
|
|
52
|
+
warn_only: true # Report but don't fail if working tree has changes
|
|
53
|
+
|
|
54
|
+
# QA - quality checks
|
|
55
|
+
qa:
|
|
56
|
+
# Default timeout per command (seconds)
|
|
57
|
+
timeout: 300
|
|
58
|
+
commands:
|
|
59
|
+
- name: "Tests"
|
|
60
|
+
command: "echo 'Configure your test command in .galangal/config.yaml'"
|
|
61
|
+
# timeout: 3600
|
|
62
|
+
|
|
63
|
+
# Review - code review (AI-driven)
|
|
64
|
+
review:
|
|
65
|
+
pass_marker: "APPROVE"
|
|
66
|
+
fail_marker: "REQUEST_CHANGES"
|
|
67
|
+
artifact: "REVIEW_NOTES.md"
|
|
68
|
+
|
|
69
|
+
# =============================================================================
|
|
70
|
+
# AI Backend Configuration
|
|
71
|
+
# =============================================================================
|
|
72
|
+
|
|
73
|
+
ai:
|
|
74
|
+
default: claude
|
|
75
|
+
|
|
76
|
+
backends:
|
|
77
|
+
claude:
|
|
78
|
+
command: "claude"
|
|
79
|
+
args: ["-p", "{{prompt}}", "--output-format", "stream-json", "--verbose"]
|
|
80
|
+
max_turns: 200
|
|
81
|
+
|
|
82
|
+
# =============================================================================
|
|
83
|
+
# Pull Request Configuration
|
|
84
|
+
# =============================================================================
|
|
85
|
+
|
|
86
|
+
pr:
|
|
87
|
+
codex_review: false # Set to true to add @codex review to PR body
|
|
88
|
+
base_branch: main
|
|
89
|
+
|
|
90
|
+
# =============================================================================
|
|
91
|
+
# Prompt Context
|
|
92
|
+
# =============================================================================
|
|
93
|
+
# Add project-specific patterns and instructions here.
|
|
94
|
+
# This context is added to ALL stage prompts.
|
|
95
|
+
|
|
96
|
+
prompt_context: |
|
|
97
|
+
## Project: {project_name}
|
|
98
|
+
|
|
99
|
+
Add your project-specific patterns, coding standards,
|
|
100
|
+
and instructions here.
|
|
101
|
+
|
|
102
|
+
# Per-stage prompt additions
|
|
103
|
+
stage_context:
|
|
104
|
+
DEV: |
|
|
105
|
+
# Add DEV-specific context here
|
|
106
|
+
TEST: |
|
|
107
|
+
# Add TEST-specific context here
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def generate_default_config(
|
|
112
|
+
project_name: str = "My Project",
|
|
113
|
+
stacks: list[dict[str, str]] | None = None,
|
|
114
|
+
) -> str:
|
|
115
|
+
"""Generate a default config.yaml content."""
|
|
116
|
+
if stacks is None:
|
|
117
|
+
stacks = [{"language": "python", "framework": None, "root": None}]
|
|
118
|
+
|
|
119
|
+
# Build stacks YAML
|
|
120
|
+
stacks_lines = []
|
|
121
|
+
for stack in stacks:
|
|
122
|
+
stacks_lines.append(f" - language: {stack['language']}")
|
|
123
|
+
if stack.get("framework"):
|
|
124
|
+
stacks_lines.append(f" framework: {stack['framework']}")
|
|
125
|
+
if stack.get("root"):
|
|
126
|
+
stacks_lines.append(f" root: {stack['root']}")
|
|
127
|
+
|
|
128
|
+
stacks_yaml = "\n".join(stacks_lines) if stacks_lines else " []"
|
|
129
|
+
|
|
130
|
+
return DEFAULT_CONFIG_YAML.format(
|
|
131
|
+
project_name=project_name,
|
|
132
|
+
stacks_yaml=stacks_yaml,
|
|
133
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration loading and management.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from galangal.config.schema import GalangalConfig
|
|
12
|
+
|
|
13
|
+
# Global config cache
|
|
14
|
+
_config: Optional[GalangalConfig] = None
|
|
15
|
+
_project_root: Optional[Path] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_project_root(start_path: Optional[Path] = None) -> Path:
|
|
19
|
+
"""
|
|
20
|
+
Find the project root by looking for .galangal/ directory.
|
|
21
|
+
Falls back to git root, then current directory.
|
|
22
|
+
"""
|
|
23
|
+
if start_path is None:
|
|
24
|
+
start_path = Path.cwd()
|
|
25
|
+
|
|
26
|
+
current = start_path.resolve()
|
|
27
|
+
|
|
28
|
+
# Walk up looking for .galangal/
|
|
29
|
+
while current != current.parent:
|
|
30
|
+
if (current / ".galangal").is_dir():
|
|
31
|
+
return current
|
|
32
|
+
if (current / ".git").is_dir():
|
|
33
|
+
# Found git root, use this as fallback
|
|
34
|
+
return current
|
|
35
|
+
current = current.parent
|
|
36
|
+
|
|
37
|
+
# Fall back to start path
|
|
38
|
+
return start_path.resolve()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_project_root() -> Path:
|
|
42
|
+
"""Get the cached project root."""
|
|
43
|
+
global _project_root
|
|
44
|
+
if _project_root is None:
|
|
45
|
+
_project_root = find_project_root()
|
|
46
|
+
return _project_root
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def set_project_root(path: Path) -> None:
|
|
50
|
+
"""Set the project root (for testing)."""
|
|
51
|
+
global _project_root, _config
|
|
52
|
+
_project_root = path.resolve()
|
|
53
|
+
_config = None # Reset config cache
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_config(project_root: Optional[Path] = None) -> GalangalConfig:
|
|
57
|
+
"""
|
|
58
|
+
Load configuration from .galangal/config.yaml.
|
|
59
|
+
Returns default config if file doesn't exist.
|
|
60
|
+
"""
|
|
61
|
+
global _config, _project_root
|
|
62
|
+
|
|
63
|
+
if project_root is not None:
|
|
64
|
+
_project_root = project_root.resolve()
|
|
65
|
+
elif _project_root is None:
|
|
66
|
+
_project_root = find_project_root()
|
|
67
|
+
|
|
68
|
+
config_path = _project_root / ".galangal" / "config.yaml"
|
|
69
|
+
|
|
70
|
+
if not config_path.exists():
|
|
71
|
+
_config = GalangalConfig()
|
|
72
|
+
return _config
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
with open(config_path) as f:
|
|
76
|
+
data = yaml.safe_load(f) or {}
|
|
77
|
+
|
|
78
|
+
_config = GalangalConfig.model_validate(data)
|
|
79
|
+
return _config
|
|
80
|
+
except Exception as e:
|
|
81
|
+
# Log warning but return defaults
|
|
82
|
+
print(f"Warning: Could not load config: {e}")
|
|
83
|
+
_config = GalangalConfig()
|
|
84
|
+
return _config
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_config() -> GalangalConfig:
|
|
88
|
+
"""Get the cached configuration, loading if necessary."""
|
|
89
|
+
global _config
|
|
90
|
+
if _config is None:
|
|
91
|
+
_config = load_config()
|
|
92
|
+
return _config
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_tasks_dir() -> Path:
|
|
96
|
+
"""Get the tasks directory path."""
|
|
97
|
+
config = get_config()
|
|
98
|
+
return get_project_root() / config.tasks_dir
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_done_dir() -> Path:
|
|
102
|
+
"""Get the done tasks directory path."""
|
|
103
|
+
return get_tasks_dir() / "done"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_active_file() -> Path:
|
|
107
|
+
"""Get the active task marker file path."""
|
|
108
|
+
return get_tasks_dir() / ".active"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_prompts_dir() -> Path:
|
|
112
|
+
"""Get the project prompts override directory."""
|
|
113
|
+
return get_project_root() / ".galangal" / "prompts"
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration schema using Pydantic models.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StackConfig(BaseModel):
|
|
10
|
+
"""Configuration for a technology stack."""
|
|
11
|
+
|
|
12
|
+
language: str = Field(description="Programming language (python, typescript, php, etc.)")
|
|
13
|
+
framework: Optional[str] = Field(default=None, description="Framework (fastapi, vite, symfony)")
|
|
14
|
+
root: Optional[str] = Field(default=None, description="Subdirectory for this stack")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProjectConfig(BaseModel):
|
|
18
|
+
"""Project-level configuration."""
|
|
19
|
+
|
|
20
|
+
name: str = Field(default="My Project", description="Project name")
|
|
21
|
+
stacks: list[StackConfig] = Field(default_factory=list, description="Technology stacks")
|
|
22
|
+
approver_name: Optional[str] = Field(default=None, description="Default approver name for plan approvals")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StageConfig(BaseModel):
|
|
26
|
+
"""Stage execution configuration."""
|
|
27
|
+
|
|
28
|
+
skip: list[str] = Field(default_factory=list, description="Stages to always skip")
|
|
29
|
+
timeout: int = Field(default=14400, description="Stage timeout in seconds (default: 4 hours)")
|
|
30
|
+
max_retries: int = Field(default=5, description="Max retries per stage")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PreflightCheck(BaseModel):
|
|
34
|
+
"""A single preflight check."""
|
|
35
|
+
|
|
36
|
+
name: str = Field(description="Check name for display")
|
|
37
|
+
command: Optional[str] = Field(default=None, description="Command to run")
|
|
38
|
+
path_exists: Optional[str] = Field(default=None, description="Path that must exist")
|
|
39
|
+
expect_empty: bool = Field(default=False, description="Pass if output is empty")
|
|
40
|
+
warn_only: bool = Field(default=False, description="Warn but don't fail the stage")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ValidationCommand(BaseModel):
|
|
44
|
+
"""A validation command configuration."""
|
|
45
|
+
|
|
46
|
+
name: str = Field(description="Command name for display")
|
|
47
|
+
command: str = Field(description="Shell command to run")
|
|
48
|
+
optional: bool = Field(default=False, description="Don't fail if this command fails")
|
|
49
|
+
allow_failure: bool = Field(default=False, description="Report but don't block on failure")
|
|
50
|
+
timeout: Optional[int] = Field(
|
|
51
|
+
default=None, description="Command timeout in seconds (overrides stage default)"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SkipCondition(BaseModel):
|
|
56
|
+
"""Condition for skipping a stage."""
|
|
57
|
+
|
|
58
|
+
no_files_match: Optional[str] = Field(
|
|
59
|
+
default=None, description="Skip if no files match this glob pattern"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class StageValidation(BaseModel):
|
|
64
|
+
"""Validation configuration for a single stage."""
|
|
65
|
+
|
|
66
|
+
skip_if: Optional[SkipCondition] = Field(default=None, description="Skip condition")
|
|
67
|
+
timeout: int = Field(
|
|
68
|
+
default=300, description="Default timeout in seconds for validation commands"
|
|
69
|
+
)
|
|
70
|
+
commands: list[ValidationCommand] = Field(
|
|
71
|
+
default_factory=list, description="Commands to run"
|
|
72
|
+
)
|
|
73
|
+
checks: list[PreflightCheck] = Field(
|
|
74
|
+
default_factory=list, description="Preflight checks (for preflight stage)"
|
|
75
|
+
)
|
|
76
|
+
pass_marker: Optional[str] = Field(
|
|
77
|
+
default=None, description="Text marker indicating pass (for AI stages)"
|
|
78
|
+
)
|
|
79
|
+
fail_marker: Optional[str] = Field(
|
|
80
|
+
default=None, description="Text marker indicating failure (for AI stages)"
|
|
81
|
+
)
|
|
82
|
+
artifact: Optional[str] = Field(
|
|
83
|
+
default=None, description="Artifact file to check for markers"
|
|
84
|
+
)
|
|
85
|
+
artifacts_required: list[str] = Field(
|
|
86
|
+
default_factory=list, description="Required artifact files"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ValidationConfig(BaseModel):
|
|
91
|
+
"""All stage validations."""
|
|
92
|
+
|
|
93
|
+
preflight: StageValidation = Field(default_factory=StageValidation)
|
|
94
|
+
migration: StageValidation = Field(default_factory=StageValidation)
|
|
95
|
+
test: StageValidation = Field(default_factory=StageValidation)
|
|
96
|
+
contract: StageValidation = Field(default_factory=StageValidation)
|
|
97
|
+
qa: StageValidation = Field(default_factory=StageValidation)
|
|
98
|
+
security: StageValidation = Field(default_factory=StageValidation)
|
|
99
|
+
review: StageValidation = Field(default_factory=StageValidation)
|
|
100
|
+
docs: StageValidation = Field(default_factory=StageValidation)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class AIBackendConfig(BaseModel):
|
|
104
|
+
"""Configuration for an AI backend."""
|
|
105
|
+
|
|
106
|
+
command: str = Field(description="Command to invoke the AI")
|
|
107
|
+
args: list[str] = Field(default_factory=list, description="Command arguments")
|
|
108
|
+
max_turns: int = Field(default=200, description="Maximum conversation turns")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AIConfig(BaseModel):
|
|
112
|
+
"""AI backend configuration."""
|
|
113
|
+
|
|
114
|
+
default: str = Field(default="claude", description="Default backend")
|
|
115
|
+
backends: dict[str, AIBackendConfig] = Field(
|
|
116
|
+
default_factory=lambda: {
|
|
117
|
+
"claude": AIBackendConfig(
|
|
118
|
+
command="claude",
|
|
119
|
+
args=["-p", "{prompt}", "--output-format", "stream-json", "--verbose"],
|
|
120
|
+
max_turns=200,
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
stage_backends: dict[str, str] = Field(
|
|
125
|
+
default_factory=dict, description="Per-stage backend overrides"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class PRConfig(BaseModel):
|
|
130
|
+
"""Pull request configuration."""
|
|
131
|
+
|
|
132
|
+
codex_review: bool = Field(
|
|
133
|
+
default=False, description="Add @codex review to PR body"
|
|
134
|
+
)
|
|
135
|
+
base_branch: str = Field(default="main", description="Base branch for PRs")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class GalangalConfig(BaseModel):
|
|
139
|
+
"""Root configuration model."""
|
|
140
|
+
|
|
141
|
+
project: ProjectConfig = Field(default_factory=ProjectConfig)
|
|
142
|
+
tasks_dir: str = Field(default="galangal-tasks", description="Task storage directory")
|
|
143
|
+
branch_pattern: str = Field(
|
|
144
|
+
default="task/{task_name}", description="Git branch naming pattern"
|
|
145
|
+
)
|
|
146
|
+
stages: StageConfig = Field(default_factory=StageConfig)
|
|
147
|
+
validation: ValidationConfig = Field(default_factory=ValidationConfig)
|
|
148
|
+
ai: AIConfig = Field(default_factory=AIConfig)
|
|
149
|
+
pr: PRConfig = Field(default_factory=PRConfig)
|
|
150
|
+
prompt_context: str = Field(
|
|
151
|
+
default="", description="Global context added to all prompts"
|
|
152
|
+
)
|
|
153
|
+
stage_context: dict[str, str] = Field(
|
|
154
|
+
default_factory=dict, description="Per-stage prompt context"
|
|
155
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Core workflow components."""
|
|
2
|
+
|
|
3
|
+
from galangal.core.state import Stage, TaskType, WorkflowState, STAGE_ORDER
|
|
4
|
+
from galangal.core.artifacts import artifact_exists, read_artifact, write_artifact
|
|
5
|
+
from galangal.core.tasks import get_active_task, set_active_task, list_tasks
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Stage",
|
|
9
|
+
"TaskType",
|
|
10
|
+
"WorkflowState",
|
|
11
|
+
"STAGE_ORDER",
|
|
12
|
+
"artifact_exists",
|
|
13
|
+
"read_artifact",
|
|
14
|
+
"write_artifact",
|
|
15
|
+
"get_active_task",
|
|
16
|
+
"set_active_task",
|
|
17
|
+
"list_tasks",
|
|
18
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Artifact management - reading and writing task artifacts.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from galangal.config.loader import get_project_root
|
|
10
|
+
from galangal.core.state import get_task_dir
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def artifact_path(name: str, task_name: Optional[str] = None) -> Path:
|
|
14
|
+
"""Get path to an artifact file."""
|
|
15
|
+
from galangal.core.tasks import get_active_task
|
|
16
|
+
|
|
17
|
+
if task_name is None:
|
|
18
|
+
task_name = get_active_task()
|
|
19
|
+
if task_name is None:
|
|
20
|
+
raise ValueError("No active task")
|
|
21
|
+
return get_task_dir(task_name) / name
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def artifact_exists(name: str, task_name: Optional[str] = None) -> bool:
|
|
25
|
+
"""Check if an artifact exists."""
|
|
26
|
+
try:
|
|
27
|
+
return artifact_path(name, task_name).exists()
|
|
28
|
+
except ValueError:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_artifact(name: str, task_name: Optional[str] = None) -> Optional[str]:
|
|
33
|
+
"""Read an artifact file."""
|
|
34
|
+
try:
|
|
35
|
+
path = artifact_path(name, task_name)
|
|
36
|
+
if path.exists():
|
|
37
|
+
return path.read_text()
|
|
38
|
+
except ValueError:
|
|
39
|
+
pass
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def write_artifact(name: str, content: str, task_name: Optional[str] = None) -> None:
|
|
44
|
+
"""Write an artifact file."""
|
|
45
|
+
path = artifact_path(name, task_name)
|
|
46
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
path.write_text(content)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_command(
|
|
51
|
+
cmd: list[str], cwd: Optional[Path] = None, timeout: int = 300
|
|
52
|
+
) -> tuple[int, str, str]:
|
|
53
|
+
"""Run a command and return (exit_code, stdout, stderr)."""
|
|
54
|
+
try:
|
|
55
|
+
result = subprocess.run(
|
|
56
|
+
cmd,
|
|
57
|
+
cwd=cwd or get_project_root(),
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
timeout=timeout,
|
|
61
|
+
)
|
|
62
|
+
return result.returncode, result.stdout, result.stderr
|
|
63
|
+
except subprocess.TimeoutExpired:
|
|
64
|
+
return -1, "", f"Command timed out after {timeout}s"
|
|
65
|
+
except Exception as e:
|
|
66
|
+
return -1, "", str(e)
|