flywheel-bootstrap-staging 0.1.9.202601272054__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 ADDED
@@ -0,0 +1,3 @@
1
+ """Bootstrap package."""
2
+
3
+ __all__ = []
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