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.
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/PKG-INFO +1 -1
- flywheel_bootstrap-0.1.9.202601272108/bootstrap/artifacts.py +101 -0
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/constants.py +1 -0
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/orchestrator.py +102 -33
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/prompts.py +11 -1
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/pyproject.toml +1 -1
- flywheel_bootstrap-0.1.9.202601272108/tests/test_artifacts.py +154 -0
- flywheel_bootstrap-0.1.9.202601272108/tests/test_orchestrator.py +416 -0
- flywheel_bootstrap-0.1.9.202601271702/bootstrap/artifacts.py +0 -18
- flywheel_bootstrap-0.1.9.202601271702/tests/test_orchestrator.py +0 -180
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/.gitignore +0 -0
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/README.md +0 -0
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/__init__.py +0 -0
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/__main__.py +0 -0
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/config_loader.py +0 -0
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/git_ops.py +0 -0
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/install.py +0 -0
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/payload.py +0 -0
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/py.typed +0 -0
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/runner.py +0 -0
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/telemetry.py +0 -0
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap.sh +0 -0
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/examples/config.example.toml +0 -0
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/tests/test_entrypoint.py +0 -0
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/tests/test_git_ops.py +0 -0
- {flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/uv.lock +0 -0
|
@@ -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
|
|
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
|
-
#
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
675
|
-
|
|
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
|
|
{flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/prompts.py
RENAMED
|
@@ -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
|
|
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"}}
|
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/git_ops.py
RENAMED
|
File without changes
|
{flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/install.py
RENAMED
|
File without changes
|
{flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/payload.py
RENAMED
|
File without changes
|
{flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/py.typed
RENAMED
|
File without changes
|
{flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap/runner.py
RENAMED
|
File without changes
|
|
File without changes
|
{flywheel_bootstrap-0.1.9.202601271702 → flywheel_bootstrap-0.1.9.202601272108}/bootstrap.sh
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|