Skip to content
Merged
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
46 changes: 46 additions & 0 deletions app/agent_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,23 @@ async def react(self, trigger: Trigger) -> None:
# for the model.
self._log_trigger_claim(trigger, session_id)

# FACTORY: a mission's RUN has actually started (vs. merely being
# queued). Without this marker, a run that later ends on a
# run_continuation trigger (which carries no mission id) could not
# be attributed to its mission — and a surrendered mission would
# silently suppress redispatch (observed: done machine with
# mission_id still set).
try:
mission_id = (trigger.payload or {}).get("factory_mission_id") if trigger else None
if mission_id:
from app.factory.host_craftbot import get_factory_host

project_id = (trigger.payload or {}).get("project_id")
if project_id:
get_factory_host().mission_run_started(str(project_id), str(mission_id))
except Exception as e:
logger.debug(f"[FACTORY] mission-start marker failed: {e}")

# ----- Deferred user-message stream write -----
# User messages enter the event stream HERE — at the start of
# their own turn — not at arrival. This keeps the stream
Expand Down Expand Up @@ -1065,6 +1082,7 @@ async def _execute_actions(
# actually landed. See spec/A2APP-PLAN.md Phase 1 B10/B11.
self._report_living_ui_writes(session_id, actions_with_input, results)


return self._merge_action_outputs(results)

# Recognises a WRITE through the lui CLI. Reads (list/get) are ignored:
Expand Down Expand Up @@ -1245,6 +1263,20 @@ async def _finalize_turn(
# The claim gate is scoped to a run: what was written for THIS
# request says nothing about the next one.
self._lui_run_writes.pop(session.id, None)
# FACTORY Phase 1 (closes I6): if this run belonged to a Living UI
# build and the machine says work should be in flight but isn't,
# the machine redispatches a fresh mission. The agent surrendering
# is no longer a terminal event — the system carries the arc.
try:
lui_project = getattr(session, "living_ui_project_id", None)
if lui_project:
from app.factory.host_craftbot import get_factory_host

get_factory_host().on_run_end(
lui_project, (trigger.payload or {}) if trigger else {}
)
except Exception as e:
logger.debug(f"[FACTORY] run-end hook failed: {e}")
await self._on_run_end(session, trigger.payload or {})
return

Expand Down Expand Up @@ -1913,10 +1945,24 @@ def _build_living_ui_note(living_ui_project_id: str) -> str:
if schema
else f"Data model: run node {_lui_cli} data {proj.path} schema\n"
)
# Same principle as the schema: capabilities go IN the
# prompt. Three builds stubbed the user's email feature
# around an invented SMTP requirement because nothing in
# context said send_gmail exists.
caps = ""
try:
from app.living_ui.agent_view import capability_block

cap = capability_block()
if cap:
caps = cap + "\n"
except Exception:
caps = ""
return (
f"[INTERACTING WITH LIVING UI: {proj.name} ({living_ui_project_id})]\n"
f"Project path: {proj.path}\n"
f"{model}"
f"{caps}"
f"Values: dates as ISO or 'tomorrow'/'next monday' (the CLI resolves them);\n"
f"references by name, e.g. --list \"To Do\". Only set fields the user asked for.\n"
f"AFTER A SUCCESSFUL WRITE the user is ALREADY shown exactly what changed, in\n"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
input_schema={
"to": {
"type": "string",
"description": "Recipient email address.",
"description": (
"Recipient email address. OMIT to send to the user's own "
"address (the connected account) — never store or guess the "
"user's email."
),
"example": "user@example.com",
},
"subject": {
Expand Down Expand Up @@ -45,7 +49,8 @@ def send_gmail(input_data: dict) -> dict:
unwrap_envelope=True,
success_message="Email sent.",
fail_message="Failed to send email.",
to=input_data["to"],
# Omitted/empty `to` → the client sends to the account owner.
to=input_data.get("to"),
subject=input_data["subject"],
body=input_data["body"],
attachments=input_data.get("attachments"),
Expand Down
172 changes: 148 additions & 24 deletions app/data/action/living_ui_actions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
"""Living UI actions for agent to notify UI status and progress."""

import logging
from pathlib import Path

from agent_core import action

logger = logging.getLogger(__name__)


@action(
name="living_ui_scaffold",
Expand Down Expand Up @@ -268,6 +273,14 @@ async def living_ui_notify_ready(input_data: dict) -> dict:

# Launched, healthy, smoke-passed — but NOT yet feature-verified.
# Verification is its own visible step: living_ui_walk_verify.
# Tell the machine the pipeline is clean → it now expects a
# verifier verdict (and will redispatch if this run just stops).
try:
from app.factory.host_craftbot import get_factory_host

get_factory_host().report_launch_success(project_id)
except Exception:
pass
return {
"status": "success",
"message": (
Expand Down Expand Up @@ -452,52 +465,163 @@ async def living_ui_walk_verify(input_data: dict) -> dict:
except Exception:
pass

# Distinguish a genuinely blocked verifier (browser/tooling died —
# legitimate announce-with-warning) from an UNPARSEABLE report (the
# sub-agent produced nonsense): announcing on nonsense is the
# fail-open hole the factory closes (FACTORY-PLAN §3.3).
if kind == "blocked":
from app.living_ui.walk_verify import _reads_as_blocked

raw_text = str((report or {}).get("raw") or "")
if raw_text.strip() and not _reads_as_blocked(raw_text):
kind = "unparseable"

if kind == "unparseable":
from app.factory.host_craftbot import get_factory_host

decision = get_factory_host().report_verify(project_id, "unparseable")
if decision is not None and decision.payload.get("redo") == "verify":
return {
"status": "error",
"message": (
"The verifier's report was unparseable (not a browser "
"failure). Call living_ui_walk_verify once more."
),
}
return {
"status": "error",
"message": (
"The verifier's report was unparseable twice. The system has "
"reported the build as stuck to the user. End the run."
),
}

if kind == "defects":
# Observed misbehavior — the only thing that blocks a launch.
await manager.stop_project(project_id)
defects = report.get("defects") or []
raw = (report.get("raw") or "")[:2500]
# The browser report says WHAT failed; the server log says WHY
# (hook exceptions, bad queries — logged via the console.error
# pattern). Without it, agents invent causes: one read a bare
# failure and diagnosed "no outbound internet access".
#
# EVERYTHING LOCAL: action handlers run from REGISTRY-EXTRACTED
# SOURCE, not as this module — module-level imports/globals do
# not exist at execution time. A module-level `Path` silently
# broke this block once, and a module-level `logger` then took
# down every walk_verify call in a run.
server_log = ""
try:
from pathlib import Path as _Path

pb_log = _Path(str(project.path)) / "logs" / "pocketbase.log"
if pb_log.exists():
lines = pb_log.read_text(
encoding="utf-8", errors="replace"
).splitlines()[-400:]
# Errors FIRST, then newest lines: a naive tail once
# shipped realtime chatter while "cannot be blank" errors
# sat just above the 30-line window.
error_lines = [
l for l in lines
if any(k in l.lower() for k in ("error", "failed", "panic", "cannot be"))
][-25:]
tail = [l for l in lines[-8:] if l not in error_lines]
server_log = (
"\n\npocketbase.log (recent — the server-side causes):\n"
+ "\n".join(error_lines + tail)
)
else:
import logging as _logging

_logging.getLogger(__name__).warning(
f"[WALK_VERIFY] no pocketbase.log at {pb_log} — "
"defect report ships without server-side causes"
)
except Exception as e:
# Never break the report — but never eat the reason either.
try:
import logging as _logging

_logging.getLogger(__name__).warning(
f"[WALK_VERIFY] could not attach pocketbase.log: {e}"
)
except Exception:
pass
full_details = (
"The walk-verify report (a real browser drove the app):\n"
+ raw
+ server_log
)
# The MACHINE owns the fix arc now (FACTORY-PLAN Phase 1): it
# records the failure, applies caps, and dispatches a FRESH fix
# mission carrying this evidence. This run's job is over.
from app.factory.host_craftbot import get_factory_host

decision = get_factory_host().report_verify(
project_id, "defects", defects=defects, details=full_details,
walk_report=raw, server_log=server_log,
)
if decision is not None and decision.next_state == "stuck":
return {
"status": "error",
"message": (
f"Walk-verify FAILED: {len(defects) or 'some'} feature(s) "
"NOT working — and the retry cap is reached. The system "
"has reported the build as stuck to the user, with the "
"full history. Do NOT retry and do NOT send a status "
"message. End the run."
),
"test_errors": defects[:10] or [raw],
}
return {
"status": "error",
"message": (
f"Walk-verify FAILED: {len(defects) or 'some'} feature(s) "
"observed NOT working. The app was stopped."
"observed NOT working. The app was stopped. A FRESH fix "
"mission carrying the full evidence has been queued by the "
"system — do NOT fix in this run and do NOT send a status "
"message. End the run now."
),
"test_errors": defects[:10] or [raw],
"details": (
"The walk-verify report (a real browser drove the app):\n"
+ raw
+ "\n\nFix these features, relaunch with "
"living_ui_notify_ready, then call living_ui_walk_verify "
"again. Do NOT tell the user the app is ready."
),
}

# Clean verdict (pass / incomplete / tooling-blocked): announce.
# Clean verdict (pass / incomplete / tooling-blocked): the MACHINE
# announces to the user (FACTORY-PLAN §3.6 — no agent-authored
# status); this run just ends.
await broadcast_living_ui_ready(project_id, url, project.port)
if kind == "pass":
verified = (
f" ({passed_n} feature(s) walk-verified in a real browser)"
)
caveat = ""
elif kind == "incomplete":
verified = (
f" (walk-verify: {passed_n} passed; coverage INCOMPLETE — "
"some features NOT REACHED. Tell the user which features "
"were not walked; do NOT claim they were tested.)"
caveat = (
f"Coverage incomplete: {passed_n} feature(s) verified; some "
"were NOT exercised (see the report). Unverified features may "
"not work yet."
)
elif kind == "blocked":
verified = (
" (WARNING: walk-verify was BLOCKED — tooling/browser issue, "
"not an app defect. Launch passed smoke checks only. Tell "
"the user NOTHING was feature-verified: "
+ str((report or {}).get("raw") or "")[:200]
+ ")"
caveat = (
"The independent verifier could not run (browser/tooling "
"issue) — the app passed launch and smoke checks only; no "
"feature was browser-verified."
)
else:
verified = " (walk-verify unavailable — smoke checks only)"
caveat = "Verifier unavailable — smoke checks only."

from app.factory.host_craftbot import get_factory_host

get_factory_host().report_verify(
project_id, kind if kind in ("pass", "incomplete", "blocked") else "blocked",
url=url, verified=report.get("passed") or [], caveat=caveat,
)
return {
"status": "success",
"message": f"Living UI {project_id} is now ready at {url}{verified}",
"message": (
f"Living UI {project_id} is ready at {url}. The system has "
"announced this to the user (including any caveats). Do NOT "
"send your own summary — end the run, or answer only direct "
"questions."
),
}
except Exception as e:
return {"status": "error", "message": f"walk-verify failed to run: {str(e)}"}
Expand Down
7 changes: 7 additions & 0 deletions app/factory/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""The Factory (FACTORY-PLAN.md): deterministic orchestration, free intelligence.

Layering (enforced by check_imports.py):
engine/ generic durable-workflow core — imports stdlib ONLY
appfactory/ the app-creation domain pack — imports engine only
host (CraftBot: app/living_ui, app/agent_base) — imports this API
"""
4 changes: 4 additions & 0 deletions app/factory/appfactory/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from app.factory.appfactory.graph import ( # noqa: F401
BUILDING, FIXING, GATING, INTERVIEWING, LAUNCHING, MISSION_STATES,
MODIFYING, RESEARCHING, SPECIFYING, VERIFYING, transition,
)
9 changes: 9 additions & 0 deletions app/factory/appfactory/cookbooks/frontend_rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Frontend rules that keep verification green (copy-adapt)
- Call your own API RELATIVELY: fetch('/api/ops/refresh') — never absolute
http://127.0.0.1:<port> self-URLs (ports change; restarts race).
- No mutation ops on mount: refresh is user-triggered; data arrives via the
kit's realtime `useCollection` — never poll, never reload.
- Load-time reads must survive an EMPTY database (first-paint console errors
fail the launch verifier).
- Missing API values render as an honest empty/offline state — never `|| 0`
defaults (a zero you invent is a lie that passes review).
40 changes: 40 additions & 0 deletions app/factory/appfactory/cookbooks/integration_actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Using ANY CraftBot integration (Slack, Notion, GitHub, …) — one pattern

Every connected service is used the SAME way: `callAction` runs CraftBot's
own tested implementation with semantic params. You never call a provider's
API, never touch credentials, never install SDKs. The capability map in your
context lists the connected integrations and their key action names.

```js
const bridge = require(`${__hooks}/_craftbot_bridge.js`);
const res = bridge.callAction(
'<action_name>', // e.g. send_slack_message, create_notion_page
{ /* semantic params */ },
{ confirmIrreversible: true } // required for sends/posts/deletes
);
if (res.status < 200 || res.status >= 300) {
console.error('<action_name> failed:', res.error); // log from RESULT, never intent
}
```

DON'T KNOW THE PARAMS? Discover them for free with a dry-run — validation
errors name the action's real schema fields, and nothing executes:
```js
bridge.callAction('send_slack_message', {}, { confirmIrreversible: true, dryRun: true });
// → res.error lists the expected params (e.g. channel, message, thread_ts)
```
A passing dry-run with your real params = the live call will reach the
provider. Dry-run every path you cannot execute at build time (scheduled
posts, sends).

## Worked example — email (PROVEN live; adapt the same shape for others)
```js
const res = bridge.callAction(
'send_gmail',
{ subject: 'Daily digest', body: text }, // omit 'to' → the user's own inbox
{ confirmIrreversible: true }
);
```
Never hardcode recipients; never example.com addresses (bridge rejects them);
never build SMTP or OAuth — if you find yourself doing either, there is an
action for what you want.
16 changes: 16 additions & 0 deletions app/factory/appfactory/cookbooks/pocketbase_traps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# PocketBase 0.39 — the traps that break every guessed API (copy-adapt)
- Handlers run in ISOLATED VMs: file-level consts/functions are INVISIBLE in
routerAdd/cronAdd callbacks. Share code via a plain .js module +
`require(`${__hooks}/mod.js`)` INSIDE each callback.
- `res.json` is the ONLY body accessor for $http.send responses.
`JSON.parse(String(res.body))` throws (body is a Go byte slice).
- find helpers THROW on no rows (never return null): wrap in try/catch or use
`findRecordsByFilter(col, filter, sort, LIMIT, OFFSET)` and check .length.
A 404 from a route you declared = your handler threw, NOT a missing route.
- Signature: findRecordsByFilter(collection, filter, SORT, LIMIT, OFFSET).
- `new Record(collectionOBJECT)` — an id string nil-panics the process.
- Migrations: `migrate(upFn, downFn)` only (no global rollback); `fields:` not
`schema:`; NEVER edit/rename an applied migration — add a NEW file.
- `required: true` on number fields REJECTS 0 — measurements must be optional.
- No setTimeout at top level (undefined); scheduled work = cronAdd.
- Current API: e.app.save/delete/findRecordsByFilter — `$app.dao()` does not exist.
Loading
Loading