flywheel-bootstrap-staging 0.1.9.202601271835__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.
- bootstrap/__init__.py +3 -0
- bootstrap/__main__.py +48 -0
- bootstrap/artifacts.py +101 -0
- bootstrap/config_loader.py +122 -0
- bootstrap/constants.py +20 -0
- bootstrap/git_ops.py +324 -0
- bootstrap/install.py +129 -0
- bootstrap/orchestrator.py +797 -0
- bootstrap/payload.py +119 -0
- bootstrap/prompts.py +79 -0
- bootstrap/py.typed +1 -0
- bootstrap/runner.py +145 -0
- bootstrap/telemetry.py +147 -0
- flywheel_bootstrap_staging-0.1.9.202601271835.dist-info/METADATA +94 -0
- flywheel_bootstrap_staging-0.1.9.202601271835.dist-info/RECORD +17 -0
- flywheel_bootstrap_staging-0.1.9.202601271835.dist-info/WHEEL +4 -0
- flywheel_bootstrap_staging-0.1.9.202601271835.dist-info/entry_points.txt +2 -0
bootstrap/__init__.py
ADDED
bootstrap/__main__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""CLI entry point for the bootstrap flow.
|
|
2
|
+
|
|
3
|
+
Usage (placeholder):
|
|
4
|
+
python -m bootstrap --run-id <id> --token <token> --config /path/to/config.toml
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from bootstrap.orchestrator import BootstrapOrchestrator, build_config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
16
|
+
parser = argparse.ArgumentParser(description="Flywheel BYOC bootstrapper")
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--run-id",
|
|
19
|
+
help="Run identifier issued by the Flywheel backend (falls back to FLYWHEEL_RUN_ID)",
|
|
20
|
+
default=None,
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--token",
|
|
24
|
+
help="Capability token for authenticating to the backend (or FLYWHEEL_RUN_TOKEN)",
|
|
25
|
+
default=None,
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--server",
|
|
29
|
+
help="Backend base URL (default: http://localhost:8000 or FLYWHEEL_SERVER)",
|
|
30
|
+
default=None,
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--config",
|
|
34
|
+
help="Path to the Codex config.toml file",
|
|
35
|
+
required=True,
|
|
36
|
+
)
|
|
37
|
+
return parser.parse_args(argv)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main(argv: list[str] | None = None) -> int:
|
|
41
|
+
args = _parse_args(argv or sys.argv[1:])
|
|
42
|
+
config = build_config(args)
|
|
43
|
+
orchestrator = BootstrapOrchestrator(config)
|
|
44
|
+
return orchestrator.run()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
raise SystemExit(main())
|
bootstrap/artifacts.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Artifact manifest helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Mapping, Sequence
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ManifestStatus(Enum):
|
|
16
|
+
"""Outcome of reading the artifact manifest."""
|
|
17
|
+
|
|
18
|
+
MISSING = "missing"
|
|
19
|
+
VALID = "valid"
|
|
20
|
+
MALFORMED = "malformed"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ManifestResult:
|
|
25
|
+
"""Result of reading the artifact manifest, with diagnostic info."""
|
|
26
|
+
|
|
27
|
+
status: ManifestStatus
|
|
28
|
+
artifacts: Sequence[Mapping[str, object]]
|
|
29
|
+
error: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_manifest(manifest_path: Path) -> ManifestResult:
|
|
33
|
+
"""Load artifact entries from the manifest path.
|
|
34
|
+
|
|
35
|
+
Tolerant of common LLM output variations:
|
|
36
|
+
- A well-formed JSON list is returned as-is.
|
|
37
|
+
- A dict wrapping a list (e.g. ``{"artifacts": [...]}``) is unwrapped.
|
|
38
|
+
- A single artifact dict is wrapped in a list.
|
|
39
|
+
- Truncated / invalid JSON is reported as malformed.
|
|
40
|
+
- Non-dict, non-list scalars are reported as malformed.
|
|
41
|
+
|
|
42
|
+
Returns a ``ManifestResult`` carrying the parsed artifacts, the outcome
|
|
43
|
+
status, and an optional human-readable error description for feedback.
|
|
44
|
+
"""
|
|
45
|
+
if not manifest_path.exists():
|
|
46
|
+
return ManifestResult(status=ManifestStatus.MISSING, artifacts=[])
|
|
47
|
+
raw = manifest_path.read_text(encoding="utf-8")
|
|
48
|
+
if not raw.strip():
|
|
49
|
+
msg = "artifact manifest file is empty"
|
|
50
|
+
logger.warning("%s: %s", msg, manifest_path)
|
|
51
|
+
return ManifestResult(status=ManifestStatus.MALFORMED, artifacts=[], error=msg)
|
|
52
|
+
try:
|
|
53
|
+
data = json.loads(raw)
|
|
54
|
+
except json.JSONDecodeError as exc:
|
|
55
|
+
msg = f"artifact manifest contains invalid JSON: {exc}"
|
|
56
|
+
logger.warning("%s: %s", msg, manifest_path)
|
|
57
|
+
return ManifestResult(status=ManifestStatus.MALFORMED, artifacts=[], error=msg)
|
|
58
|
+
return _coerce_manifest(data, manifest_path)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _coerce_manifest(data: object, manifest_path: Path) -> ManifestResult:
|
|
62
|
+
"""Best-effort coercion of parsed JSON into a list of artifact dicts."""
|
|
63
|
+
if isinstance(data, list):
|
|
64
|
+
return ManifestResult(status=ManifestStatus.VALID, artifacts=data)
|
|
65
|
+
if isinstance(data, dict):
|
|
66
|
+
return _unwrap_dict(data, manifest_path)
|
|
67
|
+
msg = f"artifact manifest is a {type(data).__name__}, expected a JSON list"
|
|
68
|
+
logger.warning("%s: %s", msg, manifest_path)
|
|
69
|
+
return ManifestResult(status=ManifestStatus.MALFORMED, artifacts=[], error=msg)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _unwrap_dict(data: dict[str, object], manifest_path: Path) -> ManifestResult:
|
|
73
|
+
"""Extract an artifact list from a dict, or treat it as a single artifact."""
|
|
74
|
+
# If the dict itself looks like an artifact, treat it as one.
|
|
75
|
+
# Check this BEFORE scanning for nested lists — a single artifact dict
|
|
76
|
+
# like {"artifact_type": "text", "payload": {"items": [...]}} must not
|
|
77
|
+
# have its nested list mistakenly extracted.
|
|
78
|
+
if "artifact_type" in data:
|
|
79
|
+
msg = "artifact manifest is a single artifact dict, wrapping in list"
|
|
80
|
+
logger.warning("%s: %s", msg, manifest_path)
|
|
81
|
+
return ManifestResult(
|
|
82
|
+
status=ManifestStatus.MALFORMED, artifacts=[data], error=msg
|
|
83
|
+
)
|
|
84
|
+
# Prefer the "artifacts" key if present and is a list.
|
|
85
|
+
if "artifacts" in data and isinstance(data["artifacts"], list):
|
|
86
|
+
msg = "artifact manifest wrapped in dict with 'artifacts' key, unwrapping"
|
|
87
|
+
logger.warning("%s: %s", msg, manifest_path)
|
|
88
|
+
return ManifestResult(
|
|
89
|
+
status=ManifestStatus.MALFORMED, artifacts=data["artifacts"], error=msg
|
|
90
|
+
)
|
|
91
|
+
# Fall back to the first value that is a list.
|
|
92
|
+
for key, value in data.items():
|
|
93
|
+
if isinstance(value, list):
|
|
94
|
+
msg = f"artifact manifest wrapped in dict with '{key}' key, unwrapping"
|
|
95
|
+
logger.warning("%s: %s", msg, manifest_path)
|
|
96
|
+
return ManifestResult(
|
|
97
|
+
status=ManifestStatus.MALFORMED, artifacts=value, error=msg
|
|
98
|
+
)
|
|
99
|
+
msg = "artifact manifest is a dict with no recognisable artifact data"
|
|
100
|
+
logger.warning("%s: %s", msg, manifest_path)
|
|
101
|
+
return ManifestResult(status=ManifestStatus.MALFORMED, artifacts=[], error=msg)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Codex config parsing helpers (skeleton)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
import tomllib
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class UserConfig:
|
|
13
|
+
"""Parsed subset of Codex config relevant to bootstrap."""
|
|
14
|
+
|
|
15
|
+
raw: Mapping[str, Any]
|
|
16
|
+
working_dir: Path | None
|
|
17
|
+
sandbox_mode: str | None
|
|
18
|
+
approval_policy: str | None
|
|
19
|
+
oss_provider: str | None
|
|
20
|
+
writable_roots: tuple[Path, ...]
|
|
21
|
+
workspace_instructions: str
|
|
22
|
+
instructions_source: str
|
|
23
|
+
warnings: tuple[str, ...] = ()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_codex_config(path: Path) -> UserConfig:
|
|
27
|
+
"""Load and extract relevant fields from the user's Codex config."""
|
|
28
|
+
|
|
29
|
+
with path.open("rb") as fp:
|
|
30
|
+
data = tomllib.load(fp)
|
|
31
|
+
|
|
32
|
+
flywheel_raw = data.get("flywheel")
|
|
33
|
+
flywheel_section: Mapping[str, Any] = (
|
|
34
|
+
flywheel_raw if isinstance(flywheel_raw, Mapping) else {}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
inline_instructions = _get_str(flywheel_section, "workspace_instructions")
|
|
38
|
+
instructions_file = _get_path(flywheel_section, "workspace_instructions_file", path)
|
|
39
|
+
|
|
40
|
+
warnings: list[str] = []
|
|
41
|
+
if instructions_file is not None and inline_instructions:
|
|
42
|
+
warnings.append(
|
|
43
|
+
"workspace_instructions ignored because workspace_instructions_file is set"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if instructions_file is not None:
|
|
47
|
+
try:
|
|
48
|
+
instructions_text = instructions_file.read_text(encoding="utf-8").strip()
|
|
49
|
+
except FileNotFoundError as exc:
|
|
50
|
+
raise SystemExit(
|
|
51
|
+
f"workspace_instructions_file not found: {instructions_file}"
|
|
52
|
+
) from exc
|
|
53
|
+
if not instructions_text:
|
|
54
|
+
raise SystemExit(
|
|
55
|
+
f"workspace_instructions_file is empty: {instructions_file}"
|
|
56
|
+
)
|
|
57
|
+
source = "file"
|
|
58
|
+
else:
|
|
59
|
+
instructions_text = inline_instructions.strip() if inline_instructions else ""
|
|
60
|
+
source = "inline"
|
|
61
|
+
|
|
62
|
+
if not instructions_text:
|
|
63
|
+
raise SystemExit(
|
|
64
|
+
"workspace instructions are required; set [flywheel].workspace_instructions "
|
|
65
|
+
"or [flywheel].workspace_instructions_file"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Best-effort extraction; Codex config schema may evolve.
|
|
69
|
+
working_dir = _get_path(data, "cd") or _get_path(data, "workspace_dir")
|
|
70
|
+
sandbox_mode = (
|
|
71
|
+
data.get("sandbox_mode") if isinstance(data.get("sandbox_mode"), str) else None
|
|
72
|
+
)
|
|
73
|
+
approval_policy = (
|
|
74
|
+
data.get("approval_policy")
|
|
75
|
+
if isinstance(data.get("approval_policy"), str)
|
|
76
|
+
else None
|
|
77
|
+
)
|
|
78
|
+
oss_provider = (
|
|
79
|
+
data.get("oss_provider") if isinstance(data.get("oss_provider"), str) else None
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
writable_roots: tuple[Path, ...] = tuple()
|
|
83
|
+
sandbox_write = data.get("sandbox_workspace_write")
|
|
84
|
+
if isinstance(sandbox_write, dict):
|
|
85
|
+
roots = sandbox_write.get("writable_roots")
|
|
86
|
+
if isinstance(roots, list):
|
|
87
|
+
writable_roots = tuple(
|
|
88
|
+
Path(str(r)).expanduser().resolve() for r in roots if isinstance(r, str)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return UserConfig(
|
|
92
|
+
raw=data,
|
|
93
|
+
working_dir=working_dir,
|
|
94
|
+
sandbox_mode=sandbox_mode,
|
|
95
|
+
approval_policy=approval_policy,
|
|
96
|
+
oss_provider=oss_provider,
|
|
97
|
+
writable_roots=writable_roots,
|
|
98
|
+
workspace_instructions=instructions_text,
|
|
99
|
+
instructions_source=source,
|
|
100
|
+
warnings=tuple(warnings),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _get_path(
|
|
105
|
+
data: Mapping[str, Any], key: str, relative_to: Path | None = None
|
|
106
|
+
) -> Path | None:
|
|
107
|
+
value = data.get(key)
|
|
108
|
+
if isinstance(value, str) and value:
|
|
109
|
+
path = Path(value).expanduser()
|
|
110
|
+
if not path.is_absolute() and relative_to is not None:
|
|
111
|
+
path = (relative_to.parent / path).resolve()
|
|
112
|
+
else:
|
|
113
|
+
path = path.resolve()
|
|
114
|
+
return path
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _get_str(data: Mapping[str, Any], key: str) -> str | None:
|
|
119
|
+
value = data.get(key)
|
|
120
|
+
if isinstance(value, str) and value:
|
|
121
|
+
return value
|
|
122
|
+
return None
|
bootstrap/constants.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Shared constants for the bootstrap flow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
DEFAULT_SERVER_URL = "http://localhost:8000"
|
|
8
|
+
DEFAULT_RUN_ROOT = Path.home() / ".flywheel" / "runs"
|
|
9
|
+
DEFAULT_ARTIFACT_MANIFEST = "flywheel_artifacts.json"
|
|
10
|
+
HEARTBEAT_INTERVAL_SECONDS = 30
|
|
11
|
+
MAX_ARTIFACT_RETRIES = 2
|
|
12
|
+
|
|
13
|
+
# Environment variables that let the backend command override defaults.
|
|
14
|
+
ENV_SERVER_URL = "FLYWHEEL_SERVER"
|
|
15
|
+
ENV_RUN_ID = "FLYWHEEL_RUN_ID"
|
|
16
|
+
ENV_RUN_TOKEN = "FLYWHEEL_RUN_TOKEN"
|
|
17
|
+
|
|
18
|
+
# Codex download
|
|
19
|
+
DEFAULT_CODEX_VERSION = None # latest
|
|
20
|
+
CODEX_RELEASE_BASE = "https://github.com/openai/codex/releases/latest/download"
|
bootstrap/git_ops.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Git operations for code persistence.
|
|
2
|
+
|
|
3
|
+
This module handles:
|
|
4
|
+
- Cloning repositories with authentication
|
|
5
|
+
- Branch creation and checkout
|
|
6
|
+
- Committing and pushing changes
|
|
7
|
+
|
|
8
|
+
IMPORTANT: The GitHub token is used for git operations via HTTPS.
|
|
9
|
+
It should never be exposed to the AI model - only the harness uses it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Callable
|
|
19
|
+
|
|
20
|
+
from bootstrap.payload import RepoContext
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class GitConfig:
|
|
25
|
+
"""Configuration for git operations."""
|
|
26
|
+
|
|
27
|
+
workspace: Path
|
|
28
|
+
repo_context: RepoContext
|
|
29
|
+
github_token: str
|
|
30
|
+
log_fn: Callable[[str, str], None] | None = None
|
|
31
|
+
|
|
32
|
+
def log(self, level: str, message: str) -> None:
|
|
33
|
+
"""Log a message using the provided log function."""
|
|
34
|
+
if self.log_fn:
|
|
35
|
+
self.log_fn(level, message)
|
|
36
|
+
else:
|
|
37
|
+
print(f"[git:{level}] {message}", file=sys.stderr)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _run_git(
|
|
41
|
+
args: list[str],
|
|
42
|
+
cwd: Path,
|
|
43
|
+
env: dict[str, str] | None = None,
|
|
44
|
+
capture_output: bool = True,
|
|
45
|
+
) -> subprocess.CompletedProcess:
|
|
46
|
+
"""Run a git command and return the result."""
|
|
47
|
+
import os
|
|
48
|
+
|
|
49
|
+
full_env = os.environ.copy()
|
|
50
|
+
if env:
|
|
51
|
+
full_env.update(env)
|
|
52
|
+
|
|
53
|
+
return subprocess.run(
|
|
54
|
+
["git"] + args,
|
|
55
|
+
cwd=cwd,
|
|
56
|
+
capture_output=capture_output,
|
|
57
|
+
text=True,
|
|
58
|
+
env=full_env,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def setup_git_credentials(config: GitConfig) -> bool:
|
|
63
|
+
"""Configure git to use the GitHub token for authentication.
|
|
64
|
+
|
|
65
|
+
Uses the credential helper to store the token temporarily.
|
|
66
|
+
The token is only valid for 1 hour.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
True if setup succeeded, False otherwise.
|
|
70
|
+
"""
|
|
71
|
+
workspace = config.workspace
|
|
72
|
+
token = config.github_token
|
|
73
|
+
|
|
74
|
+
# Configure git to use the token via credential helper
|
|
75
|
+
# We use a simple approach: configure the remote URL with the token embedded
|
|
76
|
+
# This is safe because the workspace is ephemeral and the token is short-lived
|
|
77
|
+
|
|
78
|
+
# First, configure git user for commits
|
|
79
|
+
result = _run_git(
|
|
80
|
+
["config", "user.email", "flywheel@example.com"],
|
|
81
|
+
cwd=workspace,
|
|
82
|
+
)
|
|
83
|
+
if result.returncode != 0:
|
|
84
|
+
config.log("warning", f"Failed to set git email: {result.stderr}")
|
|
85
|
+
|
|
86
|
+
result = _run_git(
|
|
87
|
+
["config", "user.name", "Flywheel"],
|
|
88
|
+
cwd=workspace,
|
|
89
|
+
)
|
|
90
|
+
if result.returncode != 0:
|
|
91
|
+
config.log("warning", f"Failed to set git name: {result.stderr}")
|
|
92
|
+
|
|
93
|
+
# Configure credential helper to cache the token
|
|
94
|
+
# We use the 'store' helper with a file in the workspace
|
|
95
|
+
credential_file = workspace / ".git-credentials"
|
|
96
|
+
repo_url = config.repo_context.repo_url
|
|
97
|
+
|
|
98
|
+
# Write credentials in the format expected by git-credential-store
|
|
99
|
+
# https://x-access-token:TOKEN@github.com
|
|
100
|
+
if "github.com" in repo_url:
|
|
101
|
+
credential_line = f"https://x-access-token:{token}@github.com\n"
|
|
102
|
+
try:
|
|
103
|
+
credential_file.write_text(credential_line)
|
|
104
|
+
credential_file.chmod(0o600) # Restrict permissions
|
|
105
|
+
|
|
106
|
+
result = _run_git(
|
|
107
|
+
["config", "credential.helper", f"store --file={credential_file}"],
|
|
108
|
+
cwd=workspace,
|
|
109
|
+
)
|
|
110
|
+
if result.returncode != 0:
|
|
111
|
+
config.log(
|
|
112
|
+
"warning", f"Failed to set credential helper: {result.stderr}"
|
|
113
|
+
)
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
config.log("info", "Git credentials configured")
|
|
117
|
+
return True
|
|
118
|
+
except Exception as e:
|
|
119
|
+
config.log("error", f"Failed to write credentials: {e}")
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def clone_repository(config: GitConfig) -> bool:
|
|
126
|
+
"""Clone the repository to the workspace.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
True if clone succeeded, False otherwise.
|
|
130
|
+
"""
|
|
131
|
+
repo = config.repo_context
|
|
132
|
+
workspace = config.workspace
|
|
133
|
+
token = config.github_token
|
|
134
|
+
|
|
135
|
+
# Build authenticated URL
|
|
136
|
+
# Format: https://x-access-token:TOKEN@github.com/owner/repo.git
|
|
137
|
+
auth_url = repo.repo_url.replace(
|
|
138
|
+
"https://github.com", f"https://x-access-token:{token}@github.com"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
config.log("info", f"Cloning repository {repo.repo_owner}/{repo.repo_name}")
|
|
142
|
+
|
|
143
|
+
# Clone to a temp directory first, then move contents to workspace
|
|
144
|
+
# This handles the case where workspace might have existing content
|
|
145
|
+
result = _run_git(
|
|
146
|
+
["clone", "--depth=1", "-b", repo.base_branch, auth_url, "."],
|
|
147
|
+
cwd=workspace,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if result.returncode != 0:
|
|
151
|
+
config.log("error", f"Failed to clone repository: {result.stderr}")
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
config.log("info", "Repository cloned successfully")
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def setup_branch(config: GitConfig) -> bool:
|
|
159
|
+
"""Create or checkout the experiment branch.
|
|
160
|
+
|
|
161
|
+
If the branch already exists on remote, we check it out.
|
|
162
|
+
Otherwise, we create it from the base branch.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
True if branch setup succeeded, False otherwise.
|
|
166
|
+
"""
|
|
167
|
+
repo = config.repo_context
|
|
168
|
+
workspace = config.workspace
|
|
169
|
+
|
|
170
|
+
branch_name = repo.branch_name
|
|
171
|
+
base_branch = repo.base_branch
|
|
172
|
+
|
|
173
|
+
# Fetch all branches
|
|
174
|
+
result = _run_git(["fetch", "--all"], cwd=workspace)
|
|
175
|
+
if result.returncode != 0:
|
|
176
|
+
config.log("warning", f"Failed to fetch: {result.stderr}")
|
|
177
|
+
|
|
178
|
+
# Check if branch exists on remote
|
|
179
|
+
result = _run_git(
|
|
180
|
+
["ls-remote", "--heads", "origin", branch_name],
|
|
181
|
+
cwd=workspace,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if result.returncode == 0 and branch_name in result.stdout:
|
|
185
|
+
# Branch exists, check it out
|
|
186
|
+
config.log("info", f"Checking out existing branch: {branch_name}")
|
|
187
|
+
result = _run_git(
|
|
188
|
+
["checkout", "-B", branch_name, f"origin/{branch_name}"],
|
|
189
|
+
cwd=workspace,
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
# Branch doesn't exist, create from base
|
|
193
|
+
config.log("info", f"Creating new branch: {branch_name} from {base_branch}")
|
|
194
|
+
result = _run_git(
|
|
195
|
+
["checkout", "-b", branch_name],
|
|
196
|
+
cwd=workspace,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if result.returncode != 0:
|
|
200
|
+
config.log("error", f"Failed to setup branch: {result.stderr}")
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
config.log("info", f"Branch {branch_name} is ready")
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def commit_changes(config: GitConfig, message: str) -> bool:
|
|
208
|
+
"""Commit any changes in the workspace.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
True if commit succeeded (or no changes to commit), False on error.
|
|
212
|
+
"""
|
|
213
|
+
workspace = config.workspace
|
|
214
|
+
|
|
215
|
+
# Check if there are any changes
|
|
216
|
+
result = _run_git(["status", "--porcelain"], cwd=workspace)
|
|
217
|
+
if result.returncode != 0:
|
|
218
|
+
config.log("error", f"Failed to check status: {result.stderr}")
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
if not result.stdout.strip():
|
|
222
|
+
config.log("info", "No changes to commit")
|
|
223
|
+
return True
|
|
224
|
+
|
|
225
|
+
# Stage all changes
|
|
226
|
+
result = _run_git(["add", "-A"], cwd=workspace)
|
|
227
|
+
if result.returncode != 0:
|
|
228
|
+
config.log("error", f"Failed to stage changes: {result.stderr}")
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
# Commit
|
|
232
|
+
result = _run_git(
|
|
233
|
+
["commit", "-m", message],
|
|
234
|
+
cwd=workspace,
|
|
235
|
+
)
|
|
236
|
+
if result.returncode != 0:
|
|
237
|
+
config.log("error", f"Failed to commit: {result.stderr}")
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
config.log("info", f"Changes committed: {message}")
|
|
241
|
+
return True
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def push_changes(config: GitConfig) -> bool:
|
|
245
|
+
"""Push committed changes to the remote.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
True if push succeeded, False otherwise.
|
|
249
|
+
"""
|
|
250
|
+
workspace = config.workspace
|
|
251
|
+
branch_name = config.repo_context.branch_name
|
|
252
|
+
|
|
253
|
+
config.log("info", f"Pushing to origin/{branch_name}")
|
|
254
|
+
|
|
255
|
+
result = _run_git(
|
|
256
|
+
["push", "-u", "origin", branch_name],
|
|
257
|
+
cwd=workspace,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if result.returncode != 0:
|
|
261
|
+
config.log("error", f"Failed to push: {result.stderr}")
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
config.log("info", "Push successful")
|
|
265
|
+
return True
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def get_head_sha(workspace: Path) -> str | None:
|
|
269
|
+
"""Get the SHA of the current HEAD commit.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
The commit SHA or None if not a git repo.
|
|
273
|
+
"""
|
|
274
|
+
result = _run_git(["rev-parse", "HEAD"], cwd=workspace)
|
|
275
|
+
if result.returncode == 0:
|
|
276
|
+
return result.stdout.strip()
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def initialize_repo(config: GitConfig) -> bool:
|
|
281
|
+
"""Initialize a fresh repository for the experiment.
|
|
282
|
+
|
|
283
|
+
This is the main entry point for setting up code persistence.
|
|
284
|
+
It handles:
|
|
285
|
+
1. Cloning the repository
|
|
286
|
+
2. Setting up git credentials
|
|
287
|
+
3. Creating/checking out the branch
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
True if initialization succeeded, False otherwise.
|
|
291
|
+
"""
|
|
292
|
+
if not clone_repository(config):
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
if not setup_git_credentials(config):
|
|
296
|
+
return False
|
|
297
|
+
|
|
298
|
+
if not setup_branch(config):
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
return True
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def finalize_repo(config: GitConfig, run_id: str) -> bool:
|
|
305
|
+
"""Finalize the repository after the experiment completes.
|
|
306
|
+
|
|
307
|
+
This commits and pushes any changes made during the run.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
config: Git configuration
|
|
311
|
+
run_id: The run ID for the commit message
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
True if finalization succeeded, False otherwise.
|
|
315
|
+
"""
|
|
316
|
+
commit_message = f"Flywheel experiment run: {run_id}"
|
|
317
|
+
|
|
318
|
+
if not commit_changes(config, commit_message):
|
|
319
|
+
return False
|
|
320
|
+
|
|
321
|
+
if not push_changes(config):
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
return True
|