mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-05 15:20:40 +08:00
backlot phase 3: in-browser replay, live-run fixes, simulation driver
- replay mode: scrub a completed run from checkpoint history + event timestamps — stage rail rewinds, script/storyboard/renders appear at their real moments, generating shimmer replays; ~20s full-run playback - stage history_entries exposed in BoardState (powers replay + versions) - entrance choreography plays on first paint only (not on SSE refreshes) - live-run fixes found by driving a simulated production against the board: null rendered by native append, activity ticker showing closed starts as running, scene-id label handling for 'scene-N' ids - scripts/backlot_simulate_run.py: drives a fake production through the REAL contract (init_project, in_progress heartbeats, gated awaiting_human -> approved, per-scene events, growing manifest) — live-board verification + demo driver - backlot/README.md
This commit is contained in:
42
backlot/README.md
Normal file
42
backlot/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Backlot — the living storyboard
|
||||
|
||||
A read-only local board that shows a production happening: pipeline stages
|
||||
lighting up, the script as a screenplay page, the scene plan as a filmstrip
|
||||
that fills in as assets generate, decisions, spend, and activity — all
|
||||
derived from what the pipeline already writes to `projects/<id>/`.
|
||||
|
||||
```bash
|
||||
python -m backlot open <project-id> # start server if needed + open browser
|
||||
python -m backlot open # library view (all projects)
|
||||
python -m backlot serve --port 4750 # run the server in the foreground
|
||||
```
|
||||
|
||||
## How it stays live
|
||||
|
||||
No agent involvement. A `watchfiles` watcher on `projects/` publishes change
|
||||
notifications over SSE; the browser refetches board state. State sources:
|
||||
|
||||
| Board element | Disk source |
|
||||
|---|---|
|
||||
| identity / rail order | `project.json` + `pipeline_defs/<type>.yaml` |
|
||||
| stage states, gates, versions | `checkpoint_<stage>.json` + `history/` |
|
||||
| script card / modal | `artifacts/script.json` |
|
||||
| filmstrip cards | `scene_plan × script × asset_manifest` join |
|
||||
| generating shimmer, activity | `events.jsonl` (written by `BaseTool` instrumentation) |
|
||||
| cost meter | checkpoint `cost_snapshot` |
|
||||
| renders | `renders/*.mp4` (+ root-level mp4 heuristic) |
|
||||
|
||||
Projects without checkpoints degrade gracefully to a "what the watcher
|
||||
found" view — media, snapshots, renders.
|
||||
|
||||
**Replay**: a completed run can be scrubbed end-to-end (▶ REPLAY RUN on the
|
||||
board) — reconstructed from checkpoint history and event timestamps.
|
||||
|
||||
Try it without a real production:
|
||||
|
||||
```bash
|
||||
python scripts/backlot_simulate_run.py # live demo run (~1 min)
|
||||
python -m backlot open backlot-demo-run
|
||||
```
|
||||
|
||||
Design doc: `internal/design/LIVING_STORYBOARD.md`.
|
||||
@@ -147,6 +147,11 @@ def _build_stage_rail(
|
||||
"human_approved": cp.get("human_approved") if cp else None,
|
||||
"partial_progress": (cp.get("metadata") or {}).get("partial_progress") if cp else None,
|
||||
"versions": len(versions) + (1 if cp else 0),
|
||||
# Chronological status trail (history + current) — powers replay.
|
||||
"history_entries": (
|
||||
[{"status": v.get("status"), "timestamp": v.get("timestamp")} for v in versions]
|
||||
+ ([{"status": cp.get("status"), "timestamp": cp.get("timestamp")}] if cp else [])
|
||||
),
|
||||
}
|
||||
# Gate audit: a gated stage that completed without ever passing
|
||||
# through awaiting_human (current or archived) was gate-skipped.
|
||||
|
||||
@@ -514,3 +514,9 @@ body.replaying .live .dot { background: var(--blue); animation: none; }
|
||||
.hint { font-size: 12px; color: var(--text-3); padding: 10px 2px; }
|
||||
|
||||
a { color: inherit; }
|
||||
|
||||
/* entrance choreography plays only on first paint, not on every SSE refresh */
|
||||
body:not(.first) .slate, body:not(.first) .rail .stage,
|
||||
body:not(.first) .script-card, body:not(.first) .notice,
|
||||
body:not(.first) aside .panel, body:not(.first) .scene-card,
|
||||
body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; }
|
||||
|
||||
@@ -13,6 +13,8 @@ const player = document.getElementById("player");
|
||||
let state = null;
|
||||
let selectedStage = null; // stage drawer open for this stage name
|
||||
let activeRender = 0;
|
||||
let replay = null; // {t0, t1, t, playing} — replay mode when non-null
|
||||
let firstPaint = true;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// header slate
|
||||
@@ -46,16 +48,14 @@ function renderSlate(s) {
|
||||
const budget = spent + (s.cost.budget_remaining_usd ?? 0);
|
||||
const hasBudget = s.cost.budget_remaining_usd != null;
|
||||
const pct = hasBudget && budget > 0 ? Math.min(100, (spent / budget) * 100) : 0;
|
||||
cost.append(
|
||||
el("div", { class: "nums" }, el("b", {}, fmtMoney(spent)),
|
||||
hasBudget ? el("span", {}, ` / ${fmtMoney(budget)}`) : null),
|
||||
hasBudget
|
||||
? el("div", { class: "bar" }, el("i", {
|
||||
class: pct > 75 ? "warn" : "", style: `width:${pct}%`,
|
||||
}))
|
||||
: null,
|
||||
el("div", { class: "label" }, "generation spend"),
|
||||
);
|
||||
cost.append(el("div", { class: "nums" }, el("b", {}, fmtMoney(spent)),
|
||||
hasBudget ? el("span", {}, ` / ${fmtMoney(budget)}`) : ""));
|
||||
if (hasBudget) {
|
||||
cost.append(el("div", { class: "bar" }, el("i", {
|
||||
class: pct > 75 ? "warn" : "", style: `width:${pct}%`,
|
||||
})));
|
||||
}
|
||||
cost.append(el("div", { class: "label" }, "generation spend"));
|
||||
}
|
||||
|
||||
return el("header", { class: "slate" },
|
||||
@@ -271,15 +271,27 @@ function renderActivity(s) {
|
||||
const events = s.events || [];
|
||||
if (!events.length) return null;
|
||||
const body = el("div", { class: "panel-body" });
|
||||
const started = new Map();
|
||||
// A start is "running" only until a later finish/error for the same
|
||||
// tool+scene closes it — closed starts are dropped (the finish row tells
|
||||
// the story), unmatched starts render as live.
|
||||
const open = new Map();
|
||||
const rows = [];
|
||||
for (const ev of events) {
|
||||
if (ev.event === "start") started.set(`${ev.tool}:${ev.scene_id || ""}`, ev);
|
||||
const key = `${ev.tool}:${ev.scene_id || ""}`;
|
||||
if (ev.event === "start") {
|
||||
open.set(key, ev);
|
||||
} else {
|
||||
open.delete(key);
|
||||
rows.push(ev);
|
||||
}
|
||||
}
|
||||
for (const ev of events.slice(-10).reverse()) {
|
||||
rows.push(...open.values());
|
||||
rows.sort((a, b) => String(a.ts).localeCompare(String(b.ts)));
|
||||
for (const ev of rows.slice(-10).reverse()) {
|
||||
let statusEl;
|
||||
if (ev.event === "finish") {
|
||||
statusEl = el("span", { class: "status ok" },
|
||||
`✓${ev.duration_s != null ? ` ${ev.duration_s}s` : ""}`);
|
||||
statusEl = el("span", { class: `status ${ev.success === false ? "err" : "ok"}` },
|
||||
`${ev.success === false ? "✕" : "✓"}${ev.duration_s != null ? ` ${ev.duration_s.toFixed ? ev.duration_s.toFixed(1) : ev.duration_s}s` : ""}${ev.cost_usd ? ` ${fmtMoney(ev.cost_usd)}` : ""}`);
|
||||
} else if (ev.event === "error") {
|
||||
statusEl = el("span", { class: "status err" }, "✕");
|
||||
} else {
|
||||
@@ -301,13 +313,20 @@ function renderActivity(s) {
|
||||
// storyboard filmstrip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sceneLabel(id) {
|
||||
// "sc4" → "SC 04", "scene-11" → "SC 11", anything else → uppercased id
|
||||
const m = String(id).match(/(\d+)\s*$/);
|
||||
if (m) return `SC ${m[1].padStart(2, "0")}`;
|
||||
return String(id).toUpperCase().slice(0, 10);
|
||||
}
|
||||
|
||||
function sceneCard(s, card) {
|
||||
const dur = card.duration_seconds;
|
||||
const width = Math.max(132, Math.min(300, 70 + (dur || 3) * 26));
|
||||
const wrap = el("div", { class: "scene-card", style: `width:${width}px` });
|
||||
|
||||
const slate = el("div", { class: "sc-slate" },
|
||||
el("span", { class: "num" }, `SC ${String(card.id).replace(/^sc/i, "").padStart(2, "0")}`),
|
||||
el("span", { class: "num" }, sceneLabel(card.id)),
|
||||
card.takes.length > 1 ? el("span", { class: "take" }, `T${card.takes.length}`) : null,
|
||||
card.hero_moment ? el("span", { class: "hero" }, "★ HERO") : null,
|
||||
el("span", { class: "dur" }, fmtDuration(dur)),
|
||||
@@ -475,17 +494,154 @@ function renderAwaitingNotice(s) {
|
||||
"The agent is paused at this gate — reply ", el("b", {}, "in chat"), " to approve or request changes."));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// replay — scrub a completed run from its timestamps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ts = (iso) => { const t = Date.parse(iso); return Number.isFinite(t) ? t : null; };
|
||||
|
||||
function replayBounds(s) {
|
||||
const moments = [];
|
||||
for (const st of s.stages) {
|
||||
for (const h of st.history_entries || []) {
|
||||
const t = ts(h.timestamp);
|
||||
if (t) moments.push(t);
|
||||
}
|
||||
}
|
||||
for (const ev of s.events || []) {
|
||||
const t = ts(ev.ts);
|
||||
if (t) moments.push(t);
|
||||
}
|
||||
if (moments.length < 2) return null;
|
||||
return { t0: Math.min(...moments), t1: Math.max(...moments) };
|
||||
}
|
||||
|
||||
function stateAt(s, T) {
|
||||
const view = structuredClone(s);
|
||||
for (const st of view.stages) {
|
||||
const past = (st.history_entries || []).filter((h) => ts(h.timestamp) != null && ts(h.timestamp) <= T);
|
||||
if (!past.length) {
|
||||
st.status = "pending"; st.review = null; st.timestamp = null;
|
||||
st.gate_skipped = false; st.partial_progress = null;
|
||||
} else {
|
||||
const cur = past[past.length - 1];
|
||||
st.status = cur.status || "pending";
|
||||
st.timestamp = cur.timestamp;
|
||||
}
|
||||
}
|
||||
view.events = (view.events || []).filter((ev) => ts(ev.ts) != null && ts(ev.ts) <= T);
|
||||
|
||||
// Storyboard: visuals appear as their scene finishes (events) or when the
|
||||
// assets stage has completed as of T (legacy runs without events).
|
||||
if (view.storyboard) {
|
||||
const assetsStage = view.stages.find((x) => x.name === "assets");
|
||||
const assetsDone = assetsStage && assetsStage.status === "completed";
|
||||
const finished = new Set();
|
||||
const startedNow = new Map();
|
||||
for (const ev of view.events) {
|
||||
if (!ev.scene_id) continue;
|
||||
if (ev.event === "finish") { finished.add(ev.scene_id); startedNow.delete(ev.scene_id); }
|
||||
else if (ev.event === "start") startedNow.set(ev.scene_id, ev);
|
||||
else if (ev.event === "error") startedNow.delete(ev.scene_id);
|
||||
}
|
||||
const scenePlanStage = view.stages.find((x) => x.name === "scene_plan");
|
||||
const scenePlanDone = scenePlanStage && ["completed", "awaiting_human"].includes(scenePlanStage.status);
|
||||
if (!scenePlanDone) {
|
||||
view.storyboard = null;
|
||||
} else {
|
||||
for (const card of view.storyboard.scenes) {
|
||||
const visible = assetsDone || finished.has(card.id);
|
||||
if (!visible) { card.visual = null; card.takes = []; card.audio = []; }
|
||||
card.generating = startedNow.has(card.id);
|
||||
card.generating_tool = (startedNow.get(card.id) || {}).tool;
|
||||
}
|
||||
}
|
||||
const scriptStage = view.stages.find((x) => x.name === "script");
|
||||
if (!(scriptStage && ["completed", "awaiting_human"].includes(scriptStage.status))) {
|
||||
delete view.artifacts.script;
|
||||
}
|
||||
const composeStage = view.stages.find((x) => x.name === "compose");
|
||||
if (!(composeStage && composeStage.status === "completed")) {
|
||||
view.media.renders = [];
|
||||
}
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
function renderReplayBar(s) {
|
||||
const bounds = replayBounds(s);
|
||||
if (!bounds) return null;
|
||||
if (!replay) {
|
||||
// collapsed: just the entry button
|
||||
return el("div", { class: "replay-bar", style: "justify-content:flex-end" },
|
||||
el("span", { class: "rp-time" }, "scrub the whole run"),
|
||||
el("span", { class: "rp-btn", onclick: startReplay }, "▶ REPLAY RUN"));
|
||||
}
|
||||
const pos = (replay.t - replay.t0) / Math.max(1, replay.t1 - replay.t0);
|
||||
return el("div", { class: "replay-bar" },
|
||||
el("span", { class: "rp-btn", onclick: toggleReplayPlay }, replay.playing ? "❚❚" : "▶"),
|
||||
el("input", {
|
||||
type: "range", min: "0", max: "1000", value: String(Math.round(pos * 1000)),
|
||||
oninput: (e) => {
|
||||
replay.t = replay.t0 + (Number(e.target.value) / 1000) * (replay.t1 - replay.t0);
|
||||
replay.playing = false;
|
||||
render();
|
||||
},
|
||||
}),
|
||||
el("span", { class: "rp-time" },
|
||||
new Date(replay.t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })),
|
||||
el("span", { class: "rp-btn", onclick: stopReplay }, "✕ LIVE"),
|
||||
);
|
||||
}
|
||||
|
||||
function startReplay() {
|
||||
const bounds = replayBounds(state);
|
||||
if (!bounds) return;
|
||||
replay = { ...bounds, t: bounds.t0, playing: true };
|
||||
document.body.classList.add("replaying");
|
||||
tickReplay();
|
||||
render();
|
||||
}
|
||||
|
||||
function stopReplay() {
|
||||
replay = null;
|
||||
document.body.classList.remove("replaying");
|
||||
render();
|
||||
}
|
||||
|
||||
function toggleReplayPlay() {
|
||||
if (!replay) return;
|
||||
replay.playing = !replay.playing;
|
||||
if (replay.playing) tickReplay();
|
||||
render();
|
||||
}
|
||||
|
||||
function tickReplay() {
|
||||
if (!replay || !replay.playing) return;
|
||||
// A full run replays in ~20 seconds regardless of real duration
|
||||
// (10 renders/second — full re-render per tick, keep it modest).
|
||||
const step = (replay.t1 - replay.t0) / 200;
|
||||
replay.t = Math.min(replay.t1, replay.t + step);
|
||||
if (replay.t >= replay.t1) replay.playing = false;
|
||||
render();
|
||||
if (replay.playing) setTimeout(tickReplay, 100);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// page assembly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function render() {
|
||||
if (!state) return;
|
||||
const s = state;
|
||||
const s = replay ? stateAt(state, replay.t) : state;
|
||||
document.title = `Backlot — ${s.title}`;
|
||||
document.body.classList.toggle("first", firstPaint);
|
||||
firstPaint = false;
|
||||
app.innerHTML = "";
|
||||
app.append(renderSlate(s));
|
||||
app.append(renderRail(s));
|
||||
const replayBar = renderReplayBar(state);
|
||||
if (replayBar) app.append(replayBar);
|
||||
const drawer = renderDrawer(s);
|
||||
if (drawer) app.append(drawer);
|
||||
const awaitingNotice = renderAwaitingNotice(s);
|
||||
|
||||
161
scripts/backlot_simulate_run.py
Normal file
161
scripts/backlot_simulate_run.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Simulate a pipeline run on disk to exercise the Backlot live board.
|
||||
|
||||
Drives a fake production through the REAL contract — init_project,
|
||||
in_progress checkpoints, gated awaiting_human states, tool events,
|
||||
progressively-written artifacts — so the board can be watched updating live.
|
||||
Also useful as a demo driver.
|
||||
|
||||
python scripts/backlot_simulate_run.py [--project backlot-demo-run]
|
||||
[--fast] [--cleanup]
|
||||
|
||||
--fast compresses waits to ~0.3s (for automated verification)
|
||||
--cleanup removes the project directory at the end
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from lib.checkpoint import PROJECTS_DIR, init_project, write_checkpoint
|
||||
from lib.events import emit_event
|
||||
|
||||
SCENES = [
|
||||
("sc1", "Opening — a lighthouse at dusk", 0, 4, "The coast holds its breath."),
|
||||
("sc2", "The beam sweeps the water", 4, 9, "Every night, the same promise."),
|
||||
("sc3", "A storm builds offshore", 9, 15, "Until the night the light went out."),
|
||||
("sc4", "The keeper climbs the stairs", 15, 21, "Someone still has to climb."),
|
||||
]
|
||||
|
||||
|
||||
def artifacts_for(project_id: str) -> dict:
|
||||
script = {
|
||||
"version": "1.0",
|
||||
"title": "The Last Lighthouse",
|
||||
"total_duration_seconds": 21,
|
||||
"sections": [
|
||||
{"id": f"s{i+1}", "label": desc.split("—")[0].strip(), "text": narration,
|
||||
"start_seconds": s0, "end_seconds": s1}
|
||||
for i, (sid, desc, s0, s1, narration) in enumerate(SCENES)
|
||||
],
|
||||
}
|
||||
scene_plan = {
|
||||
"version": "1.0",
|
||||
"scenes": [
|
||||
{"id": sid, "type": "generated", "description": desc,
|
||||
"start_seconds": s0, "end_seconds": s1,
|
||||
"script_section_id": f"s{i+1}",
|
||||
"hero_moment": sid == "sc3",
|
||||
"required_assets": [{"type": "image", "description": desc, "source": "generate"}]}
|
||||
for i, (sid, desc, s0, s1, _n) in enumerate(SCENES)
|
||||
],
|
||||
}
|
||||
return {"script": script, "scene_plan": scene_plan}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--project", default="backlot-demo-run")
|
||||
parser.add_argument("--fast", action="store_true")
|
||||
parser.add_argument("--cleanup", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
wait = 0.3 if args.fast else 2.5
|
||||
pid = args.project
|
||||
pdir = PROJECTS_DIR / pid
|
||||
if pdir.exists():
|
||||
shutil.rmtree(pdir)
|
||||
|
||||
print(f"[sim] init_project {pid}")
|
||||
init_project(pid, title="The Last Lighthouse", pipeline_type="cinematic",
|
||||
style_playbook="clean-professional")
|
||||
art = artifacts_for(pid)
|
||||
|
||||
def save_artifact(name: str, data: dict) -> None:
|
||||
path = pdir / "artifacts" / f"{name}.json"
|
||||
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
def cp(stage: str, status: str, artifacts: dict, **kw) -> None:
|
||||
write_checkpoint(PROJECTS_DIR, pid, stage, status, artifacts,
|
||||
pipeline_type="cinematic", **kw)
|
||||
print(f"[sim] checkpoint {stage} -> {status}")
|
||||
time.sleep(wait)
|
||||
|
||||
# research auto-proceeds (schema-valid fixture from the contract tests)
|
||||
cp("research", "in_progress", {})
|
||||
from tests.contracts.test_phase0_contracts import sample_artifact
|
||||
brief = sample_artifact("research_brief")
|
||||
brief["topic"] = "The Last Lighthouse"
|
||||
cp("research", "completed", {"research_brief": brief})
|
||||
|
||||
# script gates: awaiting_human -> approved
|
||||
cp("script", "in_progress", {})
|
||||
save_artifact("script", art["script"])
|
||||
cp("script", "awaiting_human", {"script": art["script"]},
|
||||
review={"round": 1, "decision": "pass", "critical": 0, "suggestions": 1,
|
||||
"nitpicks": 0, "summary": "Hook is strong; tightened s3."})
|
||||
time.sleep(wait) # "user reads the script on the board"
|
||||
cp("script", "completed", {"script": art["script"]}, human_approved=True)
|
||||
|
||||
# scene_plan gates too
|
||||
cp("scene_plan", "in_progress", {})
|
||||
save_artifact("scene_plan", art["scene_plan"])
|
||||
cp("scene_plan", "awaiting_human", {"scene_plan": art["scene_plan"]})
|
||||
time.sleep(wait)
|
||||
cp("scene_plan", "completed", {"scene_plan": art["scene_plan"]}, human_approved=True)
|
||||
|
||||
# assets: per-scene tool events + growing manifest + partial progress
|
||||
cp("assets", "in_progress", {})
|
||||
manifest = {"version": "1.0", "assets": [], "total_cost_usd": 0.0}
|
||||
done_ids = []
|
||||
from PIL import Image, ImageDraw
|
||||
palette = [(24, 32, 48), (40, 30, 60), (60, 24, 24), (20, 48, 40)]
|
||||
for i, (sid, desc, _s0, _s1, _n) in enumerate(SCENES):
|
||||
emit_event(pdir, {"tool": "flux_image", "event": "start", "scene_id": sid})
|
||||
print(f"[sim] generating {sid}…")
|
||||
time.sleep(wait * 1.5)
|
||||
rel = f"assets/images/{sid}.png"
|
||||
img = Image.new("RGB", (640, 360), palette[i % 4])
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.text((20, 160), f"{sid} — {desc[:40]}", fill=(230, 225, 210))
|
||||
img.save(pdir / rel)
|
||||
emit_event(pdir, {"tool": "flux_image", "event": "finish", "scene_id": sid,
|
||||
"success": True, "cost_usd": 0.05, "duration_s": wait * 1.5,
|
||||
"output_path": rel})
|
||||
manifest["assets"].append({
|
||||
"id": f"img_{sid}", "type": "image", "path": rel, "scene_id": sid,
|
||||
"source_tool": "flux_image", "model": "flux-sim", "cost_usd": 0.05,
|
||||
"prompt": desc, "quality_score": 0.88,
|
||||
})
|
||||
manifest["total_cost_usd"] = round(manifest["total_cost_usd"] + 0.05, 2)
|
||||
save_artifact("asset_manifest", manifest)
|
||||
done_ids.append(sid)
|
||||
write_checkpoint(PROJECTS_DIR, pid, "assets", "in_progress", {},
|
||||
pipeline_type="cinematic",
|
||||
metadata={"partial_progress": {"completed_scene_ids": done_ids}},
|
||||
cost_snapshot={"total_spent_usd": manifest["total_cost_usd"],
|
||||
"total_reserved_usd": 0.0,
|
||||
"budget_remaining_usd": 5 - manifest["total_cost_usd"]})
|
||||
# assets gate (the storyboard review)
|
||||
cp("assets", "awaiting_human", {"asset_manifest": manifest},
|
||||
cost_snapshot={"total_spent_usd": manifest["total_cost_usd"],
|
||||
"total_reserved_usd": 0.0,
|
||||
"budget_remaining_usd": 5 - manifest["total_cost_usd"]})
|
||||
time.sleep(wait)
|
||||
cp("assets", "completed", {"asset_manifest": manifest}, human_approved=True)
|
||||
|
||||
print(f"[sim] done — board at http://127.0.0.1:4750/p/{pid}")
|
||||
if args.cleanup:
|
||||
shutil.rmtree(pdir)
|
||||
print("[sim] cleaned up")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user