flywheel-bootstrap-staging 0.1.9.202601291439__tar.gz

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 (24) hide show
  1. flywheel_bootstrap_staging-0.1.9.202601291439/.gitignore +222 -0
  2. flywheel_bootstrap_staging-0.1.9.202601291439/PKG-INFO +94 -0
  3. flywheel_bootstrap_staging-0.1.9.202601291439/README.md +78 -0
  4. flywheel_bootstrap_staging-0.1.9.202601291439/bootstrap/__init__.py +3 -0
  5. flywheel_bootstrap_staging-0.1.9.202601291439/bootstrap/__main__.py +48 -0
  6. flywheel_bootstrap_staging-0.1.9.202601291439/bootstrap/artifacts.py +101 -0
  7. flywheel_bootstrap_staging-0.1.9.202601291439/bootstrap/config_loader.py +122 -0
  8. flywheel_bootstrap_staging-0.1.9.202601291439/bootstrap/constants.py +20 -0
  9. flywheel_bootstrap_staging-0.1.9.202601291439/bootstrap/git_ops.py +324 -0
  10. flywheel_bootstrap_staging-0.1.9.202601291439/bootstrap/install.py +129 -0
  11. flywheel_bootstrap_staging-0.1.9.202601291439/bootstrap/orchestrator.py +797 -0
  12. flywheel_bootstrap_staging-0.1.9.202601291439/bootstrap/payload.py +119 -0
  13. flywheel_bootstrap_staging-0.1.9.202601291439/bootstrap/prompts.py +79 -0
  14. flywheel_bootstrap_staging-0.1.9.202601291439/bootstrap/py.typed +1 -0
  15. flywheel_bootstrap_staging-0.1.9.202601291439/bootstrap/runner.py +145 -0
  16. flywheel_bootstrap_staging-0.1.9.202601291439/bootstrap/telemetry.py +147 -0
  17. flywheel_bootstrap_staging-0.1.9.202601291439/bootstrap.sh +38 -0
  18. flywheel_bootstrap_staging-0.1.9.202601291439/examples/config.example.toml +36 -0
  19. flywheel_bootstrap_staging-0.1.9.202601291439/pyproject.toml +44 -0
  20. flywheel_bootstrap_staging-0.1.9.202601291439/tests/test_artifacts.py +154 -0
  21. flywheel_bootstrap_staging-0.1.9.202601291439/tests/test_entrypoint.py +12 -0
  22. flywheel_bootstrap_staging-0.1.9.202601291439/tests/test_git_ops.py +277 -0
  23. flywheel_bootstrap_staging-0.1.9.202601291439/tests/test_orchestrator.py +416 -0
  24. flywheel_bootstrap_staging-0.1.9.202601291439/uv.lock +255 -0
@@ -0,0 +1,222 @@
1
+ # repo-specific
2
+ data/
3
+ .zshrc
4
+ .moebial/
5
+ # Byte-compiled / optimized / DLL files
6
+ .DS_Store
7
+ __pycache__/
8
+ *.py[codz]
9
+ *$py.class
10
+
11
+ # C extensions
12
+ *.so
13
+ config_new.toml
14
+ # Distribution / packaging
15
+ .Python
16
+ build/
17
+ develop-eggs/
18
+ dist/
19
+ downloads/
20
+ eggs/
21
+ .eggs/
22
+ lib/
23
+ lib64/
24
+ parts/
25
+ sdist/
26
+ var/
27
+ wheels/
28
+ share/python-wheels/
29
+ *.egg-info/
30
+ .installed.cfg
31
+ *.egg
32
+ MANIFEST
33
+
34
+ # PyInstaller
35
+ # Usually these files are written by a python script from a template
36
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
37
+ *.manifest
38
+ *.spec
39
+
40
+ # Installer logs
41
+ pip-log.txt
42
+ pip-delete-this-directory.txt
43
+
44
+ # Unit test / coverage reports
45
+ htmlcov/
46
+ .tox/
47
+ .nox/
48
+ .coverage
49
+ .coverage.*
50
+ .cache
51
+ nosetests.xml
52
+ coverage.xml
53
+ *.cover
54
+ *.py.cover
55
+ .hypothesis/
56
+ .pytest_cache/
57
+ cover/
58
+
59
+ # Translations
60
+ *.mo
61
+ *.pot
62
+
63
+ # Django stuff:
64
+ *.log
65
+ local_settings.py
66
+ db.sqlite3
67
+ db.sqlite3-journal
68
+
69
+ # Flask stuff:
70
+ instance/
71
+ .webassets-cache
72
+
73
+ # Scrapy stuff:
74
+ .scrapy
75
+
76
+ # Sphinx documentation
77
+ docs/_build/
78
+
79
+ # PyBuilder
80
+ .pybuilder/
81
+ target/
82
+
83
+ # Jupyter Notebook
84
+ .ipynb_checkpoints
85
+
86
+ # IPython
87
+ profile_default/
88
+ ipython_config.py
89
+
90
+ # pyenv
91
+ # For a library or package, you might want to ignore these files since the code is
92
+ # intended to run in multiple environments; otherwise, check them in:
93
+ # .python-version
94
+
95
+ # pipenv
96
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
97
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
98
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
99
+ # install all needed dependencies.
100
+ # Pipfile.lock
101
+
102
+ # UV
103
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
104
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
105
+ # commonly ignored for libraries.
106
+ # uv.lock
107
+
108
+ # poetry
109
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
110
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
111
+ # commonly ignored for libraries.
112
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
113
+ # poetry.lock
114
+ # poetry.toml
115
+
116
+ # pdm
117
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
118
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
119
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
120
+ # pdm.lock
121
+ # pdm.toml
122
+ .pdm-python
123
+ .pdm-build/
124
+
125
+ # pixi
126
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
127
+ # pixi.lock
128
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
129
+ # in the .venv directory. It is recommended not to include this directory in version control.
130
+ .pixi
131
+
132
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
133
+ __pypackages__/
134
+
135
+ # Celery stuff
136
+ celerybeat-schedule
137
+ celerybeat.pid
138
+
139
+ # Redis
140
+ *.rdb
141
+ *.aof
142
+ *.pid
143
+
144
+ # RabbitMQ
145
+ mnesia/
146
+ rabbitmq/
147
+ rabbitmq-data/
148
+
149
+ # ActiveMQ
150
+ activemq-data/
151
+
152
+ # SageMath parsed files
153
+ *.sage.py
154
+
155
+ # Environments
156
+ .env
157
+ .envrc
158
+ .vercel/
159
+ .venv
160
+ env/
161
+ venv/
162
+ ENV/
163
+ env.bak/
164
+ venv.bak/
165
+
166
+ # Spyder project settings
167
+ .spyderproject
168
+ .spyproject
169
+
170
+ # Rope project settings
171
+ .ropeproject
172
+
173
+ # mkdocs documentation
174
+ /site
175
+
176
+ # mypy
177
+ .mypy_cache/
178
+ .dmypy.json
179
+ dmypy.json
180
+
181
+ # Pyre type checker
182
+ .pyre/
183
+
184
+ # pytype static type analyzer
185
+ .pytype/
186
+
187
+ # Cython debug symbols
188
+ cython_debug/
189
+
190
+ # PyCharm
191
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
192
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
193
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
194
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
195
+ # .idea/
196
+
197
+ # Abstra
198
+ # Abstra is an AI-powered process automation framework.
199
+ # Ignore directories containing user credentials, local state, and settings.
200
+ # Learn more at https://abstra.io/docs
201
+ .abstra/
202
+
203
+ # Visual Studio Code
204
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
205
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
206
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
207
+ # you could uncomment the following to ignore the entire vscode folder
208
+ # .vscode/
209
+
210
+ # Ruff stuff:
211
+ .ruff_cache/
212
+
213
+ # PyPI configuration file
214
+ .pypirc
215
+
216
+ # Marimo
217
+ marimo/_static/
218
+ marimo/_lsp/
219
+ __marimo__/
220
+
221
+ # Streamlit
222
+ .streamlit/secrets.toml
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: flywheel-bootstrap-staging
3
+ Version: 0.1.9.202601291439
4
+ Summary: Bootstrap runner for Flywheel provisioned GPU instances
5
+ Project-URL: Homepage, http://paradigma.inc/
6
+ Author: Paradigma Labs
7
+ License: MIT
8
+ Keywords: bootstrap,flywheel,gpu,machine-learning,ml
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Bootstrap
18
+
19
+ This package hosts the BYOC bootstrapper that:
20
+
21
+ - Ensures Codex is available (prefers release tarball; skips install if already
22
+ on `PATH`).
23
+ - Fetches the bootstrap payload for a run from the Flywheel backend.
24
+ - Launches `codex exec` with the provided prompt/config and streams logs.
25
+ - Collects artifacts (manifest-on-exit) and reports completion or error back to
26
+ the backend.
27
+
28
+ ## Configuration
29
+
30
+ Bootstrap reads the user's Codex `config.toml` and requires one of:
31
+
32
+ ```toml
33
+ [flywheel]
34
+ # inline instructions (host-specific tips, paths, sandbox notes)
35
+ workspace_instructions = """
36
+ Use /mnt/work as your workspace. Write artifacts under ./artifacts.
37
+ """
38
+
39
+ # or: reference a file (relative paths are resolved against the config file directory)
40
+ workspace_instructions_file = "workspace_notes.md"
41
+ ```
42
+
43
+ Rules:
44
+
45
+ - At least one of `workspace_instructions` or `workspace_instructions_file` is
46
+ required; otherwise bootstrap exits before contacting the server.
47
+ - If both are set, the file wins and the inline value is ignored (warns once).
48
+ - File contents must be non-empty; the path is resolved relative to the config
49
+ file if not absolute.
50
+
51
+ Prompt assembly written to `flywheel_prompt.txt`:
52
+
53
+ 1. Flywheel engineer context (logging/artifact expectations).
54
+ 2. Task Description (prompt fetched from the server).
55
+ 3. Workspace Instructions (resolved from config as above).
56
+
57
+ Example config: `project/bootstrap/examples/config.example.toml` can be used as
58
+ a starting point; update the paths and instructions for your machine.
59
+
60
+ ## End-to-end flow (bootstrap.sh → Python bootstrapper)
61
+
62
+ 1. User runs `bash ./bootstrap.sh --run-id <id> --token <token> --config /path/to/config.toml [--server <url>]` on their BYOC machine.
63
+ 2. The shim:
64
+ - Ensures `uvx` is available (installs via `https://astral.sh/uv/install.sh` if missing, then rechecks PATH with `~/.cargo/bin`).
65
+ - Points `PKG_PATH` to the local repo copy `project/bootstrap`.
66
+ - Executes `uvx --no-cache --from "$PKG_PATH" flywheel-bootstrap "$@"` so the latest local package runs.
67
+ 3. Python entrypoint (`python -m bootstrap`):
68
+ - Parses args/env: requires run id + token, required `--config`, optional `--server` (default `http://localhost:8000`).
69
+ - Loads Codex config.toml, enforces presence of workspace instructions (inline or file), extracts workspace/sandbox settings.
70
+ 4. Workspace resolution:
71
+ - Uses `cd`/`workspace_dir` from config if set; otherwise `~/.flywheel/runs/<run_id>`.
72
+ - Creates the workspace and validates the artifact manifest path is inside sandbox `writable_roots` when sandboxing is enabled; else exits with an error.
73
+ 5. Codex availability:
74
+ - If `BOOTSTRAP_MOCK_CODEX` is set, skips install and runs a mock flow.
75
+ - Else, if `codex` is already on PATH, reuse it; otherwise download the Codex release tarball to the workspace/run root and mark it executable.
76
+ 6. Fetch bootstrap payload:
77
+ - `GET <server>/runs/<run_id>/bootstrap` with `X-Run-Token`; payload contains the task prompt.
78
+ 7. Build prompt file:
79
+ - Combine base Flywheel engineer context, “Task Description” (server prompt), and “Workspace Instructions” (user config) into `flywheel_prompt.txt` in the workspace.
80
+ 8. Launch Codex:
81
+ - Run `codex exec --json --cd <workspace> --skip-git-repo-check flywheel_prompt.txt` with env `FLYWHEEL_RUN_ID/TOKEN/SERVER`.
82
+ - Start a heartbeat thread posting `/runs/{id}/heartbeat` every 30s.
83
+ - Stream Codex stdout lines as logs to `/runs/{id}/logs`; capture Codex `run_id` if emitted.
84
+ 9. After Codex exits:
85
+ - Read `flywheel_artifacts.json`; if empty and Codex `run_id` is known, attempt one `codex resume <id>` then re-read.
86
+ - POST artifacts to `/runs/{id}/artifacts`; POST `/complete` on exit 0, else `/error` with the exit code.
87
+ - Stop/join the heartbeat thread.
88
+ 10. Mock mode (`BOOTSTRAP_MOCK_CODEX=1`):
89
+ - Sends a heartbeat, a few logs, writes a mock artifact manifest, returns 0 (used in e2e tests).
90
+
91
+ ## Next steps
92
+
93
+ - Publish bootstrap package and switch `uvx --from` to a release URL.
94
+ - Iterate on prompts / general polish
@@ -0,0 +1,78 @@
1
+ # Bootstrap
2
+
3
+ This package hosts the BYOC bootstrapper that:
4
+
5
+ - Ensures Codex is available (prefers release tarball; skips install if already
6
+ on `PATH`).
7
+ - Fetches the bootstrap payload for a run from the Flywheel backend.
8
+ - Launches `codex exec` with the provided prompt/config and streams logs.
9
+ - Collects artifacts (manifest-on-exit) and reports completion or error back to
10
+ the backend.
11
+
12
+ ## Configuration
13
+
14
+ Bootstrap reads the user's Codex `config.toml` and requires one of:
15
+
16
+ ```toml
17
+ [flywheel]
18
+ # inline instructions (host-specific tips, paths, sandbox notes)
19
+ workspace_instructions = """
20
+ Use /mnt/work as your workspace. Write artifacts under ./artifacts.
21
+ """
22
+
23
+ # or: reference a file (relative paths are resolved against the config file directory)
24
+ workspace_instructions_file = "workspace_notes.md"
25
+ ```
26
+
27
+ Rules:
28
+
29
+ - At least one of `workspace_instructions` or `workspace_instructions_file` is
30
+ required; otherwise bootstrap exits before contacting the server.
31
+ - If both are set, the file wins and the inline value is ignored (warns once).
32
+ - File contents must be non-empty; the path is resolved relative to the config
33
+ file if not absolute.
34
+
35
+ Prompt assembly written to `flywheel_prompt.txt`:
36
+
37
+ 1. Flywheel engineer context (logging/artifact expectations).
38
+ 2. Task Description (prompt fetched from the server).
39
+ 3. Workspace Instructions (resolved from config as above).
40
+
41
+ Example config: `project/bootstrap/examples/config.example.toml` can be used as
42
+ a starting point; update the paths and instructions for your machine.
43
+
44
+ ## End-to-end flow (bootstrap.sh → Python bootstrapper)
45
+
46
+ 1. User runs `bash ./bootstrap.sh --run-id <id> --token <token> --config /path/to/config.toml [--server <url>]` on their BYOC machine.
47
+ 2. The shim:
48
+ - Ensures `uvx` is available (installs via `https://astral.sh/uv/install.sh` if missing, then rechecks PATH with `~/.cargo/bin`).
49
+ - Points `PKG_PATH` to the local repo copy `project/bootstrap`.
50
+ - Executes `uvx --no-cache --from "$PKG_PATH" flywheel-bootstrap "$@"` so the latest local package runs.
51
+ 3. Python entrypoint (`python -m bootstrap`):
52
+ - Parses args/env: requires run id + token, required `--config`, optional `--server` (default `http://localhost:8000`).
53
+ - Loads Codex config.toml, enforces presence of workspace instructions (inline or file), extracts workspace/sandbox settings.
54
+ 4. Workspace resolution:
55
+ - Uses `cd`/`workspace_dir` from config if set; otherwise `~/.flywheel/runs/<run_id>`.
56
+ - Creates the workspace and validates the artifact manifest path is inside sandbox `writable_roots` when sandboxing is enabled; else exits with an error.
57
+ 5. Codex availability:
58
+ - If `BOOTSTRAP_MOCK_CODEX` is set, skips install and runs a mock flow.
59
+ - Else, if `codex` is already on PATH, reuse it; otherwise download the Codex release tarball to the workspace/run root and mark it executable.
60
+ 6. Fetch bootstrap payload:
61
+ - `GET <server>/runs/<run_id>/bootstrap` with `X-Run-Token`; payload contains the task prompt.
62
+ 7. Build prompt file:
63
+ - Combine base Flywheel engineer context, “Task Description” (server prompt), and “Workspace Instructions” (user config) into `flywheel_prompt.txt` in the workspace.
64
+ 8. Launch Codex:
65
+ - Run `codex exec --json --cd <workspace> --skip-git-repo-check flywheel_prompt.txt` with env `FLYWHEEL_RUN_ID/TOKEN/SERVER`.
66
+ - Start a heartbeat thread posting `/runs/{id}/heartbeat` every 30s.
67
+ - Stream Codex stdout lines as logs to `/runs/{id}/logs`; capture Codex `run_id` if emitted.
68
+ 9. After Codex exits:
69
+ - Read `flywheel_artifacts.json`; if empty and Codex `run_id` is known, attempt one `codex resume <id>` then re-read.
70
+ - POST artifacts to `/runs/{id}/artifacts`; POST `/complete` on exit 0, else `/error` with the exit code.
71
+ - Stop/join the heartbeat thread.
72
+ 10. Mock mode (`BOOTSTRAP_MOCK_CODEX=1`):
73
+ - Sends a heartbeat, a few logs, writes a mock artifact manifest, returns 0 (used in e2e tests).
74
+
75
+ ## Next steps
76
+
77
+ - Publish bootstrap package and switch `uvx --from` to a release URL.
78
+ - Iterate on prompts / general polish
@@ -0,0 +1,3 @@
1
+ """Bootstrap package."""
2
+
3
+ __all__ = []
@@ -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())
@@ -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
@@ -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"