Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
356 changes: 176 additions & 180 deletions app/data/action/living_ui_actions.py

Large diffs are not rendered by default.

34 changes: 19 additions & 15 deletions app/data/agent_file_system_template/AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1159,17 +1159,19 @@ living_ui_scaffold(name, description, ...) Create a project: copies the bluepri
living_ui_list_projects() {id, name, description, status, url, path, delivered}.
Resolve "the app" to an id here, never by filesystem search.
living_ui_notify_ready(project_id) Launch pipeline: install deps → validation gate (types,
build, migrations, ops manifest) → boot PocketBase +
frontend → health check. On a delivered app it boots a
STAGING copy (cloned data, hidden port), never the live app.
Gate failures come back in test_errors. Circuit breaker:
identical error ×3 warns, ×6 stops.
living_ui_walk_verify(project_id) Headless-browser sub-agent drives the running app
build, migrations, ops manifest) → boot the DEV environment
(your code on a hidden port with a FRESH schema-only DB —
migrations replay; live data is never cloned). The live app
(if any) keeps running untouched. Gate failures come back
in test_errors. Circuit breaker: identical error ×3 warns,
×6 stops.
living_ui_walk_verify(project_id) Headless-browser sub-agent drives the DEV instance
feature-by-feature against reference/requirements.md.
Verdicts: pass | incomplete | defects | blocked | unparseable.
A clean pass is the ONLY way a build completes: first build
→ project marked delivered; delivered app → staging flips
to live. 35-minute ceiling.
A clean pass is the ONLY way a change completes: it PROMOTES
the code to the live app (first build → live DB created
fresh from migrations; update → new migrations apply to the
real data) and destroys the dev copy. 35-minute ceiling.
living_ui_restart(project_id) Stop + full launch pipeline.
living_ui_report_progress(project_id, ...) Creation-phase progress. No-op once the project runs.
living_ui_usage(project_id) Returns the project's operating manual: path, live data
Expand Down Expand Up @@ -1200,15 +1202,17 @@ node <craftbot_root>/living-ui/tools/src/cli.ts run <project_path> <op-name> --
node <craftbot_root>/living-ui/tools/src/cli.ts ops <project_path>
```

`living_ui_usage(project_id)` returns the exact commands for a given project. Use `living_ui_http` only when the CLI cannot do it. Writes to a delivered app's real data outside a staging arc are refused.
`living_ui_usage(project_id)` returns the exact commands for a given project. Use `living_ui_http` only when the CLI cannot do it. While a code change is in progress, agent writes are routed to the dev instance — test writes to an app's real data are refused.

### Build / delivery lifecycle
### Build / delivery lifecycle (one flow for builds and modifies)

```
scaffold → dedicated build session writes code → notify_ready (validation gate + boot)
→ walk_verify pass → delivered (live URL announced by the factory host)
modify a delivered app → changes go to a STAGING clone on a hidden port
→ notify_ready boots staging → walk_verify pass → staging flips to live
write code in the project dir → notify_ready (validation gate + boot of the
DEV env: code copy, hidden port, fresh schema-only DB)
→ walk_verify drives the dev instance → clean pass PROMOTES:
live app boots the new code (first build: live DB created fresh from
migrations; update: new migrations apply to real data), dev copy
destroyed, ready announced by the factory host
```

- The factory host owns retries, fix-mission dispatch, and the "ready" announcement. Do not author success status messages for a build yourself.
Expand Down
102 changes: 72 additions & 30 deletions app/factory/host_craftbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,23 +104,20 @@ def _sidecar_write(self, project_id: str, data: Dict[str, Any]) -> None:
except Exception as e:
logger.debug(f"[FACTORY] sidecar write failed: {e}")

# ── delivery lifecycle (sidecar-backed; see plans/quizzical-greeting) ──
# "delivered" picks the data-safety mode for every later gate/verify:
# not delivered → the DB is disposable (verify live, restore the pristine
# baseline before announcing); delivered → real user data, everything runs
# in a staging copy. machine.terminal is NOT a substitute predicate:
# STUCK is terminal too, and marketplace/ZIP installs never get a machine.
def is_delivered(self, project_id: str) -> bool:
return bool(self._sidecar_read(project_id).get("delivered"))

def mark_delivered(self, project_id: str) -> None:
# ── delivery bookkeeping (sidecar-backed) ──────────────────────────────
# delivered_at is a COSMETIC timestamp (requirements-staleness warning,
# announce wording) — never a control input. The retired "delivered"
# flag used to pick the data-safety mode and went stale on real apps
# (2026-08-19: a two-week-in-use CRM read as never-delivered and its
# live DB was wiped by the first-delivery baseline restore). Every
# lifecycle predicate is now structural: lifecycle.live_db_exists().
def stamp_delivered(self, project_id: str) -> None:
side = self._sidecar_read(project_id)
if side.get("delivered"):
if side.get("delivered_at"):
return
side["delivered"] = True
side["delivered_at"] = time.time()
self._sidecar_write(project_id, side)
logger.info(f"[FACTORY] {project_id} marked delivered")
logger.info(f"[FACTORY] {project_id} delivery stamped")

# ── trigger-plane consent (spec TRIGGERS-PLAN) ─────────────────────────
# An app that can fire the agent can drive a session holding the user's
Expand Down Expand Up @@ -246,9 +243,38 @@ def delivered_at(self, project_id: str) -> Optional[float]:
except (TypeError, ValueError):
return None

# ── backup bookkeeping (sidecar-backed; spec living-ui-backups-plan) ───
# last_at drives the scheduler's due check (absent -> due now, which is
# also the catch-up-after-restart path); last_error is surfaced on the
# settings card and cleared by the next success.
def record_backup_ok(self, project_id: str, ts: float) -> None:
side = self._sidecar_read(project_id)
side["backup"] = {"last_at": float(ts)}
self._sidecar_write(project_id, side)

def record_backup_error(self, project_id: str, message: str) -> None:
side = self._sidecar_read(project_id)
state = side.get("backup")
state = dict(state) if isinstance(state, dict) else {}
state["last_error"] = str(message)[:500]
side["backup"] = state
self._sidecar_write(project_id, side)

def backup_state(self, project_id: str) -> Dict[str, Any]:
"""{"last_at": float|None, "last_error": str|None} — always both keys."""
state = self._sidecar_read(project_id).get("backup")
state = state if isinstance(state, dict) else {}
try:
last_at = (
float(state["last_at"]) if state.get("last_at") is not None else None
)
except (TypeError, ValueError):
last_at = None
return {"last_at": last_at, "last_error": state.get("last_error") or None}

def begin_modify(self, project_id: str) -> None:
"""A modify of a delivered app is starting (called from
launch_staging success — deterministic, never agent-dependent):
"""A modify of an app with a live database is starting (called from
open_dev success — deterministic, never agent-dependent):
re-arm the machine into MODIFYING so the whole supervision apparatus
(fix missions, caps, stuck reports, announcements) applies to the
modify exactly as it did to the build (LIFECYCLE-PLAN Phase 2).
Expand All @@ -257,7 +283,7 @@ def begin_modify(self, project_id: str) -> None:
VIRGIN (no history — machine_for mints BUILDING for marketplace/
imported apps that never had an arc). A non-terminal machine WITH
history means a modify/fix arc is already in flight — a fix
mission's notify_ready re-enters launch_staging — so no-op.
mission's notify_ready re-enters open_dev — so no-op.
"""
machine = self.machine_for(project_id)
if machine is None:
Expand All @@ -277,9 +303,10 @@ def begin_modify(self, project_id: str) -> None:
f"(generation {machine.generation})"
)

# The staging record is the single source of truth for "a staging copy of
# this app exists": actions redirect to it, the reaper kills from it, and
# clearing it is what ends staging mode.
# The staging record is the single source of truth for "a dev environment
# of this app exists": actions redirect to it, the reaper kills from it,
# and clearing it is what ends dev mode. (Key name "staging" is
# historical — kept so records from older versions stay readable.)
def get_staging_record(self, project_id: str) -> Optional[Dict[str, Any]]:
record = self._sidecar_read(project_id).get("staging")
return record if isinstance(record, dict) else None
Expand Down Expand Up @@ -516,6 +543,15 @@ def _compose_fix_brief(
if books
else ""
)
# The RUNNING instance is the dev environment when one is up —
# repro commands and logs must target it, not the (possibly not even
# running) live project dir.
_dev_rec = self.get_staging_record(project.id)
run_dir = (
str(_dev_rec.get("dir"))
if _dev_rec and _dev_rec.get("dir")
else str(project.path)
)
return f"""FIX MISSION {n} for Living UI '{project.name}' ({project.id}).

The independent verifier drove the app in a real browser. Each DEFECT below
Expand All @@ -526,12 +562,14 @@ def _compose_fix_brief(
{books_text}

=== HOW TO WORK (concrete) ===
1. Reproduce first: use the repro commands / exercise the failing op:
{cli} run {project.path} <op-name>
2. Read the evidence before theorizing: {project.path}/logs/pocketbase.log
1. Reproduce first: use the repro commands / exercise the failing op
against the RUNNING dev instance:
{cli} run {run_dir} <op-name>
2. Read the evidence before theorizing: {run_dir}/logs/pocketbase.log
(every causal claim must quote a log line; if you can't quote it, gather
more evidence — "unknown, investigating" is valid, a guess is not).
3. Fix in {project.path} (hooks/migrations/frontend per the ownership rules).
3. Fix in {project.path} (hooks/migrations/frontend per the ownership rules)
— living_ui_notify_ready syncs your edits into the dev instance.
4. Relaunch: living_ui_notify_ready(project_id="{project.id}")
5. Verify: living_ui_walk_verify(project_id="{project.id}")
The system tracks attempts and reports status to the user — do NOT send
Expand Down Expand Up @@ -565,16 +603,20 @@ def _emit_mission(
mission_id = f"{mission_kind}-{int(time.time())}"

# Modify-era missions (a reopened machine) get the modify skill —
# staging semantics and the never-touch-pb_data rules live there;
# dev-env semantics and the never-touch-pb_data rules live there;
# build-era missions keep the full creator workflow. A machine
# re-armed from a stuck BUILD (never delivered — no user data to
# protect) is still build-era despite generation > 0; a stuck
# MODIFY of a delivered app keeps the modify skill.
# re-armed from a stuck BUILD (no live database yet — no user data
# to protect) is still build-era despite generation > 0; a stuck
# MODIFY of an app with live data keeps the modify skill.
gens = machine.generations()
try:
from app.living_ui.lifecycle import has_live_env

_has_live = has_live_env(project, self)
except Exception:
_has_live = False
resumed_stuck_build = (
bool(gens)
and gens[-1].get("final_state") == STUCK
and not self.is_delivered(project.id)
bool(gens) and gens[-1].get("final_state") == STUCK and not _has_live
)
workflow_skill = (
"living-ui-modify"
Expand Down
26 changes: 14 additions & 12 deletions app/living_ui/integration_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,21 +479,23 @@ async def _handle_agent_request(self, request: web.Request) -> web.Response:
status=403,
)

# Gate 4 — era. Pre-delivery fires are agent/verifier test
# traffic (the walk verifier clicks ⚡ buttons), and a staging
# copy aliases to the real project id through the shared bridge
# token. Neither may start real agent runs — pre-delivery rows
# are wiped by the baseline restore anyway. NOT keyed on the
# factory machine: machine_for lazily creates a BUILDING machine
# for any project, so a marketplace install (which never builds
# here) would read as mid-arc forever.
delivered = host.is_delivered(project_id)
staging = host.get_staging_record(project_id)
if not delivered or staging:
# Gate 4 — era. While a DEV environment exists, fires are
# agent/verifier test traffic (the walk verifier clicks ⚡
# buttons in the dev instance, which aliases to the real project
# id through the shared bridge token) — they must not start real
# agent runs; the dev copy and its rows die at promote anyway.
# With no dev env there is nothing mid-change that could fire
# falsely: during a first build the live app does not run yet,
# and after a promote fires are legitimate operation. NOT keyed
# on the factory machine: machine_for lazily creates a BUILDING
# machine for any project, so a marketplace install (which never
# builds here) would read as mid-arc forever.
dev_env = host.get_staging_record(project_id)
if dev_env:
logger.info(
f"[INTEGRATION_BRIDGE] trigger fire deferred (era) "
f"project={project_id} trigger={trigger!r} "
f"delivered={delivered} staging={bool(staging)}"
f"dev_env=True"
)
return web.json_response(
{
Expand Down
47 changes: 47 additions & 0 deletions app/living_ui/lifecycle/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Unified Living UI lifecycle — dev/live environment separation.

One flow for first builds and modifies (spec:
docs/plans/living-ui-unified-lifecycle-plan.md, from the 2026-08-19 CRM
data-loss incident): every code change is developed and verified in a DEV
environment — a runtime copy of the project's code booted on a hidden port
with a FRESH, schema-only database (the migration chain replays at boot;
live data is never cloned). A clean verify PROMOTES: the real project boots
with the new code, new migrations apply to the real pb_data, and the dev
copy is destroyed.

The single invariant this package enforces:

Nothing writes to a live environment's pb_data except (a) PocketBase's
migration replay during Promoter.promote(), and (b) a USER-CONFIRMED
restore of a backup archive (manager.restore_backup, spec
docs/plans/living-ui-backups-requirements.md FR9 — reversible by
design: the pre-restore state is captured first, and the restore
aborts if that capture fails). The agent has no restore action.

There is no stored "delivered" mode flag — the one thing it used to decide
(first vs update promote) is derived from filesystem state via
live_db_exists(), which cannot go stale the way the sidecar flag did.
"""

from app.living_ui.lifecycle.backups import BackupEntry, BackupService, BackupStore
from app.living_ui.lifecycle.environment import (
DevInstance,
has_live_env,
live_db_exists,
)
from app.living_ui.lifecycle.lifecycle import AppLifecycle
from app.living_ui.lifecycle.promoter import Promoter
from app.living_ui.lifecycle.provisioner import DEV_PORT_RANGE, DevProvisioner

__all__ = [
"AppLifecycle",
"BackupEntry",
"BackupService",
"BackupStore",
"DevInstance",
"DevProvisioner",
"DEV_PORT_RANGE",
"Promoter",
"has_live_env",
"live_db_exists",
]
Loading
Loading