flywheel-bootstrap 0.1.9.202601271702__tar.gz → 0.1.9.202601272108__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 (26) hide show
  1. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/PKG-INFO +1 -1
  2. flywheel_bootstrap-0.1.9.202601272108/bootstrap/artifacts.py +101 -0
  3. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/constants.py +1 -0
  4. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/orchestrator.py +102 -33
  5. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/prompts.py +11 -1
  6. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/pyproject.toml +1 -1
  7. flywheel_bootstrap-0.1.9.202601272108/tests/test_artifacts.py +154 -0
  8. flywheel_bootstrap-0.1.9.202601272108/tests/test_orchestrator.py +416 -0
  9. flywheel_bootstrap-0.1.9.202601271702/bootstrap/artifacts.py +0 -18
  10. flywheel_bootstrap-0.1.9.202601271702/tests/test_orchestrator.py +0 -180
  11. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/.gitignore +0 -0
  12. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/README.md +0 -0
  13. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/__init__.py +0 -0
  14. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/__main__.py +0 -0
  15. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/config_loader.py +0 -0
  16. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/git_ops.py +0 -0
  17. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/install.py +0 -0
  18. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/payload.py +0 -0
  19. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/py.typed +0 -0
  20. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/runner.py +0 -0
  21. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/telemetry.py +0 -0
  22. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap.sh +0 -0
  23. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/examples/config.example.toml +0 -0
  24. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/tests/test_entrypoint.py +0 -0
  25. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/tests/test_git_ops.py +0 -0
  26. {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flywheel-bootstrap
3
- Version: 0.1.9.202601271702
3
+ Version: 0.1.9.202601272108
4
4
  Summary: Bootstrap runner for Flywheel provisioned GPU instances
5
5
  Project-URL: Homepage, http://paradigma.inc/
6
6
  Author: Paradigma Labs
@@ -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)
@@ -8,6 +8,7 @@ DEFAULT_SERVER_URL = "http://localhost:8000"
8
8
  DEFAULT_RUN_ROOT = Path.home() / ".flywheel" / "runs"
9
9
  DEFAULT_ARTIFACT_MANIFEST = "flywheel_artifacts.json"
10
10
  HEARTBEAT_INTERVAL_SECONDS = 30
11
+ MAX_ARTIFACT_RETRIES = 2
11
12
 
12
13
  # Environment variables that let the backend command override defaults.
13
14
  ENV_SERVER_URL = "FLYWHEEL_SERVER"
@@ -23,6 +23,7 @@ from bootstrap.constants import (
23
23
  ENV_RUN_TOKEN,
24
24
  ENV_SERVER_URL,
25
25
  HEARTBEAT_INTERVAL_SECONDS,
26
+ MAX_ARTIFACT_RETRIES,
26
27
  )
27
28
  from bootstrap.config_loader import UserConfig, load_codex_config
28
29
  from bootstrap.git_ops import GitConfig, initialize_repo, finalize_repo
@@ -30,7 +31,6 @@ from bootstrap.install import codex_login_status_ok, codex_on_path, ensure_codex
30
31
  from bootstrap.payload import BootstrapPayload, fetch_bootstrap_payload
31
32
  from bootstrap.prompts import build_prompt_text
32
33
  from bootstrap.runner import (
33
- CodexInvocation,
34
34
  CodexEvent,
35
35
  build_invocation,
36
36
  run_and_stream,
@@ -42,7 +42,7 @@ from bootstrap.telemetry import (
42
42
  post_heartbeat,
43
43
  post_log,
44
44
  )
45
- from bootstrap.artifacts import read_manifest
45
+ from bootstrap.artifacts import ManifestResult, ManifestStatus, read_manifest
46
46
 
47
47
 
48
48
  @dataclass
@@ -416,15 +416,20 @@ class BootstrapOrchestrator:
416
416
  return exit_code
417
417
 
418
418
  def _collect_and_post_artifacts(self, exit_code: int) -> None:
419
- """Read manifest (and optional resume attempt) then POST /artifacts/complete/error."""
419
+ """Read manifest (and optional resume attempts) then POST /artifacts/complete/error."""
420
420
  assert self.workspace is not None
421
421
  manifest_path = self.workspace / self.config.artifact_manifest
422
- artifacts = self._load_artifacts_with_content(manifest_path)
423
-
424
- # Optional auto-resume once if missing
425
- if not artifacts and self.codex_run_id:
426
- self._attempt_resume(manifest_path)
427
- artifacts = self._load_artifacts_with_content(manifest_path)
422
+ manifest_result, artifacts = self._load_artifacts_with_content(manifest_path)
423
+
424
+ # Auto-resume up to MAX_ARTIFACT_RETRIES times if artifacts are
425
+ # missing or the manifest was malformed.
426
+ retries = 0
427
+ while not artifacts and self.codex_run_id and retries < MAX_ARTIFACT_RETRIES:
428
+ retries += 1
429
+ self._attempt_artifact_retry(manifest_path, manifest_result)
430
+ manifest_result, artifacts = self._load_artifacts_with_content(
431
+ manifest_path
432
+ )
428
433
 
429
434
  if artifacts:
430
435
  post_artifacts(
@@ -461,7 +466,7 @@ class BootstrapOrchestrator:
461
466
 
462
467
  def _load_artifacts_with_content(
463
468
  self, manifest_path: Path
464
- ) -> list[dict[str, object]]:
469
+ ) -> tuple[ManifestResult, list[dict[str, object]]]:
465
470
  """Load artifacts and inline content when a path is provided.
466
471
 
467
472
  For text/html artifacts, if payload includes a "path" (or "file") inside the workspace,
@@ -471,6 +476,8 @@ class BootstrapOrchestrator:
471
476
  Best-effort; failures are logged and skipped.
472
477
 
473
478
  Size limit: 2MB per artifact to prevent huge payloads.
479
+
480
+ Returns a tuple of (ManifestResult, enriched artifacts list).
474
481
  """
475
482
  import base64
476
483
  import mimetypes
@@ -478,7 +485,8 @@ class BootstrapOrchestrator:
478
485
  MAX_ARTIFACT_SIZE = 25 * 1024 * 1024 # 25MB
479
486
 
480
487
  assert self.workspace is not None
481
- artifacts = read_manifest(manifest_path)
488
+ manifest_result = read_manifest(manifest_path)
489
+ artifacts = manifest_result.artifacts
482
490
  enriched: list[dict[str, object]] = []
483
491
 
484
492
  # Checkpoint file extensions (model weights, etc.)
@@ -609,7 +617,7 @@ class BootstrapOrchestrator:
609
617
  except Exception as exc: # pragma: no cover - defensive
610
618
  self._log(f"artifact enrichment error: {exc}", level="warning")
611
619
  enriched.append(dict(artifact))
612
- return enriched
620
+ return manifest_result, enriched
613
621
 
614
622
  def _resolve_artifact_path(self, path_str: str) -> Path | None:
615
623
  """Resolve artifact path within workspace, returning None if invalid."""
@@ -649,34 +657,95 @@ class BootstrapOrchestrator:
649
657
  if isinstance(run_id, str):
650
658
  self.codex_run_id = run_id
651
659
 
652
- def _attempt_resume(self, manifest_path: Path) -> None:
660
+ def _attempt_artifact_retry(
661
+ self, manifest_path: Path, manifest_result: ManifestResult
662
+ ) -> None:
663
+ """Retry artifact collection via ``codex exec`` with a feedback prompt.
664
+
665
+ Both MISSING and MALFORMED manifests are handled by launching a new
666
+ Codex exec with a targeted prompt describing the problem and telling
667
+ Codex exactly what to do. This is preferable to ``codex resume``
668
+ which cannot accept additional instructions.
669
+ """
653
670
  if not self.codex_run_id:
654
671
  return
655
- codex_path = self.codex_executable or Path("codex")
656
- resume_cmd = [str(codex_path), "resume", self.codex_run_id]
657
- post_log(
658
- self.config.server_url,
659
- self.config.run_id,
660
- self.config.capability_token,
661
- level="info",
662
- message="attempting codex resume to collect artifacts",
663
- extra={"command": resume_cmd},
672
+
673
+ manifest_name = self.config.artifact_manifest
674
+
675
+ if manifest_result.status == ManifestStatus.MALFORMED:
676
+ error_detail = manifest_result.error or "unknown error"
677
+ raw_content = ""
678
+ if manifest_path.exists():
679
+ try:
680
+ raw_content = manifest_path.read_text(encoding="utf-8")[:2000]
681
+ except Exception:
682
+ raw_content = "<could not read file>"
683
+
684
+ fix_prompt = (
685
+ "The artifact manifest file at "
686
+ f"$FLYWHEEL_WORKSPACE/{manifest_name} is malformed.\n\n"
687
+ f"Error: {error_detail}\n\n"
688
+ f"Current file contents:\n{raw_content}\n\n"
689
+ "Please rewrite this file so it is a valid JSON list of "
690
+ "artifact entries. Each entry must be an object with "
691
+ '"artifact_type" and "payload" keys. The file must be a '
692
+ "top-level JSON array, for example:\n"
693
+ "[\n"
694
+ ' {"artifact_type": "text", "payload": {"content": "..."}},\n'
695
+ ' {"artifact_type": "image", "payload": {"path": "plot.png",'
696
+ ' "format": "png"}}\n'
697
+ "]\n\n"
698
+ "Do NOT wrap the list in an object. The file must start with "
699
+ "[ and end with ].\n"
700
+ "Only fix the manifest format — do not change the actual "
701
+ "artifact content or paths."
702
+ )
703
+ log_msg = "attempting codex exec to fix malformed artifact manifest"
704
+ else:
705
+ # MISSING — the file was never written.
706
+ fix_prompt = (
707
+ "The artifact manifest file was not found at "
708
+ f"$FLYWHEEL_WORKSPACE/{manifest_name}.\n\n"
709
+ "Your task already completed successfully, but the manifest "
710
+ "file is missing. Please write the manifest now.\n\n"
711
+ "The file must be a valid JSON list of artifact entries. "
712
+ 'Each entry must be an object with "artifact_type" and '
713
+ '"payload" keys. The file must be a top-level JSON array, '
714
+ "for example:\n"
715
+ "[\n"
716
+ ' {"artifact_type": "text", "payload": {"content": "..."}},\n'
717
+ ' {"artifact_type": "image", "payload": {"path": "plot.png",'
718
+ ' "format": "png"}}\n'
719
+ "]\n\n"
720
+ "Do NOT wrap the list in an object. The file must start with "
721
+ "[ and end with ].\n"
722
+ "Look at the files you produced in the workspace and create "
723
+ "the manifest based on what you find."
724
+ )
725
+ log_msg = "attempting codex exec to write missing artifact manifest"
726
+
727
+ self._log(
728
+ log_msg,
729
+ extra={
730
+ "status": manifest_result.status.value,
731
+ "error": manifest_result.error,
732
+ },
664
733
  )
734
+
735
+ codex_path = self.codex_executable or Path("codex")
665
736
  try:
666
- run_and_stream(
667
- CodexInvocation(
668
- args=resume_cmd,
669
- env={},
670
- workdir=self.workspace or Path("."),
671
- )
737
+ invocation = build_invocation(
738
+ codex_executable=codex_path,
739
+ prompt=fix_prompt,
740
+ workdir=self.workspace or Path("."),
741
+ env=os.environ.copy(),
672
742
  )
743
+ for event in run_and_stream(invocation):
744
+ self._handle_event(event)
673
745
  except Exception as exc: # pragma: no cover
674
- post_log(
675
- self.config.server_url,
676
- self.config.run_id,
677
- self.config.capability_token,
746
+ self._log(
747
+ "codex artifact retry failed",
678
748
  level="error",
679
- message="codex resume failed",
680
749
  extra={"error": repr(exc)},
681
750
  )
682
751
 
@@ -25,8 +25,18 @@ def build_prompt_text(
25
25
  ARTIFACT MANIFEST (CRITICAL):
26
26
  - The environment variable $FLYWHEEL_WORKSPACE contains the absolute path to your workspace root.
27
27
  - When finished, write the manifest to: $FLYWHEEL_WORKSPACE/{artifact_manifest}
28
+ - The manifest MUST be a top-level JSON array (list). Do NOT wrap it in an object.
28
29
  - All file paths in the manifest must be relative to $FLYWHEEL_WORKSPACE.
29
- - Each entry must include "artifact_type" and "payload".
30
+ - Each entry must be an object with "artifact_type" and "payload" keys.
31
+
32
+ MANIFEST FORMAT — the file must look exactly like this (a JSON array):
33
+ [
34
+ {{"artifact_type": "text", "payload": {{"content": "Summary of results..."}}}},
35
+ {{"artifact_type": "image", "payload": {{"path": "plots/loss_curve.png", "format": "png"}}}},
36
+ {{"artifact_type": "table", "payload": {{"path": "results/metrics.csv", "format": "csv"}}}}
37
+ ]
38
+
39
+ IMPORTANT: The file must start with [ and end with ]. Do NOT write {{"artifacts": [...]}} or any other wrapper object.
30
40
 
31
41
  SUPPORTED ARTIFACT TYPES (use ONLY these):
32
42
  - "text": For text/markdown. Payload: {{"content": "..."}} or {{"path": "path/to/file.txt"}}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flywheel-bootstrap"
3
- version = "0.1.9.202601271702"
3
+ version = "0.1.9.202601272108"
4
4
  description = "Bootstrap runner for Flywheel provisioned GPU instances"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -0,0 +1,154 @@
1
+ """Unit tests for artifact manifest parsing – defensive handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from bootstrap.artifacts import ManifestStatus, read_manifest
9
+
10
+
11
+ class TestReadManifestDefensive:
12
+ """read_manifest should tolerate common LLM output variations."""
13
+
14
+ def test_valid_list(self, tmp_path: Path) -> None:
15
+ """A well-formed JSON list is returned as-is."""
16
+ manifest = tmp_path / "manifest.json"
17
+ entries = [{"artifact_type": "text", "payload": {"content": "hi"}}]
18
+ manifest.write_text(json.dumps(entries), encoding="utf-8")
19
+
20
+ result = read_manifest(manifest)
21
+ assert list(result.artifacts) == entries
22
+ assert result.status == ManifestStatus.VALID
23
+ assert result.error is None
24
+
25
+ def test_missing_file_returns_empty(self, tmp_path: Path) -> None:
26
+ """Non-existent manifest returns MISSING status."""
27
+ result = read_manifest(tmp_path / "nope.json")
28
+ assert list(result.artifacts) == []
29
+ assert result.status == ManifestStatus.MISSING
30
+
31
+ def test_dict_with_artifacts_key(self, tmp_path: Path) -> None:
32
+ """LLM wraps the list in {"artifacts": [...]}, we unwrap it."""
33
+ manifest = tmp_path / "manifest.json"
34
+ entries = [{"artifact_type": "text", "payload": {"content": "hi"}}]
35
+ manifest.write_text(json.dumps({"artifacts": entries}), encoding="utf-8")
36
+
37
+ result = read_manifest(manifest)
38
+ assert list(result.artifacts) == entries
39
+ assert result.status == ManifestStatus.MALFORMED
40
+ assert result.error is not None
41
+
42
+ def test_dict_with_other_list_key(self, tmp_path: Path) -> None:
43
+ """Dict wrapping with an arbitrary key containing a list is unwrapped."""
44
+ manifest = tmp_path / "manifest.json"
45
+ entries = [{"artifact_type": "text", "payload": {"content": "x"}}]
46
+ manifest.write_text(json.dumps({"results": entries}), encoding="utf-8")
47
+
48
+ result = read_manifest(manifest)
49
+ assert list(result.artifacts) == entries
50
+ assert result.status == ManifestStatus.MALFORMED
51
+
52
+ def test_dict_single_artifact_wrapped_in_list(self, tmp_path: Path) -> None:
53
+ """A single artifact dict (not in a list) gets wrapped."""
54
+ manifest = tmp_path / "manifest.json"
55
+ entry = {"artifact_type": "text", "payload": {"content": "single"}}
56
+ manifest.write_text(json.dumps(entry), encoding="utf-8")
57
+
58
+ result = read_manifest(manifest)
59
+ assert list(result.artifacts) == [entry]
60
+ assert result.status == ManifestStatus.MALFORMED
61
+
62
+ def test_dict_with_artifact_type_takes_priority_over_nested_list(
63
+ self, tmp_path: Path
64
+ ) -> None:
65
+ """A dict with artifact_type is treated as a single artifact even if it has nested lists."""
66
+ manifest = tmp_path / "manifest.json"
67
+ entry = {
68
+ "artifact_type": "text",
69
+ "payload": {"content": "hi", "items": ["a", "b"]},
70
+ }
71
+ manifest.write_text(json.dumps(entry), encoding="utf-8")
72
+
73
+ result = read_manifest(manifest)
74
+ assert list(result.artifacts) == [entry]
75
+ assert result.status == ManifestStatus.MALFORMED
76
+
77
+ def test_dict_with_multiple_list_values_prefers_artifacts_key(
78
+ self, tmp_path: Path
79
+ ) -> None:
80
+ """When dict has no artifact_type and multiple list values, prefer 'artifacts' key."""
81
+ manifest = tmp_path / "manifest.json"
82
+ entries = [{"artifact_type": "text", "payload": {"content": "a"}}]
83
+ other = [{"something": "else"}]
84
+ manifest.write_text(
85
+ json.dumps({"artifacts": entries, "other": other}), encoding="utf-8"
86
+ )
87
+
88
+ result = read_manifest(manifest)
89
+ assert list(result.artifacts) == entries
90
+
91
+ def test_dict_with_no_list_values_is_single_artifact(self, tmp_path: Path) -> None:
92
+ """A dict with artifact_type but no list values is treated as a single artifact."""
93
+ manifest = tmp_path / "manifest.json"
94
+ entry = {"artifact_type": "text", "payload": {"content": "solo"}}
95
+ manifest.write_text(json.dumps(entry), encoding="utf-8")
96
+
97
+ result = read_manifest(manifest)
98
+ assert list(result.artifacts) == [entry]
99
+
100
+ def test_truncated_json_is_malformed(self, tmp_path: Path) -> None:
101
+ """Truncated/invalid JSON is reported as malformed."""
102
+ manifest = tmp_path / "manifest.json"
103
+ manifest.write_text('[{"artifact_type": "text", "pay', encoding="utf-8")
104
+
105
+ result = read_manifest(manifest)
106
+ assert list(result.artifacts) == []
107
+ assert result.status == ManifestStatus.MALFORMED
108
+ assert result.error is not None
109
+ assert "invalid JSON" in result.error
110
+
111
+ def test_empty_file_is_malformed(self, tmp_path: Path) -> None:
112
+ """An empty file is reported as malformed."""
113
+ manifest = tmp_path / "manifest.json"
114
+ manifest.write_text("", encoding="utf-8")
115
+
116
+ result = read_manifest(manifest)
117
+ assert list(result.artifacts) == []
118
+ assert result.status == ManifestStatus.MALFORMED
119
+
120
+ def test_non_json_content_is_malformed(self, tmp_path: Path) -> None:
121
+ """Non-JSON content is reported as malformed."""
122
+ manifest = tmp_path / "manifest.json"
123
+ manifest.write_text("this is not json at all", encoding="utf-8")
124
+
125
+ result = read_manifest(manifest)
126
+ assert list(result.artifacts) == []
127
+ assert result.status == ManifestStatus.MALFORMED
128
+
129
+ def test_empty_list(self, tmp_path: Path) -> None:
130
+ """An empty JSON list is VALID (just no artifacts)."""
131
+ manifest = tmp_path / "manifest.json"
132
+ manifest.write_text("[]", encoding="utf-8")
133
+
134
+ result = read_manifest(manifest)
135
+ assert list(result.artifacts) == []
136
+ assert result.status == ManifestStatus.VALID
137
+
138
+ def test_empty_dict_is_malformed(self, tmp_path: Path) -> None:
139
+ """An empty dict is reported as malformed."""
140
+ manifest = tmp_path / "manifest.json"
141
+ manifest.write_text("{}", encoding="utf-8")
142
+
143
+ result = read_manifest(manifest)
144
+ assert list(result.artifacts) == []
145
+ assert result.status == ManifestStatus.MALFORMED
146
+
147
+ def test_scalar_value_is_malformed(self, tmp_path: Path) -> None:
148
+ """A scalar JSON value (string, number) is reported as malformed."""
149
+ manifest = tmp_path / "manifest.json"
150
+ manifest.write_text('"just a string"', encoding="utf-8")
151
+
152
+ result = read_manifest(manifest)
153
+ assert list(result.artifacts) == []
154
+ assert result.status == ManifestStatus.MALFORMED
@@ -0,0 +1,416 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ import pytest
5
+ from bootstrap.artifacts import ManifestResult, ManifestStatus
6
+ from bootstrap.config_loader import UserConfig
7
+ from bootstrap.orchestrator import BootstrapConfig, BootstrapOrchestrator
8
+ from bootstrap.payload import BootstrapPayload
9
+ from bootstrap.runner import CodexInvocation, CodexEvent
10
+
11
+
12
+ def test_orchestrator_happy_path(monkeypatch, tmp_path):
13
+ """End-to-end orchestration with fakes: no real network or codex."""
14
+
15
+ workspace = tmp_path / "work"
16
+ artifacts = [{"artifact_type": "text", "payload": {"content": "hi"}}]
17
+ captured_env: dict[str, str] = {}
18
+ captured_extra_flags: tuple[str, ...] = ()
19
+
20
+ # Prepare a dummy config file
21
+ cfg_file = tmp_path / "config.toml"
22
+ cfg_file.write_text("", encoding="utf-8")
23
+
24
+ # Telemetry collectors
25
+ heartbeats: list[dict[str, Any]] = []
26
+ logs: list[dict[str, Any]] = []
27
+ posted_artifacts: list[dict[str, Any]] = []
28
+ completions: list[dict[str, Any]] = []
29
+ errors: list[dict[str, Any]] = []
30
+
31
+ monkeypatch.setattr(
32
+ "bootstrap.orchestrator.load_codex_config",
33
+ lambda path: UserConfig(
34
+ raw={},
35
+ working_dir=workspace,
36
+ sandbox_mode=None,
37
+ approval_policy="unless-allow-listed",
38
+ oss_provider=None,
39
+ writable_roots=(workspace,),
40
+ workspace_instructions="use workspace",
41
+ instructions_source="inline",
42
+ warnings=(),
43
+ ),
44
+ )
45
+ monkeypatch.setattr("bootstrap.orchestrator.codex_on_path", lambda: True)
46
+ monkeypatch.setattr(
47
+ "bootstrap.orchestrator.codex_login_status_ok", lambda _path: True
48
+ )
49
+ monkeypatch.setattr(
50
+ "bootstrap.orchestrator.fetch_bootstrap_payload",
51
+ lambda server_url, run_id, token: BootstrapPayload(prompt="PROMPT"),
52
+ )
53
+
54
+ def fake_build_invocation(codex_executable, prompt, workdir, env, extra_flags=()):
55
+ captured_env.update(env)
56
+ nonlocal captured_extra_flags
57
+ captured_extra_flags = tuple(extra_flags)
58
+ return CodexInvocation(args=["codex", "exec"], env=env, workdir=workdir)
59
+
60
+ def fake_run_and_stream(invocation):
61
+ invocation.exit_code = 0
62
+ yield CodexEvent(raw={"run_id": "codex-run-1"})
63
+ yield CodexEvent(raw={"message": "ok"})
64
+
65
+ monkeypatch.setattr(
66
+ "bootstrap.orchestrator.build_invocation", fake_build_invocation
67
+ )
68
+ monkeypatch.setattr("bootstrap.orchestrator.run_and_stream", fake_run_and_stream)
69
+ monkeypatch.setattr(
70
+ "bootstrap.orchestrator.read_manifest",
71
+ lambda path: ManifestResult(status=ManifestStatus.VALID, artifacts=artifacts),
72
+ )
73
+
74
+ monkeypatch.setattr(
75
+ "bootstrap.orchestrator.post_heartbeat",
76
+ lambda server_url, run_id, token, summary: heartbeats.append(
77
+ {"summary": summary}
78
+ ),
79
+ )
80
+ monkeypatch.setattr(
81
+ "bootstrap.orchestrator.post_log",
82
+ lambda server_url, run_id, token, level, message, extra: logs.append(
83
+ {"level": level, "message": message}
84
+ ),
85
+ )
86
+ monkeypatch.setattr(
87
+ "bootstrap.orchestrator.post_artifacts",
88
+ lambda server_url, run_id, token, entries: posted_artifacts.extend(entries),
89
+ )
90
+ monkeypatch.setattr(
91
+ "bootstrap.orchestrator.post_completion",
92
+ lambda server_url, run_id, token, summary: completions.append(
93
+ {"summary": summary}
94
+ ),
95
+ )
96
+ monkeypatch.setattr(
97
+ "bootstrap.orchestrator.post_error",
98
+ lambda server_url, run_id, token, reason, summary=None: errors.append(
99
+ {"reason": reason, "summary": summary}
100
+ ),
101
+ )
102
+
103
+ cfg = BootstrapConfig(
104
+ run_id="run-123",
105
+ capability_token="secret",
106
+ config_path=cfg_file,
107
+ server_url="http://server",
108
+ run_root=tmp_path,
109
+ )
110
+ orchestrator = BootstrapOrchestrator(cfg)
111
+ code = orchestrator.run()
112
+
113
+ assert code == 0
114
+ assert heartbeats, "expected at least one heartbeat"
115
+ assert logs, "expected log forwarding"
116
+ assert posted_artifacts == artifacts
117
+ assert completions and not errors
118
+ assert "PATH" in captured_env
119
+ assert captured_env.get("FLYWHEEL_RUN_ID") == "run-123"
120
+ assert captured_env.get("FLYWHEEL_RUN_TOKEN") == "secret"
121
+ assert captured_env.get("FLYWHEEL_SERVER") == "http://server"
122
+ assert "CODEX_HOME" in captured_env
123
+ # With sandbox_mode=None, no sandbox flags should be added
124
+ assert len(captured_extra_flags) == 0
125
+
126
+
127
+ def test_resolve_workspace_rejects_outside_writable(monkeypatch, tmp_path):
128
+ cfg_file = tmp_path / "config.toml"
129
+ cfg_file.write_text("", encoding="utf-8")
130
+
131
+ orchestrator = BootstrapOrchestrator(
132
+ BootstrapConfig(
133
+ run_id="run-1",
134
+ capability_token="token",
135
+ config_path=cfg_file,
136
+ server_url="http://server",
137
+ run_root=tmp_path,
138
+ )
139
+ )
140
+ orchestrator.user_config = UserConfig(
141
+ raw={},
142
+ working_dir=tmp_path / "work",
143
+ sandbox_mode="workspace-write",
144
+ approval_policy="unless-allow-listed",
145
+ oss_provider=None,
146
+ writable_roots=(tmp_path / "other",),
147
+ workspace_instructions="instr",
148
+ instructions_source="inline",
149
+ warnings=(),
150
+ )
151
+
152
+ with pytest.raises(SystemExit):
153
+ orchestrator._resolve_workspace()
154
+
155
+
156
+ def test_resolve_workspace_expands_run_id_placeholder(tmp_path) -> None:
157
+ cfg_file = tmp_path / "config.toml"
158
+ cfg_file.write_text("", encoding="utf-8")
159
+
160
+ orchestrator = BootstrapOrchestrator(
161
+ BootstrapConfig(
162
+ run_id="run-xyz",
163
+ capability_token="token",
164
+ config_path=cfg_file,
165
+ server_url="http://server",
166
+ run_root=tmp_path,
167
+ )
168
+ )
169
+
170
+ templated = tmp_path / "runs" / "<run_id>"
171
+ orchestrator.user_config = UserConfig(
172
+ raw={},
173
+ working_dir=templated,
174
+ sandbox_mode="workspace-write",
175
+ approval_policy="unless-allow-listed",
176
+ oss_provider=None,
177
+ writable_roots=(tmp_path,),
178
+ workspace_instructions="instr",
179
+ instructions_source="inline",
180
+ warnings=(),
181
+ )
182
+
183
+ orchestrator._resolve_workspace()
184
+ assert orchestrator.workspace == (tmp_path / "runs" / "run-xyz").resolve()
185
+
186
+
187
+ def test_malformed_manifest_triggers_two_retry_attempts(monkeypatch, tmp_path):
188
+ """When manifest is malformed, the orchestrator retries up to 2 times with feedback."""
189
+
190
+ workspace = tmp_path / "work"
191
+ good_artifacts = [{"artifact_type": "text", "payload": {"content": "hi"}}]
192
+
193
+ cfg_file = tmp_path / "config.toml"
194
+ cfg_file.write_text("", encoding="utf-8")
195
+
196
+ heartbeats: list[dict[str, Any]] = []
197
+ logs: list[dict[str, Any]] = []
198
+ posted_artifacts: list[dict[str, Any]] = []
199
+ completions: list[dict[str, Any]] = []
200
+ errors: list[dict[str, Any]] = []
201
+
202
+ monkeypatch.setattr(
203
+ "bootstrap.orchestrator.load_codex_config",
204
+ lambda path: UserConfig(
205
+ raw={},
206
+ working_dir=workspace,
207
+ sandbox_mode=None,
208
+ approval_policy="unless-allow-listed",
209
+ oss_provider=None,
210
+ writable_roots=(workspace,),
211
+ workspace_instructions="use workspace",
212
+ instructions_source="inline",
213
+ warnings=(),
214
+ ),
215
+ )
216
+ monkeypatch.setattr("bootstrap.orchestrator.codex_on_path", lambda: True)
217
+ monkeypatch.setattr(
218
+ "bootstrap.orchestrator.codex_login_status_ok", lambda _path: True
219
+ )
220
+ monkeypatch.setattr(
221
+ "bootstrap.orchestrator.fetch_bootstrap_payload",
222
+ lambda server_url, run_id, token: BootstrapPayload(prompt="PROMPT"),
223
+ )
224
+
225
+ def fake_build_invocation(codex_executable, prompt, workdir, env, extra_flags=()):
226
+ return CodexInvocation(args=["codex", "exec"], env=env, workdir=workdir)
227
+
228
+ def fake_run_and_stream(invocation):
229
+ invocation.exit_code = 0
230
+ yield CodexEvent(raw={"run_id": "codex-run-1"})
231
+ yield CodexEvent(raw={"message": "ok"})
232
+
233
+ monkeypatch.setattr(
234
+ "bootstrap.orchestrator.build_invocation", fake_build_invocation
235
+ )
236
+ monkeypatch.setattr("bootstrap.orchestrator.run_and_stream", fake_run_and_stream)
237
+
238
+ calls = {"manifest": 0, "retry": 0}
239
+
240
+ def fake_read_manifest(path):
241
+ calls["manifest"] += 1
242
+ # First two reads return malformed, third returns good data
243
+ if calls["manifest"] <= 2:
244
+ return ManifestResult(
245
+ status=ManifestStatus.MALFORMED,
246
+ artifacts=[],
247
+ error="artifact manifest wrapped in dict",
248
+ )
249
+ return ManifestResult(status=ManifestStatus.VALID, artifacts=good_artifacts)
250
+
251
+ monkeypatch.setattr("bootstrap.orchestrator.read_manifest", fake_read_manifest)
252
+
253
+ def fake_attempt_artifact_retry(self, manifest_path, manifest_result):
254
+ calls["retry"] += 1
255
+
256
+ monkeypatch.setattr(
257
+ "bootstrap.orchestrator.BootstrapOrchestrator._attempt_artifact_retry",
258
+ fake_attempt_artifact_retry,
259
+ )
260
+
261
+ monkeypatch.setattr("bootstrap.orchestrator.HEARTBEAT_INTERVAL_SECONDS", 0.01)
262
+ monkeypatch.setattr(
263
+ "bootstrap.orchestrator.post_heartbeat",
264
+ lambda server_url, run_id, token, summary: heartbeats.append(
265
+ {"summary": summary}
266
+ ),
267
+ )
268
+ monkeypatch.setattr(
269
+ "bootstrap.orchestrator.post_log",
270
+ lambda server_url, run_id, token, level, message, extra: logs.append(
271
+ {"level": level, "message": message}
272
+ ),
273
+ )
274
+ monkeypatch.setattr(
275
+ "bootstrap.orchestrator.post_artifacts",
276
+ lambda server_url, run_id, token, entries: posted_artifacts.extend(entries),
277
+ )
278
+ monkeypatch.setattr(
279
+ "bootstrap.orchestrator.post_completion",
280
+ lambda server_url, run_id, token, summary: completions.append(
281
+ {"summary": summary}
282
+ ),
283
+ )
284
+ monkeypatch.setattr(
285
+ "bootstrap.orchestrator.post_error",
286
+ lambda server_url, run_id, token, reason, summary=None: errors.append(
287
+ {"reason": reason, "summary": summary}
288
+ ),
289
+ )
290
+
291
+ cfg = BootstrapConfig(
292
+ run_id="run-456",
293
+ capability_token="secret",
294
+ config_path=cfg_file,
295
+ server_url="http://server",
296
+ run_root=tmp_path,
297
+ )
298
+ orchestrator = BootstrapOrchestrator(cfg)
299
+ code = orchestrator.run()
300
+
301
+ assert code == 0
302
+ # Should have attempted retry twice (max retries = 2)
303
+ assert calls["retry"] == 2
304
+ # manifest read: initial + after 1st retry + after 2nd retry = 3
305
+ assert calls["manifest"] == 3
306
+ assert posted_artifacts == good_artifacts
307
+ assert completions and not errors
308
+
309
+
310
+ def test_malformed_manifest_exhausts_retries_still_completes(monkeypatch, tmp_path):
311
+ """When all retry attempts fail to produce artifacts, run still completes (no crash)."""
312
+
313
+ workspace = tmp_path / "work"
314
+
315
+ cfg_file = tmp_path / "config.toml"
316
+ cfg_file.write_text("", encoding="utf-8")
317
+
318
+ completions: list[dict[str, Any]] = []
319
+ errors: list[dict[str, Any]] = []
320
+
321
+ monkeypatch.setattr(
322
+ "bootstrap.orchestrator.load_codex_config",
323
+ lambda path: UserConfig(
324
+ raw={},
325
+ working_dir=workspace,
326
+ sandbox_mode=None,
327
+ approval_policy="unless-allow-listed",
328
+ oss_provider=None,
329
+ writable_roots=(workspace,),
330
+ workspace_instructions="use workspace",
331
+ instructions_source="inline",
332
+ warnings=(),
333
+ ),
334
+ )
335
+ monkeypatch.setattr("bootstrap.orchestrator.codex_on_path", lambda: True)
336
+ monkeypatch.setattr(
337
+ "bootstrap.orchestrator.codex_login_status_ok", lambda _path: True
338
+ )
339
+ monkeypatch.setattr(
340
+ "bootstrap.orchestrator.fetch_bootstrap_payload",
341
+ lambda server_url, run_id, token: BootstrapPayload(prompt="PROMPT"),
342
+ )
343
+
344
+ def fake_build_invocation(codex_executable, prompt, workdir, env, extra_flags=()):
345
+ return CodexInvocation(args=["codex", "exec"], env=env, workdir=workdir)
346
+
347
+ def fake_run_and_stream(invocation):
348
+ invocation.exit_code = 0
349
+ yield CodexEvent(raw={"run_id": "codex-run-1"})
350
+
351
+ monkeypatch.setattr(
352
+ "bootstrap.orchestrator.build_invocation", fake_build_invocation
353
+ )
354
+ monkeypatch.setattr("bootstrap.orchestrator.run_and_stream", fake_run_and_stream)
355
+
356
+ calls = {"retry": 0}
357
+
358
+ # Always return malformed (manifest never gets fixed)
359
+ monkeypatch.setattr(
360
+ "bootstrap.orchestrator.read_manifest",
361
+ lambda path: ManifestResult(
362
+ status=ManifestStatus.MALFORMED,
363
+ artifacts=[],
364
+ error="artifact manifest is empty",
365
+ ),
366
+ )
367
+
368
+ def fake_attempt_artifact_retry(self, manifest_path, manifest_result):
369
+ calls["retry"] += 1
370
+
371
+ monkeypatch.setattr(
372
+ "bootstrap.orchestrator.BootstrapOrchestrator._attempt_artifact_retry",
373
+ fake_attempt_artifact_retry,
374
+ )
375
+
376
+ monkeypatch.setattr("bootstrap.orchestrator.HEARTBEAT_INTERVAL_SECONDS", 0.01)
377
+ monkeypatch.setattr(
378
+ "bootstrap.orchestrator.post_heartbeat",
379
+ lambda server_url, run_id, token, summary: None,
380
+ )
381
+ monkeypatch.setattr(
382
+ "bootstrap.orchestrator.post_log",
383
+ lambda server_url, run_id, token, level, message, extra: None,
384
+ )
385
+ monkeypatch.setattr(
386
+ "bootstrap.orchestrator.post_artifacts",
387
+ lambda server_url, run_id, token, entries: None,
388
+ )
389
+ monkeypatch.setattr(
390
+ "bootstrap.orchestrator.post_completion",
391
+ lambda server_url, run_id, token, summary: completions.append(
392
+ {"summary": summary}
393
+ ),
394
+ )
395
+ monkeypatch.setattr(
396
+ "bootstrap.orchestrator.post_error",
397
+ lambda server_url, run_id, token, reason, summary=None: errors.append(
398
+ {"reason": reason, "summary": summary}
399
+ ),
400
+ )
401
+
402
+ cfg = BootstrapConfig(
403
+ run_id="run-789",
404
+ capability_token="secret",
405
+ config_path=cfg_file,
406
+ server_url="http://server",
407
+ run_root=tmp_path,
408
+ )
409
+ orchestrator = BootstrapOrchestrator(cfg)
410
+ code = orchestrator.run()
411
+
412
+ assert code == 0
413
+ # Exhausted all 2 retry attempts
414
+ assert calls["retry"] == 2
415
+ # Still completes (exit code was 0)
416
+ assert completions and not errors
@@ -1,18 +0,0 @@
1
- """Artifact manifest helpers (skeleton)."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
- from typing import Mapping, Sequence
7
- import json
8
-
9
-
10
- def read_manifest(manifest_path: Path) -> Sequence[Mapping[str, object]]:
11
- """Load artifact entries from the manifest path."""
12
- if not manifest_path.exists():
13
- return []
14
- with manifest_path.open("r", encoding="utf-8") as fp:
15
- data = json.load(fp)
16
- if isinstance(data, list):
17
- return data
18
- raise ValueError("artifact manifest must be a JSON list")
@@ -1,180 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import Any
4
- import pytest
5
- from bootstrap.config_loader import UserConfig
6
- from bootstrap.orchestrator import BootstrapConfig, BootstrapOrchestrator
7
- from bootstrap.payload import BootstrapPayload
8
- from bootstrap.runner import CodexInvocation, CodexEvent
9
-
10
-
11
- def test_orchestrator_happy_path(monkeypatch, tmp_path):
12
- """End-to-end orchestration with fakes: no real network or codex."""
13
-
14
- workspace = tmp_path / "work"
15
- artifacts = [{"artifact_type": "text", "payload": {"content": "hi"}}]
16
- captured_env: dict[str, str] = {}
17
- captured_extra_flags: tuple[str, ...] = ()
18
-
19
- # Prepare a dummy config file
20
- cfg_file = tmp_path / "config.toml"
21
- cfg_file.write_text("", encoding="utf-8")
22
-
23
- # Telemetry collectors
24
- heartbeats: list[dict[str, Any]] = []
25
- logs: list[dict[str, Any]] = []
26
- posted_artifacts: list[dict[str, Any]] = []
27
- completions: list[dict[str, Any]] = []
28
- errors: list[dict[str, Any]] = []
29
-
30
- monkeypatch.setattr(
31
- "bootstrap.orchestrator.load_codex_config",
32
- lambda path: UserConfig(
33
- raw={},
34
- working_dir=workspace,
35
- sandbox_mode=None,
36
- approval_policy="unless-allow-listed",
37
- oss_provider=None,
38
- writable_roots=(workspace,),
39
- workspace_instructions="use workspace",
40
- instructions_source="inline",
41
- warnings=(),
42
- ),
43
- )
44
- monkeypatch.setattr("bootstrap.orchestrator.codex_on_path", lambda: True)
45
- monkeypatch.setattr(
46
- "bootstrap.orchestrator.codex_login_status_ok", lambda _path: True
47
- )
48
- monkeypatch.setattr(
49
- "bootstrap.orchestrator.fetch_bootstrap_payload",
50
- lambda server_url, run_id, token: BootstrapPayload(prompt="PROMPT"),
51
- )
52
-
53
- def fake_build_invocation(codex_executable, prompt, workdir, env, extra_flags=()):
54
- captured_env.update(env)
55
- nonlocal captured_extra_flags
56
- captured_extra_flags = tuple(extra_flags)
57
- return CodexInvocation(args=["codex", "exec"], env=env, workdir=workdir)
58
-
59
- def fake_run_and_stream(invocation):
60
- invocation.exit_code = 0
61
- yield CodexEvent(raw={"run_id": "codex-run-1"})
62
- yield CodexEvent(raw={"message": "ok"})
63
-
64
- monkeypatch.setattr(
65
- "bootstrap.orchestrator.build_invocation", fake_build_invocation
66
- )
67
- monkeypatch.setattr("bootstrap.orchestrator.run_and_stream", fake_run_and_stream)
68
- monkeypatch.setattr("bootstrap.orchestrator.read_manifest", lambda path: artifacts)
69
-
70
- monkeypatch.setattr(
71
- "bootstrap.orchestrator.post_heartbeat",
72
- lambda server_url, run_id, token, summary: heartbeats.append(
73
- {"summary": summary}
74
- ),
75
- )
76
- monkeypatch.setattr(
77
- "bootstrap.orchestrator.post_log",
78
- lambda server_url, run_id, token, level, message, extra: logs.append(
79
- {"level": level, "message": message}
80
- ),
81
- )
82
- monkeypatch.setattr(
83
- "bootstrap.orchestrator.post_artifacts",
84
- lambda server_url, run_id, token, entries: posted_artifacts.extend(entries),
85
- )
86
- monkeypatch.setattr(
87
- "bootstrap.orchestrator.post_completion",
88
- lambda server_url, run_id, token, summary: completions.append(
89
- {"summary": summary}
90
- ),
91
- )
92
- monkeypatch.setattr(
93
- "bootstrap.orchestrator.post_error",
94
- lambda server_url, run_id, token, reason, summary=None: errors.append(
95
- {"reason": reason, "summary": summary}
96
- ),
97
- )
98
-
99
- cfg = BootstrapConfig(
100
- run_id="run-123",
101
- capability_token="secret",
102
- config_path=cfg_file,
103
- server_url="http://server",
104
- run_root=tmp_path,
105
- )
106
- orchestrator = BootstrapOrchestrator(cfg)
107
- code = orchestrator.run()
108
-
109
- assert code == 0
110
- assert heartbeats, "expected at least one heartbeat"
111
- assert logs, "expected log forwarding"
112
- assert posted_artifacts == artifacts
113
- assert completions and not errors
114
- assert "PATH" in captured_env
115
- assert captured_env.get("FLYWHEEL_RUN_ID") == "run-123"
116
- assert captured_env.get("FLYWHEEL_RUN_TOKEN") == "secret"
117
- assert captured_env.get("FLYWHEEL_SERVER") == "http://server"
118
- assert "CODEX_HOME" in captured_env
119
- # With sandbox_mode=None, no sandbox flags should be added
120
- assert len(captured_extra_flags) == 0
121
-
122
-
123
- def test_resolve_workspace_rejects_outside_writable(monkeypatch, tmp_path):
124
- cfg_file = tmp_path / "config.toml"
125
- cfg_file.write_text("", encoding="utf-8")
126
-
127
- orchestrator = BootstrapOrchestrator(
128
- BootstrapConfig(
129
- run_id="run-1",
130
- capability_token="token",
131
- config_path=cfg_file,
132
- server_url="http://server",
133
- run_root=tmp_path,
134
- )
135
- )
136
- orchestrator.user_config = UserConfig(
137
- raw={},
138
- working_dir=tmp_path / "work",
139
- sandbox_mode="workspace-write",
140
- approval_policy="unless-allow-listed",
141
- oss_provider=None,
142
- writable_roots=(tmp_path / "other",),
143
- workspace_instructions="instr",
144
- instructions_source="inline",
145
- warnings=(),
146
- )
147
-
148
- with pytest.raises(SystemExit):
149
- orchestrator._resolve_workspace()
150
-
151
-
152
- def test_resolve_workspace_expands_run_id_placeholder(tmp_path) -> None:
153
- cfg_file = tmp_path / "config.toml"
154
- cfg_file.write_text("", encoding="utf-8")
155
-
156
- orchestrator = BootstrapOrchestrator(
157
- BootstrapConfig(
158
- run_id="run-xyz",
159
- capability_token="token",
160
- config_path=cfg_file,
161
- server_url="http://server",
162
- run_root=tmp_path,
163
- )
164
- )
165
-
166
- templated = tmp_path / "runs" / "<run_id>"
167
- orchestrator.user_config = UserConfig(
168
- raw={},
169
- working_dir=templated,
170
- sandbox_mode="workspace-write",
171
- approval_policy="unless-allow-listed",
172
- oss_provider=None,
173
- writable_roots=(tmp_path,),
174
- workspace_instructions="instr",
175
- instructions_source="inline",
176
- warnings=(),
177
- )
178
-
179
- orchestrator._resolve_workspace()
180
- assert orchestrator.workspace == (tmp_path / "runs" / "run-xyz").resolve()