diff --git a/app/agent_base.py b/app/agent_base.py index a4e88089..bfdc4ff3 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -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 @@ -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: @@ -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 @@ -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" diff --git a/app/data/action/integrations/google_workspace/gmail_actions.py b/app/data/action/integrations/google_workspace/gmail_actions.py index a3283aa2..9f08a6ec 100644 --- a/app/data/action/integrations/google_workspace/gmail_actions.py +++ b/app/data/action/integrations/google_workspace/gmail_actions.py @@ -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": { @@ -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"), diff --git a/app/data/action/living_ui_actions.py b/app/data/action/living_ui_actions.py index 41710a95..d4e2375e 100644 --- a/app/data/action/living_ui_actions.py +++ b/app/data/action/living_ui_actions.py @@ -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", @@ -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": ( @@ -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)}"} diff --git a/app/factory/__init__.py b/app/factory/__init__.py new file mode 100644 index 00000000..fd397c7a --- /dev/null +++ b/app/factory/__init__.py @@ -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 +""" diff --git a/app/factory/appfactory/__init__.py b/app/factory/appfactory/__init__.py new file mode 100644 index 00000000..0c54f9ea --- /dev/null +++ b/app/factory/appfactory/__init__.py @@ -0,0 +1,4 @@ +from app.factory.appfactory.graph import ( # noqa: F401 + BUILDING, FIXING, GATING, INTERVIEWING, LAUNCHING, MISSION_STATES, + MODIFYING, RESEARCHING, SPECIFYING, VERIFYING, transition, +) diff --git a/app/factory/appfactory/cookbooks/frontend_rules.md b/app/factory/appfactory/cookbooks/frontend_rules.md new file mode 100644 index 00000000..e713b784 --- /dev/null +++ b/app/factory/appfactory/cookbooks/frontend_rules.md @@ -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: 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). diff --git a/app/factory/appfactory/cookbooks/integration_actions.md b/app/factory/appfactory/cookbooks/integration_actions.md new file mode 100644 index 00000000..1d9a671a --- /dev/null +++ b/app/factory/appfactory/cookbooks/integration_actions.md @@ -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( + '', // 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(' 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. diff --git a/app/factory/appfactory/cookbooks/pocketbase_traps.md b/app/factory/appfactory/cookbooks/pocketbase_traps.md new file mode 100644 index 00000000..803b4ab9 --- /dev/null +++ b/app/factory/appfactory/cookbooks/pocketbase_traps.md @@ -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. diff --git a/app/factory/appfactory/cookbooks/third_party_fetch.md b/app/factory/appfactory/cookbooks/third_party_fetch.md new file mode 100644 index 00000000..c64b680e --- /dev/null +++ b/app/factory/appfactory/cookbooks/third_party_fetch.md @@ -0,0 +1,25 @@ +# Third-party public APIs (PROVEN pattern — module + require-inside-handler) +```js +// pb/pb_hooks/source.js (module: its own scope IS visible internally) +const BASE = 'https://api.example-provider.com/v1'; // literal → recorded as egress +function fetchAll(app) { + const res = $http.send({ url: BASE + '/endpoint?param=1', method: 'GET', timeout: 20 }); + if (res.statusCode !== 200) throw new Error('source returned HTTP ' + res.statusCode); + const data = res.json; // ONLY correct accessor + // store via app.save(...); return what you stored +} +module.exports = { fetchAll }; + +// pb/pb_hooks/ops.pb.js +routerAdd('POST', '/api/ops/refresh', (e) => { + const src = require(`${__hooks}/source.js`); + try { return e.json(200, { updated: src.fetchAll(e.app).length }); } + catch (err) { console.error('refresh failed:', err); return e.json(502, { error: String(err) }); } +}); +cronAdd('sync', '*/15 * * * *', () => { + const src = require(`${__hooks}/source.js`); + try { src.fetchAll($app); } catch (err) { console.error('sync failed:', err); } +}); +``` +RESEARCH the provider's real endpoint/params first (never from memory); an +unreachable source = clean error + honest empty state, NEVER generated data. diff --git a/app/factory/appfactory/distill.py b/app/factory/appfactory/distill.py new file mode 100644 index 00000000..f35db284 --- /dev/null +++ b/app/factory/appfactory/distill.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +"""Distill raw verifier output + server evidence into DefectCards +(FACTORY-PLAN §3.5 / Phase 2). + +Pure code, deterministic — no ModelPort yet (Phase 3 adds an optional LLM +polish for candidate_cause/suggested_direction once the runner exists; the +mechanical distillation already carries the high-value components: location, +observed value with quotes, repro command, and evidence lines). + +Input is what the pipeline already produces: +- the walk-verify report ("- — FAIL — " lines) +- the errors-first pocketbase.log excerpt +- verify.ts console lines (HTTP-with-body, REQUEST FAILED with URL+cause) +""" + +from __future__ import annotations + +import re +from typing import List, Optional + +from app.factory.engine.cards import DefectCard + +_FAIL_LINE = re.compile(r"^-\s+(.{1,140}?)\s*[—–:]\s*FAIL\s*[—–:]\s*(.+)$") +_ROUTE = re.compile(r"(/api/[\w/.-]+)") +_OP_ROUTE = re.compile(r"/api/ops/([\w/-]+)") +# Server-side lines that name causes (the console.error convention + PB's own) +_CAUSE_HINT = re.compile( + r"(cannot be blank|is not defined|GoError|panic|ReferenceError|TypeError|" + r"invalid |failed:|REQUEST FAILED|ERR_CONNECTION|no rows|not permitted|" + r"is not granted|Dry-run found)", + re.IGNORECASE, +) + + +def _slug(text: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:48] or "feature" + + +def _evidence_lines(server_log: str, console: List[str]) -> List[str]: + lines: List[str] = [] + for line in (server_log or "").splitlines(): + if _CAUSE_HINT.search(line): + lines.append(line.strip()[:220]) + for line in console or []: + if _CAUSE_HINT.search(line) or line.startswith(("HTTP ", "REQUEST FAILED")): + lines.append(line.strip()[:220]) + # Dedup, keep order, cap. + seen, out = set(), [] + for line in lines: + if line not in seen: + seen.add(line) + out.append(line) + return out[:10] + + +def _match_evidence(observed: str, evidence: List[str]) -> Optional[str]: + """The evidence line most plausibly behind THIS feature's failure: + shares a route, an op name, or a distinctive token with the observation.""" + route = _ROUTE.search(observed) + for line in evidence: + if route and route.group(1) in line: + return line + tokens = [t for t in re.findall(r"[A-Za-z_]{6,}", observed)][:5] + for line in evidence: + if any(t.lower() in line.lower() for t in tokens): + return line + return evidence[0] if evidence else None + + +def distill( + walk_report: str, + server_log: str = "", + console_lines: Optional[List[str]] = None, + project_path: str = "", + cli: str = "node living-ui-v2/tools/src/cli.ts", +) -> List[DefectCard]: + """Raw report → cards. Every card gets a repro and quoted evidence; + candidate_cause is 'unknown' when no evidence line matches — a card must + never contain an unquoted theory (the Vite lesson).""" + console_lines = console_lines or [] + evidence = _evidence_lines(server_log, console_lines) + cards: List[DefectCard] = [] + + for raw_line in (walk_report or "").splitlines(): + m = _FAIL_LINE.match(raw_line.strip()) + if not m: + continue + feature, observed = m.group(1).strip(), m.group(2).strip() + best = _match_evidence(observed, evidence) + + route_m = _ROUTE.search(observed) or (_ROUTE.search(best) if best else None) + where = route_m.group(1) if route_m else "see evidence" + op_m = _OP_ROUTE.search(where) + if op_m: + repro = f"{cli} run {project_path} {op_m.group(1).replace('/', '-')}" + else: + repro = f"open the app and exercise: {feature}" + + if best: + cause = f"evidence points at: {best}" + direction = ( + "Reproduce with the repro command, confirm the quoted evidence " + "line recurs, then fix the code path it names. Re-check the " + "server log after your fix — the line must stop appearing." + ) + else: + cause = "unknown — no matching server/console evidence captured" + direction = ( + "Do NOT theorize. Reproduce with the repro command, then read " + f"{project_path}/logs/pocketbase.log and the op's response body " + "for the failing call; quote what you find before changing code." + ) + + cards.append( + DefectCard( + key=f"verify.{_slug(feature)}", + where=where, + observed=observed[:300], + expected=f"'{feature}' works as a user would expect (see report line)", + candidate_cause=cause[:300], + suggested_direction=direction, + repro=repro, + evidence=([best] if best else []) + [e for e in evidence if e != best][:4], + ) + ) + + if not cards and (walk_report or "").strip(): + # A failure with no parseable FAIL lines still needs a card — the + # machine's fingerprint/caps must never depend on report formatting. + cards.append( + DefectCard( + key="verify.unstructured-failure", + where="see evidence", + observed=(walk_report.strip()[:300]), + expected="the verifier reports per-feature verdicts", + candidate_cause="unknown — report had no parseable FAIL lines", + suggested_direction=( + "Reproduce the app's main flows manually via the CLI and " + "browser probe; read logs/pocketbase.log; quote evidence." + ), + repro=f"{cli} verify {project_path} --url ", + evidence=evidence[:5], + ) + ) + return cards diff --git a/app/factory/appfactory/graph.py b/app/factory/appfactory/graph.py new file mode 100644 index 00000000..cf48ab72 --- /dev/null +++ b/app/factory/appfactory/graph.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +"""The app-factory state graph (FACTORY-PLAN §3.3) — the domain pack's ONLY +knowledge the engine consumes: (state, outcome) → Decision. + +Pure function, no I/O, no host imports. Phase 1 wires real gate/verify +outcomes into it; Phase 0 pins the shape with tests so the wiring cannot +drift from the plan. +""" + +from __future__ import annotations + +from app.factory.engine.machine import ( + ANNOUNCE_READY, + ANNOUNCE_STUCK, + DISPATCH_MISSION, + DONE, + NONE, + STUCK, + Decision, + Outcome, +) + +# States (plan §3.3). Terminal names come from the engine. +INTERVIEWING = "interviewing" +SPECIFYING = "specifying" +BUILDING = "building" +RESEARCHING = "researching" +GATING = "gating" +LAUNCHING = "launching" +VERIFYING = "verifying" +FIXING = "fixing" +MODIFYING = "modifying" + +MISSION_STATES = (BUILDING, RESEARCHING, FIXING, MODIFYING) + + +def transition(state: str, outcome: Outcome) -> Decision: # noqa: C901 + """Pre-caps Decision for every (state, outcome) pair the plan defines. + The engine applies caps/escalation on top; the model decides nothing.""" + + # ── happy path ───────────────────────────────────────────────────────── + if state == INTERVIEWING and outcome.ok: + return Decision(SPECIFYING) + if state == SPECIFYING and outcome.ok: + return Decision(BUILDING, DISPATCH_MISSION, payload={"mission": "build"}) + if state == BUILDING and outcome.ok: + # The spec may demand external data with no covering action → research + # is a STATE the machine enters, not a step the agent remembers. + if outcome.payload.get("needs_research"): + return Decision( + RESEARCHING, DISPATCH_MISSION, + payload={"mission": "research", "topics": outcome.payload.get("topics", [])}, + ) + return Decision(GATING) + if state == RESEARCHING and outcome.ok: + return Decision(BUILDING, DISPATCH_MISSION, payload={"mission": "build"}) + if state == GATING and outcome.ok: + return Decision(LAUNCHING) + if state == LAUNCHING and outcome.ok: + return Decision(VERIFYING) + if state == VERIFYING and outcome.ok: + return Decision(DONE, ANNOUNCE_READY, payload=outcome.payload) + if state == MODIFYING and outcome.ok: + return Decision(GATING) + if state == FIXING and outcome.ok: + # A fix mission ended; truth comes from re-running the pipeline, + # never from the mission's self-assessment (E2). + return Decision(GATING) + + # ── failures ─────────────────────────────────────────────────────────── + if state == VERIFYING and outcome.payload.get("unknown_verdict"): + # Fail closed: NEVER announce on an unparseable verdict (§3.3). + if outcome.payload.get("already_retried"): + return Decision(STUCK, ANNOUNCE_STUCK, reason="verifier verdict unparseable twice") + return Decision(VERIFYING, NONE, reason="re-verify once", payload={"redo": "verify"}) + + if state in (GATING, LAUNCHING, VERIFYING, BUILDING, MODIFYING, FIXING) and not outcome.ok: + return Decision( + FIXING, DISPATCH_MISSION, + payload={"mission": "fix", "cards": outcome.payload.get("cards", [])}, + ) + if state in (INTERVIEWING, SPECIFYING, RESEARCHING) and not outcome.ok: + # Pre-code states failing is a host/wizard problem, not a fix mission. + return Decision(STUCK, ANNOUNCE_STUCK, reason=f"{state} failed: {outcome.payload}") + + return Decision(STUCK, ANNOUNCE_STUCK, reason=f"undefined transition: {state}/{outcome.ok}") diff --git a/app/factory/check_imports.py b/app/factory/check_imports.py new file mode 100644 index 00000000..b933b290 --- /dev/null +++ b/app/factory/check_imports.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +"""Import-direction gate (FACTORY-PLAN §3.1): engine ↛ appfactory ↛ host. + + engine/ may import: stdlib, app.factory.engine.* + appfactory/ may import: stdlib, app.factory.* + (hosts import app.factory; nothing here checks hosts) + +Run: python3 -m app.factory.check_imports (exit 1 on violation) +This is the mechanical guarantee that the factory stays a plug-and-play +component — the same philosophy as the kit's ownership hashes. +""" + +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +_STDLIB_HINT = None # py3.10+: sys.stdlib_module_names + + +def _imports_of(path: Path): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + yield alias.name, node.lineno + elif isinstance(node, ast.ImportFrom) and node.module: + yield node.module, node.lineno + + +def _violations(root: Path): + stdlib = set(getattr(sys, "stdlib_module_names", ())) + for layer, allowed_prefixes in ( + ("engine", ("app.factory.engine",)), + ("appfactory", ("app.factory",)), + ): + for py in sorted((root / layer).rglob("*.py")): + for module, lineno in _imports_of(py): + top = module.split(".")[0] + if top in stdlib: + continue + if any(module == p or module.startswith(p + ".") for p in allowed_prefixes): + continue + yield f"{py.relative_to(root.parent.parent)}:{lineno}: {layer} imports '{module}'" + + +def main() -> int: + root = Path(__file__).resolve().parent + problems = list(_violations(root)) + if problems: + print("FACTORY LAYERING VIOLATIONS (engine ↛ appfactory ↛ host):") + for p in problems: + print(" " + p) + return 1 + print("factory layering OK (engine: stdlib-only; appfactory: engine-only)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app/factory/engine/__init__.py b/app/factory/engine/__init__.py new file mode 100644 index 00000000..0b6171cb --- /dev/null +++ b/app/factory/engine/__init__.py @@ -0,0 +1,8 @@ +from app.factory.engine.cards import DefectCard, card_from_dict, validate_card # noqa: F401 +from app.factory.engine.machine import ( # noqa: F401 + ANNOUNCE_READY, ANNOUNCE_STUCK, DISPATCH_MISSION, DONE, NONE, STUCK, + Caps, Decision, Machine, Outcome, +) +from app.factory.engine.ports import ( # noqa: F401 + IntegrationPort, MissionDispatcher, ModelPort, NotifyPort, +) diff --git a/app/factory/engine/cards.py b/app/factory/engine/cards.py new file mode 100644 index 00000000..38fe103e --- /dev/null +++ b/app/factory/engine/cards.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +"""Defect cards (FACTORY-PLAN §3.5) — the ONLY thing a fix mission receives +about a failure. + +Format follows the strongest weak-model repair evidence (location + observed +value + suggested fix direction ⇒ +40–44pp terminal repair success on 8–14B +models; raw diagnostics ≈ baseline). Cards are machine-distilled from raw +reports/logs; missions never see the undistilled dumps. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List + +# Required string fields, in brief-rendering order. +_REQUIRED = ("key", "where", "observed", "expected", "candidate_cause", + "suggested_direction", "repro") + + +@dataclass +class DefectCard: + key: str # stable fingerprint source, e.g. "verify.feature.refresh-502" + where: str # route/file:line — the location component + observed: str # what actually happened, with the quoted value + expected: str # what passing looks like + candidate_cause: str # best supported theory ("unknown" is valid) + suggested_direction: str # the +40pp component: how to approach the fix + repro: str # ready-made command (I2: agents execute pasted calls) + evidence: List[str] = field(default_factory=list) # quoted log/console/request lines + + def fingerprint(self) -> str: + import hashlib + + return hashlib.sha1(self.key.encode("utf-8")).hexdigest()[:12] + + def render(self) -> str: + """Brief-ready text block. Terse and evidence-rich (ACI principle).""" + lines = [ + f"DEFECT {self.key}", + f" where: {self.where}", + f" observed: {self.observed}", + f" expected: {self.expected}", + f" cause?: {self.candidate_cause}", + f" direction: {self.suggested_direction}", + f" repro: {self.repro}", + ] + for e in self.evidence[:8]: + lines.append(f" evidence: {e}") + return "\n".join(lines) + + +def validate_card(data: Dict[str, Any]) -> List[str]: + """Problems list (empty = valid). Pure; used by the distiller to reject + malformed model output and retry.""" + problems: List[str] = [] + for key in _REQUIRED: + value = data.get(key) + if not isinstance(value, str) or not value.strip(): + problems.append(f"missing/empty required field '{key}'") + evidence = data.get("evidence", []) + if not isinstance(evidence, list) or not all(isinstance(e, str) for e in evidence): + problems.append("'evidence' must be a list of strings") + unknown = set(data) - set(_REQUIRED) - {"evidence"} + if unknown: + problems.append(f"unknown fields: {sorted(unknown)}") + return problems + + +def card_from_dict(data: Dict[str, Any]) -> DefectCard: + problems = validate_card(data) + if problems: + raise ValueError("; ".join(problems)) + return DefectCard(**{k: data[k] for k in _REQUIRED}, evidence=list(data.get("evidence", []))) diff --git a/app/factory/engine/machine.py b/app/factory/engine/machine.py new file mode 100644 index 00000000..a0ddedaf --- /dev/null +++ b/app/factory/engine/machine.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +"""The generic machine runtime (FACTORY-PLAN §3.3) — owns the ARC. + +Domain-agnostic: states are strings supplied by a domain pack's transition +function. The engine owns what weak models empirically cannot (I1/I6): +persistence, retry caps, fingerprint escalation, redispatch-on-surrender, +history. It decides nothing domain-specific and talks to nothing external — +pure stdlib, JSON-persisted, so a host or a future TS port carries it whole. + +The MODEL never decides "should I retry": outcomes come in, Decisions go out. +""" + +from __future__ import annotations + +import json +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +# Terminal states are engine-level concepts; domain graphs must use them. +DONE = "done" +STUCK = "stuck" +TERMINAL = (DONE, STUCK) + +# Actions a Decision can carry — the full vocabulary the host executes. +DISPATCH_MISSION = "dispatch_mission" +ANNOUNCE_READY = "announce_ready" +ANNOUNCE_STUCK = "announce_stuck" +NONE = "none" + + +@dataclass +class Outcome: + """What just happened, reported by gate/verifier/mission — never by the + model's self-assessment.""" + + state: str # state this outcome belongs to + ok: bool + fingerprint: Optional[str] = None # stable failure identity (card fingerprint) + payload: Dict[str, Any] = field(default_factory=dict) # cards, urls, reports + + +@dataclass +class Decision: + next_state: str + action: str = NONE + escalate: bool = False # same fingerprint seen again → richer brief + reason: str = "" + payload: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class Caps: + per_fingerprint: int = 3 + total_missions: int = 12 + + +# A domain pack supplies: (current_state, outcome) -> Decision (pre-caps). +TransitionFn = Callable[[str, Outcome], Decision] + + +class Machine: + def __init__( + self, + transition: TransitionFn, + store_path: Path, + initial_state: str, + caps: Optional[Caps] = None, + ) -> None: + self._transition = transition + self._store_path = Path(store_path) + self._caps = caps or Caps() + self._state: Dict[str, Any] = { + "state": initial_state, + "mission_id": None, + "total_missions": 0, + "defect_fingerprints": {}, + "history": [], + "caps": {"per_fingerprint": self._caps.per_fingerprint, + "total_missions": self._caps.total_missions}, + } + if self._store_path.exists(): + self._state.update(json.loads(self._store_path.read_text(encoding="utf-8"))) + + # ── persistence ──────────────────────────────────────────────────────── + def save(self) -> None: + self._store_path.parent.mkdir(parents=True, exist_ok=True) + self._store_path.write_text( + json.dumps(self._state, indent=2) + "\n", encoding="utf-8" + ) + + # ── introspection ────────────────────────────────────────────────────── + @property + def state(self) -> str: + return str(self._state["state"]) + + @property + def terminal(self) -> bool: + return self.state in TERMINAL + + @property + def active_mission(self) -> Optional[str]: + return self._state.get("mission_id") + + def history(self) -> List[Dict[str, Any]]: + return list(self._state["history"]) + + # ── the arc ──────────────────────────────────────────────────────────── + def advance(self, outcome: Outcome) -> Decision: + """Feed one outcome; get the machine's Decision, caps applied. + + Cap policy (§3.3): a repeating fingerprint first ESCALATES the brief + (more evidence, wider excerpts) and only then goes stuck; total + mission budget is absolute.""" + decision = self._transition(self.state, outcome) + + if not outcome.ok and outcome.fingerprint: + counts = self._state["defect_fingerprints"] + n = counts.get(outcome.fingerprint, 0) + 1 + counts[outcome.fingerprint] = n + if decision.action == DISPATCH_MISSION: + if n >= self._caps.per_fingerprint: + decision = Decision( + next_state=STUCK, + action=ANNOUNCE_STUCK, + reason=( + f"same failure {n}× (fingerprint {outcome.fingerprint}); " + f"cap {self._caps.per_fingerprint} reached" + ), + payload=decision.payload, + ) + elif n >= 2: + decision.escalate = True + + if decision.action == DISPATCH_MISSION: + total = self._state["total_missions"] + 1 + if total > self._caps.total_missions: + decision = Decision( + next_state=STUCK, + action=ANNOUNCE_STUCK, + reason=f"mission budget exhausted ({self._caps.total_missions})", + payload=decision.payload, + ) + else: + self._state["total_missions"] = total + + self._state["history"].append( + { + "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "state": self.state, + "ok": outcome.ok, + "fingerprint": outcome.fingerprint, + "next": decision.next_state, + "action": decision.action, + } + ) + self._state["state"] = decision.next_state + self.save() + return decision + + # ── redispatch-on-surrender (closes I6) ──────────────────────────────── + def mission_started(self, mission_id: str) -> None: + self._state["mission_id"] = mission_id + self.save() + + def mission_ended(self, mission_id: str) -> None: + if self._state.get("mission_id") == mission_id: + self._state["mission_id"] = None + self.save() + + def needs_redispatch(self) -> bool: + """True when work should be in flight but is not: non-terminal state + and no active mission. The host's run-end hook polls this — the + mechanism that makes surrender structurally impossible.""" + return not self.terminal and self.active_mission is None + + # ── honest stuck report (machine-composed, §3.6) ─────────────────────── + def stuck_report(self) -> str: + tried = [h for h in self._state["history"] if h["action"] == DISPATCH_MISSION] + lines = [ + "The build could not be completed automatically.", + f"State reached: {self.state}. Missions attempted: " + f"{self._state['total_missions']}/{self._caps.total_missions}.", + ] + fps = self._state["defect_fingerprints"] + if fps: + worst = max(fps.items(), key=lambda kv: kv[1]) + lines.append(f"Most persistent failure: {worst[0]} ({worst[1]}×).") + if tried: + lines.append(f"Last attempt: {tried[-1]['state']} → {tried[-1]['next']}.") + lines.append("The full attempt history is preserved for review.") + return "\n".join(lines) diff --git a/app/factory/engine/ports.py b/app/factory/engine/ports.py new file mode 100644 index 00000000..29daab3e --- /dev/null +++ b/app/factory/engine/ports.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +"""Factory engine ports (FACTORY-PLAN §3.2) — the ONLY doors to a host. + +The engine is the generic durable-workflow core ("deterministic +orchestration, free intelligence"). It may import NOTHING from the host or +from a domain pack; hosts hand it implementations of these Protocols. +`check_imports.py` enforces the direction mechanically. + +Frozen after Phase 0: additions require a FACTORY-PLAN amendment. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Protocol, runtime_checkable + + +@runtime_checkable +class ModelPort(Protocol): + """One raw LLM call. No sessions, no provider semantics — the engine + composes every prompt fresh (fresh-context per mission is the point).""" + + def complete( + self, + messages: List[Dict[str, str]], + schema: Optional[Dict[str, Any]] = None, + temperature: float = 0.0, + ) -> str: + """Return the model's text (JSON text when `schema` is given).""" + ... + + +@runtime_checkable +class IntegrationPort(Protocol): + """OPTIONAL host-managed integrations (Base44-pattern). Absent port ⇒ + apps build with third-party APIs only; briefs must say so honestly.""" + + def capabilities(self) -> Dict[str, Any]: + """{'connected': [...], 'actions': {name: {...schema...}}, 'facts': [...]}""" + ... + + def call( + self, + action: str, + params: Dict[str, Any], + confirm: bool = False, + dry_run: bool = False, + ) -> Dict[str, Any]: + """{'status': int, 'data'|'error': ...} — mirrors the bridge contract.""" + ... + + +@runtime_checkable +class NotifyPort(Protocol): + """The machine composes ALL user-facing status; the host only renders. + Event kinds (typed by `kind`): phase, defects, ready, stuck, question.""" + + def emit(self, event: Dict[str, Any]) -> None: ... + + +@runtime_checkable +class MissionDispatcher(Protocol): + """Runs ONE fresh-context mission and reports its outcome back to the + machine. Phase 1: CraftBot triggers/sessions. Phase 3: the ACI runner.""" + + def dispatch(self, mission: Dict[str, Any]) -> str: + """Start the mission (brief included); return a mission id.""" + ... diff --git a/app/factory/host_craftbot.py b/app/factory/host_craftbot.py new file mode 100644 index 00000000..b950884a --- /dev/null +++ b/app/factory/host_craftbot.py @@ -0,0 +1,441 @@ +# -*- coding: utf-8 -*- +"""CraftBot host adapter for the Factory (FACTORY-PLAN §5 Phase 1). + +HOST layer: may import app.* freely; nothing in engine/appfactory imports it. + +Phase-1 scope (deliberate, per plan): +- The machine owns the VERIFY→FIX arc, redispatch-on-surrender, caps, and all + user-facing ready/stuck status — the empirically failing parts. +- The tight gate-error loop inside one run (types → fix → relaunch) stays + agent-owned for now: it is per-STEP work and measured competent. Phase 3 + moves it onto the ACI runner. +- Missions are fresh triggers into the project's session, _escalate_crash + style (the proven prototype): concrete brief, ready-made calls, high + priority. Stream reset is NOT attempted in Phase 1 (plan R3): a fresh + concrete instruction alone was the "100% of observed cases" mechanism. +""" + +from __future__ import annotations + +import hashlib +import json +import time +from pathlib import Path +from typing import Any, Dict, List, Optional + +from app.factory.appfactory import ( + BUILDING, + FIXING, + GATING, + LAUNCHING, + VERIFYING, + transition, +) +from app.factory.engine import ( + ANNOUNCE_READY, + ANNOUNCE_STUCK, + DISPATCH_MISSION, + Caps, + Decision, + Machine, + Outcome, +) + +try: + from app.logger import logger +except Exception: # pragma: no cover + import logging + + logger = logging.getLogger(__name__) + +_REDISPATCH_MIN_INTERVAL_S = 20 # thrash guard on the run-end hook + + +def _fingerprint(text: str) -> str: + """Stable identity of a failure from its first meaningful line.""" + first = next((l.strip() for l in (text or "").splitlines() if l.strip()), "unknown") + return hashlib.sha1(first[:200].encode("utf-8")).hexdigest()[:12] + + +class FactoryHost: + """One per process; machines are per-project, persisted in the project.""" + + def __init__(self) -> None: + self._machines: Dict[str, Machine] = {} + + # ── machine access ───────────────────────────────────────────────────── + def _project(self, project_id: str): + from app.living_ui import get_living_ui_manager + + mgr = get_living_ui_manager() + return mgr.get_project(project_id) if mgr else None + + def machine_for(self, project_id: str) -> Optional[Machine]: + if project_id in self._machines: + return self._machines[project_id] + project = self._project(project_id) + if project is None: + return None + store = Path(project.path) / ".factory" / "state.json" + machine = Machine(transition, store, initial_state=BUILDING, caps=Caps()) + self._machines[project_id] = machine + return machine + + def _sidecar(self, project_id: str) -> Path: + project = self._project(project_id) + return Path(project.path) / ".factory" / "host.json" + + def _sidecar_read(self, project_id: str) -> Dict[str, Any]: + try: + return json.loads(self._sidecar(project_id).read_text(encoding="utf-8")) + except Exception: + return {} + + def _sidecar_write(self, project_id: str, data: Dict[str, Any]) -> None: + try: + path = self._sidecar(project_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + except Exception as e: + logger.debug(f"[FACTORY] sidecar write failed: {e}") + + # ── outcome reporting (called by the pipeline actions) ───────────────── + def _normalize_to(self, machine: Machine, target: str) -> None: + """Advance through implicit-ok states so outcomes land on the right + state (a mission that reaches walk_verify implicitly passed its + earlier states). Never dispatches: BUILD/FIX ok and GATE/LAUNCH ok + transitions carry no mission action.""" + order = [BUILDING, FIXING, GATING, LAUNCHING, VERIFYING] + guard = 0 + while machine.state != target and machine.state in order and guard < 6: + machine.advance(Outcome(machine.state, ok=True)) + guard += 1 + + def report_launch_success(self, project_id: str) -> None: + """notify_ready fully succeeded → the machine is now waiting on the + independent verifier.""" + machine = self.machine_for(project_id) + if machine is None or machine.terminal: + return + self._normalize_to(machine, VERIFYING) + side = self._sidecar_read(project_id) + side.pop("verify_retried", None) + self._sidecar_write(project_id, side) + + def report_verify( + self, + project_id: str, + kind: str, # pass | defects | incomplete | blocked | unparseable + defects: Optional[List[str]] = None, + details: str = "", + walk_report: str = "", + server_log: str = "", + console_lines: Optional[List[str]] = None, + url: str = "", + verified: Optional[List[str]] = None, + caveat: str = "", + ) -> Optional[Decision]: + """Feed the walk_verify verdict; act on the machine's Decision. + Returns the Decision so the action can shape its agent-facing text.""" + machine = self.machine_for(project_id) + if machine is None: + return None + if machine.terminal: + # A re-verify after done (e.g. modify flows Phase 2+); ignore. + return None + self._normalize_to(machine, VERIFYING) + + if kind in ("pass", "incomplete", "blocked"): + decision = machine.advance( + Outcome(VERIFYING, ok=True, payload={"url": url, "verified": verified or []}) + ) + if decision.action == ANNOUNCE_READY: + self._announce_ready(project_id, url, verified or [], caveat) + return decision + + if kind == "unparseable": + side = self._sidecar_read(project_id) + already = bool(side.get("verify_retried")) + side["verify_retried"] = True + self._sidecar_write(project_id, side) + decision = machine.advance( + Outcome( + VERIFYING, ok=False, + payload={"unknown_verdict": True, "already_retried": already}, + ) + ) + if decision.action == ANNOUNCE_STUCK: + self._announce_stuck(project_id, machine) + return decision + + # defects → DISTILL to cards (E3: cards are the fix-mission input) + from app.factory.appfactory.distill import distill + + project = self._project(project_id) + cli = "node /Users/ahmad/Work/CraftOS/CraftBot/living-ui-v2/tools/src/cli.ts" + cards = distill( + walk_report=walk_report or "\n".join(defects or []), + server_log=server_log, + console_lines=console_lines or [], + project_path=str(project.path) if project else "", + cli=cli, + ) + # Fingerprint = the FIRST card's identity (stable across rounds). + fp = cards[0].fingerprint() if cards else _fingerprint(details or "verification failed") + decision = machine.advance( + Outcome( + VERIFYING, ok=False, + fingerprint=fp, + payload={"cards": [c.key for c in cards]}, + ) + ) + if decision.action == DISPATCH_MISSION: + self._dispatch_fix_mission(project_id, machine, decision, cards) + elif decision.action == ANNOUNCE_STUCK: + self._announce_stuck(project_id, machine) + return decision + + # ── missions ─────────────────────────────────────────────────────────── + @staticmethod + def _select_cookbooks(text: str) -> List[str]: + """Known-good snippets by evidence keywords (weak models copy-adapt + far better than they synthesize — E6/I3).""" + from pathlib import Path as _P + + books_dir = _P(__file__).parent / "appfactory" / "cookbooks" + lowered = text.lower() + picks = [] + rules = [ + ("integration_actions.md", ("gmail", "email", "smtp", "mailer", "send_", + "callaction", "slack", "notion", "discord", + "not granted", "irreversible", "bridge")), + ("pocketbase_traps.md", ("cannot be blank", "not defined", "dao", + "404", "migration", "no rows", "panic", + "invalid sort", "record(")), + ("third_party_fetch.md", ("http.send", "502", "fetch failed", + "statuscode", "api.")), + ("frontend_rules.md", ("err_connection", "request failed", + "console error", "first paint", "mount")), + ] + for name, keys in rules: + if any(k in lowered for k in keys): + path = books_dir / name + if path.exists(): + picks.append(path.read_text(encoding="utf-8")[:2200]) + return picks[:2] + + def _compose_fix_brief( + self, project, machine: Machine, decision: Decision, cards: list + ) -> str: + n = len([h for h in machine.history() if h["action"] == DISPATCH_MISSION]) + escalation = "" + if decision.escalate: + escalation = ( + "\nTHIS FAILURE HAS REPEATED. Your previous approach did not fix it — " + "do something DIFFERENT: reread the evidence below, reproduce with the " + "exact command, and check the server log after reproducing.\n" + ) + cli = "node /Users/ahmad/Work/CraftOS/CraftBot/living-ui-v2/tools/src/cli.ts" + cards_text = "\n\n".join(c.render() for c in cards)[:6000] + books = self._select_cookbooks(cards_text) + books_text = ("\n\n=== PROVEN PATTERNS (copy-adapt; do not invent) ===\n" + + "\n---\n".join(books)) if books else "" + 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 +carries its evidence and a repro. Your ONLY goal: make these features work. +{escalation} +=== DEFECT CARDS === +{cards_text} +{books_text} + +=== HOW TO WORK (concrete) === +1. Reproduce first: use the repro commands / exercise the failing op: + {cli} run {project.path} +2. Read the evidence before theorizing: {project.path}/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). +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 +status messages; when verification passes the user is informed automatically.""" + + def _dispatch_fix_mission( + self, project_id: str, machine: Machine, decision: Decision, cards: list + ) -> None: + project = self._project(project_id) + if project is None: + return + brief = self._compose_fix_brief(project, machine, decision, cards) + side = self._sidecar_read(project_id) + side["last_brief"] = brief + self._sidecar_write(project_id, side) + self._emit_mission(project, brief, mission_kind="fix", machine=machine) + + def _emit_mission(self, project, brief: str, mission_kind: str, machine: Machine) -> None: + from app.living_ui import get_living_ui_manager + + mgr = get_living_ui_manager() + if mgr is None or not getattr(mgr, "_trigger_service", None): + logger.error("[FACTORY] cannot dispatch mission — trigger service unbound") + return + session = mgr.ensure_project_session(project) + if not session: + logger.error("[FACTORY] cannot dispatch mission — no project session") + return + mission_id = f"{mission_kind}-{int(time.time())}" + + async def _emit() -> None: + from app.triggers import TriggerSource, TriggerSpec + + await mgr._trigger_service.emit( + TriggerSpec( + source=TriggerSource.LIVING_UI_CRASH_FIX, # existing fix-run source + description=brief, + priority=30, + session_id=session.id, + payload={ + "project_id": project.id, + "factory_mission_id": mission_id, + "workflow_skills": ["living-ui-creator"], + }, + ) + ) + + import asyncio + + try: + loop = asyncio.get_running_loop() + loop.create_task(_emit()) + except RuntimeError: + asyncio.run(_emit()) + machine.mission_started(mission_id) + logger.info(f"[FACTORY] dispatched {mission_id} for {project.id}") + + def mission_run_started(self, project_id: str, mission_id: str) -> None: + """The queued mission's run has actually begun. Lets a later run-end + WITHOUT a mission id (run_continuation triggers carry none) still be + attributed to the running mission.""" + side = self._sidecar_read(project_id) + side["running_mission"] = mission_id + self._sidecar_write(project_id, side) + + # ── run-end hook (closes I6) ─────────────────────────────────────────── + def on_run_end(self, project_id: str, trigger_payload: Dict[str, Any]) -> None: + """Called by the host when ANY run in a project session ends. If the + machine says work should be in flight but isn't, redispatch — the + agent surrendering is no longer a terminal event.""" + try: + machine = self.machine_for(project_id) + if machine is None: + return + side = self._sidecar_read(project_id) + mission_id = (trigger_payload or {}).get("factory_mission_id") + if not mission_id and machine.active_mission and ( + side.get("running_mission") == machine.active_mission + ): + # This run belonged to the active mission (it started via the + # mission trigger; the FINAL trigger of the run was a + # continuation with no id). + mission_id = machine.active_mission + if mission_id: + machine.mission_ended(str(mission_id)) + if side.get("running_mission") == str(mission_id): + side.pop("running_mission", None) + self._sidecar_write(project_id, side) + if not machine.needs_redispatch(): + return + history = machine.history() + if history: + last = history[-1].get("at", "") + try: + last_ts = time.mktime(time.strptime(last, "%Y-%m-%dT%H:%M:%SZ")) + if time.time() - last_ts < _REDISPATCH_MIN_INTERVAL_S: + return + except Exception: + pass + project = self._project(project_id) + if project is None: + return + side = self._sidecar_read(project_id) + brief = side.get("last_brief") or ( + f"CONTINUE BUILD for Living UI '{project.name}' ({project.id}).\n" + f"The previous run ended before the build was verified. Continue from " + f"the current state of {project.path}: finish the work, then\n" + f'living_ui_notify_ready(project_id="{project.id}") and\n' + f'living_ui_walk_verify(project_id="{project.id}").\n' + f"The system reports status to the user automatically — do not send " + f"status messages." + ) + brief = ( + "PREVIOUS ATTEMPT ENDED WITHOUT COMPLETING.\n\n" + brief + if side.get("last_brief") + else brief + ) + self._emit_mission(project, brief, mission_kind="resume", machine=machine) + logger.warning( + f"[FACTORY] run ended with machine at '{machine.state}' and no active " + f"mission — redispatched (project={project_id})" + ) + except Exception as e: + logger.error(f"[FACTORY] on_run_end failed for {project_id}: {e}") + + # ── machine-composed status (§3.6: retire agent announcements) ───────── + def _emit_chat(self, project_id: str, text: str) -> None: + try: + from app.internal_action_interface import InternalActionInterface as I + from app.living_ui import get_living_ui_manager + from agent_core.core.event_stream.event import EventType + + mgr = get_living_ui_manager() + project = mgr.get_project(project_id) if mgr else None + session = mgr.ensure_project_session(project) if (mgr and project) else None + if I.event_stream_manager and session: + I.event_stream_manager.log( + kind="factory_status", + message=text, + event_type=EventType.AGENT_MESSAGE, + display_message=text, + task_id=session.id, + ) + except Exception as e: + logger.debug(f"[FACTORY] chat emit failed: {e}") + + def _announce_ready( + self, project_id: str, url: str, verified: List[str], caveat: str + ) -> None: + n = len(verified) + text = f"✅ The app is ready at {url}" + ( + f" — {n} feature(s) verified in a real browser." if n else "." + ) + if caveat: + text += f"\n⚠️ {caveat}" + self._emit_chat(project_id, text) + + def _announce_stuck(self, project_id: str, machine: Machine) -> None: + self._emit_chat(project_id, "❌ " + machine.stuck_report()) + try: + import asyncio + + from app.living_ui.broadcast import broadcast_living_ui_progress + + coroutine = broadcast_living_ui_progress( + project_id, "error", 100, "Build stuck — see the report in chat" + ) + try: + asyncio.get_running_loop().create_task(coroutine) + except RuntimeError: + asyncio.run(coroutine) + except Exception as e: + logger.debug(f"[FACTORY] stuck broadcast failed: {e}") + + +_HOST: Optional[FactoryHost] = None + + +def get_factory_host() -> FactoryHost: + global _HOST + if _HOST is None: + _HOST = FactoryHost() + return _HOST diff --git a/app/factory/test_phase0.py b/app/factory/test_phase0.py new file mode 100644 index 00000000..d9402949 --- /dev/null +++ b/app/factory/test_phase0.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +"""Phase 0 acceptance (FACTORY-PLAN §5 Phase 0). Plain asserts, no deps: + python3 -m app.factory.test_phase0 +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +from app.factory.engine import ( + ANNOUNCE_READY, ANNOUNCE_STUCK, DISPATCH_MISSION, DONE, STUCK, + Caps, Machine, Outcome, card_from_dict, validate_card, +) +from app.factory.appfactory import ( + BUILDING, FIXING, GATING, LAUNCHING, SPECIFYING, VERIFYING, transition, +) + +# ── §3.5 example card validates ───────────────────────────────────────────── +EXAMPLE = { + "key": "verify.feature.refresh-502", + "where": "POST /api/ops/refresh-stories (ops.pb.js:41)", + "observed": "502; pocketbase.log: 'hn-refresh failed: comment_count: cannot be blank'", + "expected": "200 and stories rows created on click", + "candidate_cause": "required number field rejects 0 (PB semantics)", + "suggested_direction": "set a safe default before save OR relax required in a NEW migration", + "repro": "node run refresh_stories", + "evidence": ["hn-refresh failed: GoError: comment_count: cannot be blank."], +} +assert validate_card(EXAMPLE) == [], validate_card(EXAMPLE) +card = card_from_dict(EXAMPLE) +assert card.fingerprint() and "DEFECT" in card.render() +assert validate_card({**EXAMPLE, "observed": ""}) != [] # empty required +assert validate_card({**EXAMPLE, "extra": "x"}) != [] # unknown field +print("card schema: OK") + +# ── the arc: happy path ───────────────────────────────────────────────────── +def fresh_machine(tmp: Path, caps=None) -> Machine: + return Machine(transition, tmp / "state.json", SPECIFYING, caps=caps) + +with tempfile.TemporaryDirectory() as td: + m = fresh_machine(Path(td)) + d = m.advance(Outcome(SPECIFYING, ok=True)) + assert (m.state, d.action) == (BUILDING, DISPATCH_MISSION) + m.mission_started("build-1") + assert not m.needs_redispatch() + m.mission_ended("build-1") + assert m.needs_redispatch() # I6: surrender is visible + for s in (BUILDING, GATING, LAUNCHING): + m.advance(Outcome(s, ok=True)) + d = m.advance(Outcome(VERIFYING, ok=True, payload={"verified": ["a", "b"]})) + assert (m.state, d.action) == (DONE, ANNOUNCE_READY) + assert not m.needs_redispatch() +print("happy path: OK") + +# ── failure loop: caps + escalation ───────────────────────────────────────── +with tempfile.TemporaryDirectory() as td: + m = fresh_machine(Path(td), caps=Caps(per_fingerprint=3, total_missions=12)) + fp = card.fingerprint() + m.advance(Outcome(SPECIFYING, ok=True)) # → building (mission 1) + m.advance(Outcome(BUILDING, ok=True)) # → gating + d1 = m.advance(Outcome(GATING, ok=False, fingerprint=fp, payload={"cards": [EXAMPLE]})) + assert (m.state, d1.action, d1.escalate) == (FIXING, DISPATCH_MISSION, False) + m.advance(Outcome(FIXING, ok=True)) # fix ended → re-gate + d2 = m.advance(Outcome(GATING, ok=False, fingerprint=fp)) + assert d2.escalate, "second identical failure must escalate the brief" + m.advance(Outcome(FIXING, ok=True)) + d3 = m.advance(Outcome(GATING, ok=False, fingerprint=fp)) + assert (m.state, d3.action) == (STUCK, ANNOUNCE_STUCK) # cap 3 → stuck + assert "3×" in d3.reason or "cap" in d3.reason + report = m.stuck_report() + assert "could not be completed" in report and fp in report +print("caps + escalation + honest stuck report: OK") + +# ── total mission budget ──────────────────────────────────────────────────── +with tempfile.TemporaryDirectory() as td: + m = fresh_machine(Path(td), caps=Caps(per_fingerprint=99, total_missions=2)) + m.advance(Outcome(SPECIFYING, ok=True)) # mission 1 (build) + m.advance(Outcome(BUILDING, ok=True)) # → gating + d = m.advance(Outcome(GATING, ok=False, fingerprint="x1")) # mission 2 (fix) + assert d.action == DISPATCH_MISSION + m.advance(Outcome(FIXING, ok=True)) + d = m.advance(Outcome(GATING, ok=False, fingerprint="x2")) # would be 3 → stuck + assert (m.state, d.action) == (STUCK, ANNOUNCE_STUCK) +print("mission budget: OK") + +# ── fail-closed verdicts ──────────────────────────────────────────────────── +with tempfile.TemporaryDirectory() as td: + m = fresh_machine(Path(td)) + for s in (SPECIFYING, BUILDING, GATING, LAUNCHING): + m.advance(Outcome(s, ok=True)) + d = m.advance(Outcome(VERIFYING, ok=False, payload={"unknown_verdict": True})) + assert m.state == VERIFYING and d.payload.get("redo") == "verify" + d = m.advance(Outcome(VERIFYING, ok=False, + payload={"unknown_verdict": True, "already_retried": True})) + assert (m.state, d.action) == (STUCK, ANNOUNCE_STUCK) # NEVER announce +print("fail-closed verdicts: OK") + +# ── persistence survives restart ──────────────────────────────────────────── +with tempfile.TemporaryDirectory() as td: + m = fresh_machine(Path(td)) + m.advance(Outcome(SPECIFYING, ok=True)) + m.mission_started("build-1") + m2 = fresh_machine(Path(td)) # reload from disk + assert m2.state == BUILDING and m2.active_mission == "build-1" +print("persistence: OK") + +print("\nPhase 0 acceptance: ALL GREEN") diff --git a/app/factory/test_phase1.py b/app/factory/test_phase1.py new file mode 100644 index 00000000..9aca82af --- /dev/null +++ b/app/factory/test_phase1.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +"""Phase 1 acceptance (FACTORY-PLAN §5 Phase 1): the CraftBot host adapter +drives the machine — fresh missions on defects, redispatch on surrender, +honest stuck at caps, announce only from the machine. + +Runs with a STUBBED manager (no CraftBot runtime): + python3 -m app.factory.test_phase1 +""" + +from __future__ import annotations + +import tempfile +import types +from pathlib import Path + +import app.factory.host_craftbot as host_mod +import app.living_ui as living_ui_mod +from app.factory.host_craftbot import FactoryHost + +host_mod._REDISPATCH_MIN_INTERVAL_S = 0 # test: no thrash-guard waits + +DISPATCHED = [] # captured TriggerSpecs +CHAT = [] # captured machine-composed chat lines + + +class _Session: + id = "lui_test" + + +class _TriggerService: + async def emit(self, spec): + DISPATCHED.append(spec) + + +class _Project: + def __init__(self, path): + self.id = "testproj" + self.name = "Test App" + self.path = str(path) + + +class _Manager: + def __init__(self, path): + self._p = _Project(path) + self._trigger_service = _TriggerService() + + def get_project(self, pid): + return self._p if pid == "testproj" else None + + def ensure_project_session(self, project): + return _Session() + + +def make_host(tmp) -> FactoryHost: + living_ui_mod.get_living_ui_manager = lambda: _Manager(tmp) # monkeypatch + host = FactoryHost() + host._emit_chat = lambda pid, text: CHAT.append(text) # capture announcements + return host + + +# ── defects → fresh mission with evidence; repeats → escalation → stuck ───── +with tempfile.TemporaryDirectory() as td: + DISPATCHED.clear(); CHAT.clear() + host = make_host(Path(td)) + host.report_launch_success("testproj") + d = host.report_verify("testproj", "defects", + defects=["- Refresh — FAIL — 502 on /api/ops/x"], + details="VERDICT: FAIL\n502 evidence line") + assert d is not None and d.next_state == "fixing" + assert len(DISPATCHED) == 1, "first defect round must dispatch a fresh fix mission" + assert "FIX MISSION" in DISPATCHED[0].description + assert "DEFECT" in DISPATCHED[0].description # card format (Phase 2) + assert "502 on /api/ops/x" in DISPATCHED[0].description # observed value travels + assert DISPATCHED[0].payload["factory_mission_id"].startswith("fix-") + + d = host.report_verify("testproj", "defects", + defects=["- Refresh — FAIL — 502 on /api/ops/x"], + details="VERDICT: FAIL\n502 evidence line") + assert d.escalate and len(DISPATCHED) == 2 + assert "REPEATED" in DISPATCHED[1].description # escalated brief + + d = host.report_verify("testproj", "defects", + defects=["- Refresh — FAIL — 502 on /api/ops/x"], + details="VERDICT: FAIL\n502 evidence line") + assert d.next_state == "stuck" and len(DISPATCHED) == 2 # cap: no 3rd mission + assert CHAT and "could not be completed" in CHAT[-1] # machine-composed stuck +print("defects → mission → escalate → honest stuck: OK") + +# ── surrender → redispatch (I6 closed at the host level) ──────────────────── +with tempfile.TemporaryDirectory() as td: + DISPATCHED.clear(); CHAT.clear() + host = make_host(Path(td)) + machine = host.machine_for("testproj") + # Simulate: build run ends mid-work (machine exists, non-terminal, no mission) + host.on_run_end("testproj", {}) + assert len(DISPATCHED) == 1, "surrendered run must redispatch" + assert "CONTINUE BUILD" in DISPATCHED[0].description + mission_id = DISPATCHED[0].payload["factory_mission_id"] + # That mission's run ends without finishing either → redispatch again + host.on_run_end("testproj", {"factory_mission_id": mission_id}) + assert len(DISPATCHED) == 2 +print("surrender → auto-redispatch: OK") + +# ── pass verdict → machine announces; done = no more redispatch ───────────── +with tempfile.TemporaryDirectory() as td: + DISPATCHED.clear(); CHAT.clear() + host = make_host(Path(td)) + host.report_launch_success("testproj") + d = host.report_verify("testproj", "pass", url="http://127.0.0.1:3100", + verified=["feature a", "feature b"], caveat="") + assert d.next_state == "done" + assert CHAT and "ready at http://127.0.0.1:3100" in CHAT[-1] and "2 feature" in CHAT[-1] + host.on_run_end("testproj", {}) + assert DISPATCHED == [], "done build must never redispatch" +print("machine-composed ready + terminal stability: OK") + +# ── unparseable verdict: retry once, then stuck — never announce ──────────── +with tempfile.TemporaryDirectory() as td: + DISPATCHED.clear(); CHAT.clear() + host = make_host(Path(td)) + host.report_launch_success("testproj") + d = host.report_verify("testproj", "unparseable") + assert d.payload.get("redo") == "verify" and CHAT == [] + d = host.report_verify("testproj", "unparseable") + assert d.next_state == "stuck" + assert CHAT and "could not be completed" in CHAT[-1] + assert all("ready at" not in c for c in CHAT) # NEVER announced ready +print("unparseable verdicts fail closed: OK") + + +# ── surrender via CONTINUATION trigger (no mission id in final payload) ───── +with tempfile.TemporaryDirectory() as td: + DISPATCHED.clear(); CHAT.clear() + host = make_host(Path(td)) + machine = host.machine_for("testproj") + host.on_run_end("testproj", {}) # dispatch resume-1 + assert len(DISPATCHED) == 1 + mission_id = DISPATCHED[0].payload["factory_mission_id"] + host.mission_run_started("testproj", mission_id) # its run began + # ...run ends on a run_continuation trigger: payload has NO mission id + host.on_run_end("testproj", {}) + assert len(DISPATCHED) == 2, "continuation-ended surrender must still redispatch" + # But a QUEUED (never-started) mission must NOT be clobbered: + queued_id = DISPATCHED[1].payload["factory_mission_id"] + host.on_run_end("testproj", {}) # e.g. stray old run ends + assert len(DISPATCHED) == 2, "queued mission must not be cleared by an unrelated run-end" +print("continuation-trigger surrender + queued-mission safety: OK") + +print("\nPhase 1 acceptance: ALL GREEN") diff --git a/app/factory/test_phase2.py b/app/factory/test_phase2.py new file mode 100644 index 00000000..573c4788 --- /dev/null +++ b/app/factory/test_phase2.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +"""Phase 2 acceptance: distiller replays of two REAL incidents. + python3 -m app.factory.test_phase2 +""" + +from __future__ import annotations + +from app.factory.appfactory.distill import distill +from app.factory.engine.cards import validate_card + +# ── Replay 1: run 14 (the "Vite" hallucination incident) ──────────────────── +# What the verifier + new requestfailed capture would produce for that tail: +WALK_14 = """VERDICT: FAIL +FEATURES: +- View current top stories list refreshed hourly — FAIL — Clicked Refresh HN; received "Refresh failed: HTTP 502" and console error; no stories loaded | expected: stories list populates after refresh +- Bookmark any story — NOT REACHED +""" +CONSOLE_14 = [ + "REQUEST FAILED: POST http://127.0.0.1:3100/api/ops/refresh-stories — net::ERR_CONNECTION_REFUSED", +] +cards = distill(WALK_14, server_log="", console_lines=CONSOLE_14, + project_path="/w/proj", cli="node cli.ts") +assert len(cards) == 1 +c = cards[0].__dict__ +assert validate_card({k: v for k, v in c.items()}) == [] +assert "/api/ops/refresh-stories" in cards[0].where or any( + "refresh-stories" in e for e in cards[0].evidence +) +assert any("ERR_CONNECTION_REFUSED" in e for e in cards[0].evidence), "URL+cause must be quoted" +assert "node cli.ts run /w/proj refresh-stories" == cards[0].repro +blob = cards[0].render() +assert "Vite" not in blob and "vite" not in blob # the hallucination is not utterable from evidence +print("run-14 replay: refused URL named, repro ready, no Vite utterable: OK") + +# ── Replay 2: run 15 (comment_count — evidence present, cause matched) ────── +WALK_15 = """VERDICT: FAIL +FEATURES: +- View current top stories list refreshed hourly (title, url, score) — FAIL — Clicked Refresh HN; 502 Bad Gateway on /api/ops/refresh-stories; no stories loaded | expected: rows appear +""" +LOG_15 = """INFO POST /api/ops/refresh-stories +2026/08/03 07:34:26 hn-refresh failed: GoError: comment_count: cannot be blank. +[0.00ms] SELECT `stories`.* FROM `stories`""" +cards = distill(WALK_15, server_log=LOG_15, project_path="/w/proj", cli="node cli.ts") +assert len(cards) == 1 +assert "cannot be blank" in cards[0].candidate_cause, "server evidence must drive the cause" +assert "cannot be blank" in " ".join(cards[0].evidence) +assert cards[0].repro.endswith("run /w/proj refresh-stories") +print("run-15 replay: cause quoted from server log: OK") + +# ── No-evidence failure: cause must be 'unknown', direction = gather ──────── +cards = distill("- Something — FAIL — it broke | expected: works", + server_log="", console_lines=[]) +assert cards[0].candidate_cause.startswith("unknown") +assert "Do NOT theorize" in cards[0].suggested_direction +print("evidence-bound: no evidence → unknown + gather, never a theory: OK") + +# ── Unstructured report still yields a card (fingerprint/caps never starve) ─ +cards = distill("the verifier returned prose with no FAIL lines at all") +assert len(cards) == 1 and cards[0].key == "verify.unstructured-failure" +print("unstructured fallback card: OK") + +# ── Cookbook selection ────────────────────────────────────────────────────── +from app.factory.host_craftbot import FactoryHost + +books = FactoryHost._select_cookbooks("GoError: comment_count: cannot be blank") +assert books and "required: true" in books[0] or "REJECTS 0" in books[0] +books = FactoryHost._select_cookbooks("send_gmail failed: not granted") +assert any("confirmIrreversible" in b for b in books) +books = FactoryHost._select_cookbooks("REQUEST FAILED: net::ERR_CONNECTION_REFUSED") +assert any("RELATIVELY" in b or "relative" in b.lower() for b in books) +print("cookbook selection by evidence keywords: OK") + +print("\nPhase 2 acceptance: ALL GREEN") diff --git a/app/living_ui/agent_view.py b/app/living_ui/agent_view.py index c204df28..b8462bcc 100644 --- a/app/living_ui/agent_view.py +++ b/app/living_ui/agent_view.py @@ -99,6 +99,12 @@ def schema_block(base_url: str, max_chars: int = 2000) -> Optional[str]: fields.append(f"{field_name}({_type_label(spec)}){star}") if fields: lines.append(f" {name}: {' '.join(fields)}") + else: + # Silently omitting an empty collection HID the evidence of a + # failed migration once (a weather app whose readings collection + # held only `id` rendered 0° everywhere). Show the anomaly — the + # agent can only reason about what it can see. + lines.append(f" {name}: NO WRITABLE FIELDS — writes to it are silently dropped") block = "\n".join(lines) if len(block) > max_chars: # very large apps: names only, still better than nothing @@ -106,6 +112,80 @@ def schema_block(base_url: str, max_chars: int = 2000) -> Optional[str]: return block +_CAP_CACHE: Dict[str, tuple] = {} +_CAP_TTL_SECONDS = 300 + + +def capability_block() -> Optional[str]: + """What the app CAN reach through the bridge — connected integrations + with their key actions, plus the facts that kill recurring myths. + + Injected (not referenced): three separate builds invented an SMTP + requirement and stubbed the user's email feature because nothing in + context said `send_gmail` exists. Weak models fail on missing facts, + not on fifteen extra lines. ~300 tokens, cached 5 minutes. + """ + cached = _CAP_CACHE.get("caps") + if cached is not None and time.time() - cached[0] < _CAP_TTL_SECONDS: + return cached[1] + + block: Optional[str] = None + try: + from craftos_integrations import get_client, get_registered_platforms + from agent_core.core.action_framework.registry import ActionRegistry + + connected, disconnected = [], [] + for pid in get_registered_platforms(): + try: + client = get_client(pid) + ok = bool(client and client.has_credentials()) + except Exception: + ok = False + (connected if ok else disconnected).append(pid) + + # Key actions per connected integration, from the registry's + # action_sets convention (["gmail_mail", "gmail"] → gmail). Sends and + # creates first — those are what apps reach for. + registry = ActionRegistry().list_all_actions() + by_integration: Dict[str, list] = {pid: [] for pid in connected} + for action_name, impls in registry.items(): + impl = impls.get("all") or next(iter(impls.values()), None) + if impl is None: + continue + sets = set(getattr(impl.metadata, "action_sets", None) or []) + for pid in connected: + if pid in sets: + by_integration[pid].append(action_name) + for pid in by_integration: + by_integration[pid].sort( + key=lambda n: (not n.startswith(("send_", "create_", "post_")), n) + ) + + lines = ["[INTEGRATIONS this app can use — bridge.callAction(name, params)]"] + for pid in sorted(connected): + names = by_integration.get(pid) or [] + shown = ", ".join(names[:4]) + (", …" if len(names) > 4 else "") + lines.append(f" connected: {pid} ({shown})" if names else f" connected: {pid}") + if disconnected: + lines.append( + " NOT connected (user must connect in CraftBot first): " + + ", ".join(sorted(disconnected)) + ) + lines.append( + " FACTS: There is NO SMTP and NO API-key config anywhere in this platform —\n" + " email IS callAction('send_gmail', {subject, body}, {confirmIrreversible: true});\n" + " omit 'to' to email the user. Credentials are injected by the bridge; never\n" + " ask the user for keys, never stub a feature 'until SMTP is configured'." + ) + block = "\n".join(lines) + except Exception as e: + logger.debug(f"[AGENT_VIEW] capability block unavailable: {e}") + block = None + + _CAP_CACHE["caps"] = (time.time(), block) + return block + + def _resolve_ref(base_url: str, entity: str, record_id: str) -> Optional[str]: """A referenced record's human label, so the user reads 'To Do' not an id.""" described = _describe(base_url) diff --git a/app/living_ui/integration_bridge.py b/app/living_ui/integration_bridge.py index 947dc079..0e871ff5 100644 --- a/app/living_ui/integration_bridge.py +++ b/app/living_ui/integration_bridge.py @@ -27,6 +27,39 @@ logger = logging.getLogger(__name__) +# RFC 2606 / 6761 reserved names: values built on these are placeholders by +# definition — "you@example.com" compiles, validates, and mails nobody. A +# standards-based check, not a per-integration rule. +_PLACEHOLDER_DOMAINS = ( + "example.com", "example.org", "example.net", "example.edu", + ".example", ".test", ".invalid", "@example.", +) + + +def _dry_run_param_problems(input_schema: dict, params: dict) -> list: + """Pure param validation for dry-run: unknown keys against the action's + schema, and placeholder values that would 'succeed' into a void.""" + problems = [] + known = set(input_schema.keys()) + for key in params.keys(): + if known and key not in known: + problems.append( + f"unknown param '{key}' — this action's schema has: " + + ", ".join(sorted(known)) + ) + for key, value in params.items(): + if isinstance(value, str): + lowered = value.lower() + if any(marker in lowered for marker in _PLACEHOLDER_DOMAINS): + problems.append( + f"param '{key}' looks like a PLACEHOLDER ({value!r}) — " + "RFC-reserved example domains reach nobody. Use a real " + "value, or omit the param if the action resolves it " + "(send_gmail with no 'to' goes to the account owner)." + ) + return problems + + class IntegrationBridge: """ HTTP proxy that lets Living UI backends make authenticated API calls @@ -47,6 +80,7 @@ def register_routes(self, app: web.Application) -> None: """Register integration bridge routes on the aiohttp app.""" app.router.add_get("/api/integrations/available", self._handle_available) app.router.add_post("/api/integrations/proxy", self._handle_proxy) + app.router.add_post("/api/integrations/action", self._handle_action) app.router.add_post("/api/bridge/llm", self._handle_llm) app.router.add_post("/api/bridge/vlm", self._handle_vlm) logger.info("[INTEGRATION_BRIDGE] Routes registered") @@ -189,6 +223,158 @@ async def _handle_proxy(self, request: web.Request) -> web.Response: logger.error(f"[INTEGRATION_BRIDGE] Proxy error: {e}") return web.json_response({"error": f"Proxy error: {str(e)}"}, status=502) + async def _handle_action(self, request: web.Request) -> web.Response: # noqa: C901 + """Execute one of CRAFTBOT'S OWN integration actions for a Living UI. + + The raw proxy below makes apps speak each provider's native API — + which made an agent hand-roll Gmail MIME envelopes and, when its + invented endpoint 404'd, conclude the bridge was broken. CraftBot + already owns tested implementations of every integration operation + (send_gmail, send_slack_message, …): this endpoint exposes THEM, so + apps pass semantic params ({to, subject, body}) and provider-API + knowledge stays in one place. + + Body: {"action": "send_gmail", "params": {...}, + "confirm_irreversible": true?} + Grants: the gate derives capabilities.actions from callAction + literals in the app's hooks — same derive-from-code flow as + external_hosts/integrations. Only actions tied to a known + integration are callable; irreversible ones need the explicit flag. + """ + project_id = self._validate_token(request) + if not project_id: + return web.json_response({"error": "Unauthorized"}, status=401) + try: + data = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON body"}, status=400) + + name = str(data.get("action") or "") + params = data.get("params") or {} + if not name: + return web.json_response({"error": "Missing required field: action"}, status=400) + if not isinstance(params, dict): + return web.json_response({"error": "params must be an object"}, status=400) + + dry_run = bool(data.get("dry_run") or data.get("dryRun")) + + from agent_core.core.action_framework.registry import ActionRegistry + + impl = ActionRegistry().get_action_implementation(name) + if impl is None: + logger.warning( + f"[INTEGRATION_BRIDGE] REFUSED (unknown action) project={project_id} action={name!r}" + ) + return web.json_response({"error": f"Unknown action '{name}'"}, status=404) + + # Only INTEGRATION actions are exposed: the action's action_sets carry + # its integration id by convention (["gmail_mail", "gmail"] → gmail). + sets = set(getattr(impl.metadata, "action_sets", None) or []) + integration = next((i for i in sorted(self.PROXY_DESTINATIONS) if i in sets), None) + if integration is None: + logger.warning( + f"[INTEGRATION_BRIDGE] REFUSED (not an integration action) " + f"project={project_id} action={name!r}" + ) + return web.json_response( + {"error": f"'{name}' is not an integration action — only integration " + "actions are callable through the bridge"}, + status=403, + ) + + granted, why = self._project_action_grants(project_id, name) + if not granted: + logger.warning( + f"[INTEGRATION_BRIDGE] BLOCKED (action) project={project_id} " + f"action={name!r}: {why}" + ) + return web.json_response( + {"error": f"This app is not permitted to call '{name}': {why}"}, + status=403, + ) + + if getattr(impl.metadata, "irreversible", False) and not data.get("confirm_irreversible"): + # A refused send with no server-side log line is how an app once + # shipped a cron that logged "sent" while nothing ever left. + logger.warning( + f"[INTEGRATION_BRIDGE] REFUSED (irreversible, no confirm) " + f"project={project_id} action={name!r}" + ) + return web.json_response( + {"error": f"'{name}' is irreversible (it acts on the user's real " + "account). Retry with \"confirm_irreversible\": true."}, + status=400, + ) + + if dry_run: + # Everything above ran: token, action exists, integration mapped, + # grant present, irreversible confirmed. Now validate params + # WITHOUT executing, so build-time verification can exercise + # paths that must never fire for real (emails, posts, deletes). + problems = _dry_run_param_problems( + getattr(impl.metadata, "input_schema", None) or {}, params + ) + if problems: + logger.warning( + f"[INTEGRATION_BRIDGE] DRY-RUN found problems " + f"project={project_id} action={name!r}: {problems}" + ) + return web.json_response( + {"error": "Dry-run found problems: " + "; ".join(problems)}, + status=400, + ) + return web.json_response( + { + "status": 200, + "dry_run": True, + "would_execute": name, + "integration": integration, + "note": "All checks passed (grant, params, confirmation). " + "Nothing was executed.", + }, + status=200, + ) + + try: + import asyncio as _asyncio + import inspect as _inspect + + if _inspect.iscoroutinefunction(impl.handler): + result = await impl.handler(params) + else: + result = await _asyncio.to_thread(impl.handler, params) + return web.json_response({"status": 200, "data": result}, status=200) + except Exception as e: + logger.error(f"[INTEGRATION_BRIDGE] action '{name}' failed: {e}") + return web.json_response({"error": f"Action failed: {str(e)}"}, status=502) + + def _project_action_grants(self, project_id: str, action_name: str) -> tuple: + """(ok, reason) — is `action_name` in the manifest's derived + capabilities.actions? Fails closed, mirror of _project_grants.""" + import json as _json + + try: + project = self._manager.get_project(project_id) + except Exception as e: + return False, f"unknown project ({e})" + if project is None: + return False, "unknown project" + try: + manifest_path = Path(project.path) / "manifest.json" + manifest = _json.loads(manifest_path.read_text(encoding="utf-8")) + except Exception as e: + return False, f"manifest unreadable ({e})" + + declared = (manifest.get("capabilities") or {}).get("actions") + if not isinstance(declared, list) or action_name not in declared: + return False, ( + f"'{action_name}' is not in capabilities.actions. The gate derives " + "the list from callAction literals in your hooks — call " + f"bridge.callAction('{action_name}', {{…}}) with a literal name and " + "relaunch with living_ui_notify_ready." + ) + return True, "" + async def _handle_llm(self, request: web.Request) -> web.Response: """Proxy LLM completion request through CraftBot's configured LLM.""" project_id = self._validate_token(request) @@ -349,13 +535,24 @@ def _project_grants(self, project_id: str, integration: str) -> tuple: capabilities = manifest.get("capabilities") or {} declared = capabilities.get("integrations") + # The grant is DERIVED, not hand-written: the validation gate scans + # the hooks for callIntegration('' literals and writes the list + # into the (system-managed) manifest. So the fix for a missing grant + # is always the same: make the call in code, re-run the gate. if not isinstance(declared, list): return False, ( - "manifest declares no capabilities.integrations — add " - f'"capabilities": {{"integrations": ["{integration}"]}} to grant it' + f"'{integration}' is not granted: the manifest has no " + "capabilities.integrations. The gate derives it from your " + f"hooks — call bridge.callIntegration('{integration}', …) with " + "a literal id and relaunch with living_ui_notify_ready." ) if integration not in declared: - return False, f"not in capabilities.integrations {declared}" + return False, ( + f"'{integration}' is not in capabilities.integrations " + f"{declared}. The gate derives the list from callIntegration " + "literals in your hooks — use a literal id and relaunch with " + "living_ui_notify_ready." + ) return True, "" def _resolve_destination(self, integration: str, url: str) -> tuple: diff --git a/app/living_ui/manager.py b/app/living_ui/manager.py index 44c72d73..22ea9aaf 100644 --- a/app/living_ui/manager.py +++ b/app/living_ui/manager.py @@ -715,6 +715,51 @@ def _kill_process_by_pid(self, pid: str) -> bool: # Manifest-driven launch pipeline # ======================================================================== + def _check_migration_divergence(self, project_path: Path) -> Optional[str]: + """A migration recorded as applied in the LIVE pb_data but missing + from pb_migrations/ bricks the app: at boot PocketBase re-runs the + renamed file as "new", collides with the existing schema ("Collection + name must be unique") and EXITS before serving /api/health. The gate + cannot see this — it migrates a fresh temp DB, where a rename is + harmless. Compare live history against the directory BEFORE boot. + + Observed live (weather_tracker_4453c73c): 1700000001_weather_schema.js + renamed to ...0002 after a successful launch had applied it; every + boot after that died with only "/api/health not responding" surfaced. + """ + import sqlite3 as _sqlite3 + + db = project_path / "pb" / "pb_data" / "data.db" + mig_dir = project_path / "pb" / "pb_migrations" + if not db.exists() or not mig_dir.is_dir(): + return None # fresh project — nothing applied yet + try: + conn = _sqlite3.connect(f"file:{db}?mode=ro", uri=True) + try: + rows = conn.execute("SELECT file FROM _migrations").fetchall() + finally: + conn.close() + except Exception as e: + # Fail OPEN: this check exists to explain a brick, never to cause + # a launch failure of its own. + logger.debug(f"[LIVING_UI:V2] migration-history check skipped: {e}") + return None + applied_js = {str(r[0]) for r in rows if str(r[0]).endswith(".js")} + on_disk = {p.name for p in mig_dir.glob("*.js")} + missing = sorted(applied_js - on_disk) + if not missing: + return None + return ( + "Applied migration(s) missing from pb_migrations/: " + + ", ".join(missing) + + ". These already ran against this app's LIVE data — the filename " + "is the identity. Renaming or deleting an applied migration makes " + "every boot re-run its replacement into the existing schema, and " + "PocketBase exits before serving anything. Restore the original " + "filename(s) exactly as listed, and put schema changes in a NEW " + "migration file." + ) + async def _launch_v2(self, project: LivingUIProject) -> dict: """V2 launch pipeline: install → validation gate → serve → health. @@ -746,6 +791,12 @@ def _fail(step: str, errors: list) -> dict: else: self._kill_process_on_port(project.port) + # Renamed/deleted APPLIED migrations brick the boot with an error only + # pocketbase.log ever sees — catch them here, before any process spawns. + divergence = self._check_migration_divergence(project_path) + if divergence: + return _fail("validation", [divergence]) + try: await self.v2_runner.install(project_path) except Exception as e: @@ -758,6 +809,29 @@ def _fail(step: str, errors: list) -> dict: if not project.bridge_token: project.bridge_token = secrets.token_urlsafe(32) + # pocketbase.log is append-mode across launches: remember where THIS + # boot starts so failures below can quote only their own boot's lines. + pb_log_path = project_path / "logs" / "pocketbase.log" + pb_log_offset = pb_log_path.stat().st_size if pb_log_path.exists() else 0 + + def _pb_log_since_boot(limit_lines: int = 30) -> str: + """Errors FIRST, then the newest lines. A naive tail once shipped + 30 lines of realtime-subscription chatter while the actual + 'cannot be blank' errors sat just above the window.""" + try: + with open(pb_log_path, "r", encoding="utf-8", errors="replace") as f: + f.seek(pb_log_offset) + lines = f.read().splitlines() + error_lines = [ + l for l in lines + if any(k in l.lower() for k in ("error", "failed", "panic", "cannot be")) + ][-limit_lines:] + tail = [l for l in lines[-10:] if l not in error_lines] + picked = error_lines + tail + return "\n".join(picked[-(limit_lines + 10):]) + except Exception: + return "" + try: project.process = await self.v2_runner.start( project_path, project.port, bridge_token=project.bridge_token @@ -768,7 +842,34 @@ def _fail(step: str, errors: list) -> dict: if not await self.v2_runner.wait_healthy(project.port): self._terminate_process(project.process) project.process = None - return _fail("health", [f"/api/health not responding on :{project.port}"]) + # A dead health check with no cause starved the agent before — + # the boot abort (bad migration, hook panic) is in pocketbase.log + # and nowhere else. Ship this boot's lines with the failure. + errors = [f"/api/health not responding on :{project.port}"] + boot_log = _pb_log_since_boot() + if boot_log: + errors.append("pocketbase.log (this boot):\n" + boot_log) + return _fail("health", errors) + + # A hook file that fails to load is a CORRUPT app, not a healthy one: + # every route/cron below the throwing line silently does not exist. + # Observed live: top-level setTimeout() killed ops.pb.js at line 57, + # health passed, and the app shipped with half its routes missing. + boot_log = _pb_log_since_boot(200) + load_failures = [ + line for line in boot_log.splitlines() if "failed to execute" in line + ] + if load_failures: + self._terminate_process(project.process) + project.process = None + return _fail( + "hooks", + [ + "A hook file failed to load — every route and cron job " + "defined after the throwing line DOES NOT EXIST in the " + "running app:\n" + "\n".join(load_failures[:5]), + ], + ) # Walk-verify smoke pass (headless, invisible): app must mount with # zero console errors. 'skipped' (no browser) never blocks a launch. @@ -779,7 +880,14 @@ def _fail(step: str, errors: list) -> dict: if verify_status == "fail": self._terminate_process(project.process) project.process = None - return _fail("verify", [verify_detail]) + # The browser sees only status codes; the CAUSE (hook exception, + # bad query) is server-side. Ship this boot's log lines so the + # agent debugs evidence instead of inventing explanations. + errors = [verify_detail] + boot_log = _pb_log_since_boot() + if boot_log: + errors.append("pocketbase.log (this boot):\n" + boot_log) + return _fail("verify", errors) if verify_status == "skipped": logger.warning( f"[LIVING_UI:V2] verify skipped for {project.id}: {verify_detail}" diff --git a/app/living_ui/v2_runner.py b/app/living_ui/v2_runner.py index f8965617..100525a0 100644 --- a/app/living_ui/v2_runner.py +++ b/app/living_ui/v2_runner.py @@ -80,6 +80,12 @@ async def _run( # Without this, spawning node/npm/pocketbase from this windowless # process makes Windows flash a new console window per invocation. kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + else: + # Own process group, so a timeout can kill the WHOLE TREE. Killing + # only the direct child (cli.ts) orphans its pocketbase grandchild + # — observed: a wedged `pocketbase migrate` surviving its parent's + # timeout kill and squatting forever. + kwargs["start_new_session"] = True proc = await asyncio.create_subprocess_exec( *cmd, cwd=str(cwd) if cwd else None, @@ -90,7 +96,19 @@ async def _run( try: out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: - proc.kill() + if sys.platform == "win32": + subprocess.run( + ["taskkill", "/T", "/F", "/PID", str(proc.pid)], + capture_output=True, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + else: + import signal as _signal + + try: + os.killpg(proc.pid, _signal.SIGKILL) + except Exception: + proc.kill() return 124, f"timed out after {timeout}s: {' '.join(map(str, cmd))}" return proc.returncode or 0, out.decode(errors="replace") diff --git a/app/living_ui/walk_verify.py b/app/living_ui/walk_verify.py index 7a5b4d76..c0b66646 100644 --- a/app/living_ui/walk_verify.py +++ b/app/living_ui/walk_verify.py @@ -109,8 +109,20 @@ def parse_check_report(text: str) -> Dict[str, Any]: """Classify a walk_verify result. kinds: pass | defects | incomplete (NOT REACHED, defect-free) | blocked.""" text = text or "" - m = re.search(r"VERDICT:\s*(PASS|FAIL|BLOCKED)", text, re.IGNORECASE) + # The contract allows PASS|FAIL|BLOCKED, but sub-agents invent softeners — + # "VERDICT: INCOMPLETE" and "VERDICT: PARTIAL VERIFICATION" both observed + # live. An unknown word must NOT fall through to "blocked" (which + # announces the app with a misleading tooling-issue warning): treat the + # softeners as FAIL and let the defect / NOT-REACHED logic classify the + # report into the honest "incomplete" kind. + m = re.search( + r"VERDICT:\s*(PASS|FAIL|BLOCKED|INCOMPLETE|PARTIAL(?:\s+\w+)?)", + text, + re.IGNORECASE, + ) verdict = m.group(1).upper() if m else None + if verdict is not None and verdict.startswith(("INCOMPLETE", "PARTIAL")): + verdict = "FAIL" # A FAIL whose body describes a blockage is a blockage wearing a FAIL # costume — never dispatch fixes for defects nobody observed. diff --git a/app/living_ui/wizard.py b/app/living_ui/wizard.py index 7846658e..e4638660 100644 --- a/app/living_ui/wizard.py +++ b/app/living_ui/wizard.py @@ -294,6 +294,73 @@ def _render_config(config: Dict[str, Any], image_notes: List[str]) -> str: degrading gracefully offline.""" +_MARKETPLACE_CACHE: Dict[str, Any] = {} +_MARKETPLACE_TTL_SECONDS = 3600 +_MARKETPLACE_RAW_URL = ( + "https://raw.githubusercontent.com/CraftOS-dev/living-ui-marketplace/main/catalogue.json" +) + + +def _marketplace_catalogue() -> List[Dict[str, Any]]: + """[{id, name, description, tags}] of ready-made marketplace apps, or []. + + Local checkout first (developer machines), GitHub raw as fallback, + fail-open always — a missing catalogue must never block a build. Cached + an hour; the catalogue changes rarely. + """ + import time as _time + + cached = _MARKETPLACE_CACHE.get("apps") + if cached is not None and _time.time() - cached[0] < _MARKETPLACE_TTL_SECONDS: + return cached[1] + + apps: List[Dict[str, Any]] = [] + raw = None + for local in ( + Path(__file__).resolve().parents[2].parent / "living-ui-marketplace" / "catalogue.json", + ): + try: + if local.exists(): + raw = json.loads(local.read_text(encoding="utf-8")) + break + except Exception: + raw = None + if raw is None: + try: + import urllib.request + + with urllib.request.urlopen(_MARKETPLACE_RAW_URL, timeout=4) as response: + raw = json.loads(response.read().decode("utf-8")) + except Exception as e: + logger.debug(f"[WIZARD] marketplace catalogue unavailable: {e}") + raw = None + if isinstance(raw, dict): + entries = raw.get("apps") or [] + for entry in entries: + if isinstance(entry, dict) and entry.get("id") and entry.get("description"): + apps.append( + { + "id": entry["id"], + "name": entry.get("name") or entry["id"], + "description": str(entry["description"])[:200], + "tags": entry.get("tags") or [], + } + ) + + _MARKETPLACE_CACHE["apps"] = (_time.time(), apps) + return apps + + +def _render_marketplace(apps: List[Dict[str, Any]]) -> str: + if not apps: + return "" + lines = ["\nMARKETPLACE — ready-made apps that can be installed instead of building:"] + for a in apps: + tags = (" [" + ", ".join(a["tags"][:4]) + "]") if a["tags"] else "" + lines.append(f"- {a['id']}: {a['name']} — {a['description']}{tags}") + return "\n".join(lines) + "\n" + + INTERVIEW_SYSTEM_PROMPT = f"""You are a requirements interviewer for a web-app builder. \ The user described an app and picked configuration options; your questions close the \ gaps between that input and a complete, buildable specification. @@ -315,6 +382,13 @@ def _render_config(config: Dict[str, Any], image_notes: List[str]) -> str: always type a free answer instead, so options should capture the most likely \ answers. - Mark a question multiSelect when several options can genuinely combine. +- MARKETPLACE CHECK: when the input includes a MARKETPLACE list and one app \ +on it clearly matches what the user described (same core purpose, not merely \ +a shared word), your FIRST question must present it BY NAME and offer exactly: \ +"Install as-is (ready now)", "Install and adapt it to my needs", \ +"Build a fresh app from scratch". Reusing a finished app is the user's \ +decision — never silently rebuild what exists, and never force the question \ +when nothing genuinely matches. Respond with STRICT JSON only (no prose, no markdown fence): {{"questions": [{{"id": "q1", "question": "...", "why": "one short sentence on why this matters", "multiSelect": false, "options": ["...", "...", "...", "..."]}}]}}""" @@ -326,6 +400,7 @@ async def generate_interview( """Generate interview questions from the wizard configuration.""" user_prompt = ( _render_config(config, image_notes) + + _render_marketplace(_marketplace_catalogue()) + "\n\nGenerate the interview questions now (STRICT JSON)." ) raw = await _llm( @@ -378,6 +453,17 @@ async def generate_interview( the INTENT into the platform's equivalent (bridge pull on load/refresh plus a \ scheduled sync operation) and write THAT. +If an interview answer chose to INSTALL a marketplace app (as-is or adapted), \ +the document's FIRST line must be exactly: \ +`MARKETPLACE DECISION: install ; adapt: ` followed by a short \ +list of requested adaptations (if any). The builder installs that app via \ +living_ui_marketplace_install and applies only the adaptations — it must NOT \ +build from scratch. + +NEVER weaken a user-stated deliverable when rewriting: "email me" means the \ +user RECEIVES an email (via the bridge's send_gmail action) — not "queues", \ +"logs", or "prepares" one. Preserve user-visible outcomes verbatim. + The document is markdown with EXACTLY these sections: # — Requirements diff --git a/app/subagent/definitions/walk_verify.py b/app/subagent/definitions/walk_verify.py index 82e2591e..1ad0fb26 100644 --- a/app/subagent/definitions/walk_verify.py +++ b/app/subagent/definitions/walk_verify.py @@ -40,7 +40,16 @@ YOUR WALK: 1. read_file the requirements → a numbered list of the FEATURES a user should - be able to do (one per capability). + be able to do (one per capability). EVERY feature in the requirements MUST + appear in your final FEATURES list — including ones a browser cannot + exercise (scheduled emails, cron jobs, exports you can't download). + Omitting a feature makes an incomplete walk look complete: an app once + PASSED with its required daily-email feature silently unbuilt because the + walk simply left it off the list. For unexercisable features, grep_files + the project's hooks for their implementation (a mailer call, a cronAdd for + the schedule): implementation present → '— NOT REACHED (code present, not + exercisable in browser)'; NO implementing code at all → FAIL — the feature + was not built. 2. Open the app: browser_navigate to the app URL, then browser_snapshot. If the page is blank, an error boundary, or only skeletons, that is a FAIL for everything — the app doesn't run. @@ -59,7 +68,16 @@ record, browser_navigate to the app URL again (a full reload) and snapshot. If the data is gone, that feature is a FAIL ("saves" that vanish on reload are the most common way an app looks finished and isn't). -6. Decide each feature and end. +6. LIVE DATA — when a feature claims live/external/synced data (weather, + prices, feeds, "pulled from", "real-time"): rendered data is NOT evidence. + The fetch happens server-side, so the browser cannot see it — instead + grep_files the project's pb/pb_hooks/*.js (excluding _*.js) for + "$http.send" or "callIntegration". Neither present = FAIL for that + feature: "displays data but the app fetches nothing — the data cannot be + live". If the serving hook instead generates values (Math.random, + hardcoded samples), FAIL it and quote the line. This rule exists because + an app once rendered Math.random() as "live weather" and passed review. +7. Decide each feature and end. VERDICTS (mechanical, not stylistic): V1. PASS a feature ONLY with concrete evidence from an action YOU ran: a @@ -69,6 +87,18 @@ placeholder / "coming soon" / dead button) = FAIL, with what you observed. V3. No minor category: one console error during normal use = FAIL; a feature that "mostly" works = FAIL. +V3b. JUDGE THE VALUES LIKE A HUMAN USER, not just the rendering. Data that + renders but cannot be real is a FAIL: every temperature 0°, every price + $0.00, all rows identical, "undefined"/"NaN"/placeholder text where a + value belongs. Ask "would a person looking at this believe it?" — a + weather dashboard showing 0° for Lahore in July is broken no matter how + cleanly it rendered. Say WHAT value looked impossible in your report. +V3c. A 404 from a route DECLARED in ops.pb.js means the handler THREW (in + PocketBase, find* helpers throw NotFound on zero rows) — it does NOT mean + the route is unregistered. Report it as "handler error on ", not + "route missing": the wrong theory sends the builder to fix registration + that was never broken. A sibling route answering anything (even 400) + proves registration works. V4. FAIL means YOU SAW THE APP MISBEHAVE. If you could not exercise the app at all — the browser tools error out, the MCP connection is lost, the URL is unreachable — that is NOT the app's fault and NOT a FAIL: end with @@ -85,7 +115,9 @@ VERDICT: PASS | FAIL | BLOCKED FEATURES: - — PASS — -- — FAIL — +- — FAIL — | expected: - — NOT REACHED FAILURES (only if any FAIL): - : @@ -94,7 +126,9 @@ ``` VERDICT is PASS only if EVERY feature in your scope passed (NOT REACHED entries mean the walk is incomplete). Use FAIL only for behaviour you -observed; use BLOCKED when you never got to observe any. +observed; use BLOCKED when you never got to observe any. There is NO +"INCOMPLETE" or "PARTIAL" verdict — an unfinished walk is FAIL with +'— NOT REACHED' entries for whatever you did not exercise. """ diff --git a/craftos_integrations/integrations/gmail/__init__.py b/craftos_integrations/integrations/gmail/__init__.py index 6cff823c..deb22154 100644 --- a/craftos_integrations/integrations/gmail/__init__.py +++ b/craftos_integrations/integrations/gmail/__init__.py @@ -362,15 +362,19 @@ def _encode_email( def send_email( self, - to: str, - subject: str, - body: str, + to: Optional[str] = None, + subject: str = "", + body: str = "", from_email: Optional[str] = None, attachments: Optional[List[str]] = None, ) -> Result: cred = self._load() sender = from_email or cred.email - raw = self._encode_email(to, sender, subject, body, attachments) + # No recipient = the account owner. Callers reaching "the user" (a + # Living UI's daily digest, an agent self-notification) should never + # need to know or store the user's address — identity is CraftBot's. + recipient = to or cred.email + raw = self._encode_email(recipient, sender, subject, body, attachments) return http_request( "POST", f"{GMAIL_API_BASE}/users/me/messages/send", diff --git a/living-ui-v2/blueprint/LIVING_UI.md b/living-ui-v2/blueprint/LIVING_UI.md index a620d3d7..1881225f 100644 --- a/living-ui-v2/blueprint/LIVING_UI.md +++ b/living-ui-v2/blueprint/LIVING_UI.md @@ -23,6 +23,16 @@ See `reference/requirements.md` (binding). Feature checklist: Declared in `operations.json`; discoverable at `GET /api/_ops`. +## External data + +| Source | Used for | Auth | Called from | +|--------|----------|------|-------------| +| (none yet — external APIs are called from pb_hooks via `$http.send`; list each source here) | | | | + +Rules: hooks only (never the frontend), always a `timeout`, non-200 → clean +error + offline/empty state. Never substitute generated data for a real +source. Scheduled syncs use `cronAdd`. + ## Ownership map - Editable: `frontend/src/app/`, `pb/pb_migrations/`, `pb/pb_hooks/ops.pb.js`, diff --git a/living-ui-v2/blueprint/pb/pb_hooks/_a2app.pb.js b/living-ui-v2/blueprint/pb/pb_hooks/_a2app.pb.js index 6c33c438..d94683c0 100644 --- a/living-ui-v2/blueprint/pb/pb_hooks/_a2app.pb.js +++ b/living-ui-v2/blueprint/pb/pb_hooks/_a2app.pb.js @@ -14,6 +14,35 @@ * Keep the bodies below trivial. */ +// Handler exceptions are otherwise INVISIBLE: PocketBase converts an uncaught +// throw into a generic 404/500 with no log line — which reads exactly like a +// missing route. An agent burned three rebuild cycles on "routerAdd isn't +// activating" while its handler was throwing NotFound from a find* call on +// zero rows. Log the REAL error server-side, response unchanged. Registered +// FIRST so it wraps the guard and every app handler beneath it. +routerUse((e) => { + try { + return e.next(); + } catch (err) { + try { + const path = String((e.request && e.request.url && e.request.url.path) || ''); + // PB's own CRUD/realtime endpoints surface their errors in responses + // already; logging them here would be noise. Custom routes are the + // ones whose failures vanish. + if (path.indexOf('/api/collections/') !== 0 && path.indexOf('/api/realtime') !== 0) { + console.error( + '[handler-error] ' + ((e.request && e.request.method) || '?') + ' ' + path + + ' threw: ' + err + + ' — if this is a find* call, PocketBase find helpers THROW on no rows (they never return null).' + ); + } + } catch { + /* logging must never change the outcome */ + } + throw err; + } +}); + routerUse((e) => { const a2 = require(`${__hooks}/_a2app_lib.js`); return a2.guardRequest(e); diff --git a/living-ui-v2/blueprint/pb/pb_hooks/_craftbot_bridge.js b/living-ui-v2/blueprint/pb/pb_hooks/_craftbot_bridge.js index 60f03397..b3bd4406 100644 --- a/living-ui-v2/blueprint/pb/pb_hooks/_craftbot_bridge.js +++ b/living-ui-v2/blueprint/pb/pb_hooks/_craftbot_bridge.js @@ -5,9 +5,13 @@ * * const bridge = require(`${__hooks}/_craftbot_bridge.js`); * const text = bridge.callLLM('Summarize: ...', 'You are terse.'); - * const res = bridge.callIntegration('slack', 'POST', '/chat.postMessage', { ... }); + * const res = bridge.callAction('send_gmail', { to, subject, body }); + * const raw = bridge.callIntegration('slack', 'POST', '/chat.postMessage', { ... }); * - * Both no-op gracefully when the app runs outside CraftBot (env vars unset). + * PREFER callAction: it runs CraftBot's own tested implementation with + * semantic params — no provider-API knowledge needed. callIntegration is the + * raw fallback for endpoints no action covers (you must use the provider's + * real paths/payloads there). All no-op gracefully outside CraftBot. */ function callLLM(prompt, systemMessage) { @@ -31,6 +35,39 @@ function callLLM(prompt, systemMessage) { } } +function callAction(actionName, params, options) { + try { + const bridge = $os.getenv('CRAFTBOT_BRIDGE_URL'); + const token = $os.getenv('CRAFTBOT_BRIDGE_TOKEN'); + if (!bridge || !token) { + return { status: 503, error: 'CraftBot integration bridge is unavailable' }; + } + const res = $http.send({ + url: bridge + '/api/integrations/action', + method: 'POST', + body: JSON.stringify({ + action: actionName, + params: params || {}, + confirm_irreversible: !!(options && options.confirmIrreversible), + // dryRun: validate everything (grant, params, confirmation) WITHOUT + // executing — build-time verification of paths that must never fire + // for real (emails, posts, deletes). + dry_run: !!(options && options.dryRun), + }), + headers: { + 'content-type': 'application/json', + authorization: 'Bearer ' + token, + }, + timeout: 120, + }); + const out = res.json || { error: 'Empty bridge response' }; + if (out.status === undefined) out.status = res.statusCode || 502; + return out; + } catch (err) { + return { status: 502, error: String(err) }; + } +} + function callIntegration(integration, method, url, body, headers) { try { const bridge = $os.getenv('CRAFTBOT_BRIDGE_URL'); @@ -64,5 +101,6 @@ function callIntegration(integration, method, url, body, headers) { module.exports = { callLLM: callLLM, + callAction: callAction, callIntegration: callIntegration, }; diff --git a/living-ui-v2/package-lock.json b/living-ui-v2/package-lock.json index 71e2b035..d3fa6e65 100644 --- a/living-ui-v2/package-lock.json +++ b/living-ui-v2/package-lock.json @@ -72,6 +72,7 @@ "examples/ci-demo/frontend": { "name": "lui-app-ci-demo", "version": "0.1.0", + "extraneous": true, "dependencies": { "@radix-ui/react-dialog": "^1.1.0", "class-variance-authority": "^0.7.0", @@ -114,6 +115,29 @@ "vite": "^7.0.0" } }, + "examples/egress-demo/frontend": { + "name": "lui-app-egress-demo", + "version": "0.1.0", + "extraneous": true, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "pocketbase": "^0.26.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^2.5.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.0.0", + "tailwindcss": "^4.1.0", + "typescript": "^5.6.0", + "vite": "^7.0.0" + } + }, "examples/team-tasks/frontend": { "name": "lui-app-team-tasks", "version": "0.1.0", @@ -137,6 +161,28 @@ "vite": "^7.0.0" } }, + "examples/wedge-test/frontend": { + "name": "lui-app-wedge-test", + "version": "0.1.0", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "pocketbase": "^0.26.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^2.5.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.0.0", + "tailwindcss": "^4.1.0", + "typescript": "^5.6.0", + "vite": "^7.0.0" + } + }, "kit": { "name": "@livingui/kit", "version": "0.5.0", @@ -3690,8 +3736,8 @@ "yallist": "^3.0.2" } }, - "node_modules/lui-app-ci-demo": { - "resolved": "examples/ci-demo/frontend", + "node_modules/lui-app-wedge-test": { + "resolved": "examples/wedge-test/frontend", "link": true }, "node_modules/magic-string": { diff --git a/living-ui-v2/tools/src/cli.ts b/living-ui-v2/tools/src/cli.ts index fd1cbd88..5c054124 100755 --- a/living-ui-v2/tools/src/cli.ts +++ b/living-ui-v2/tools/src/cli.ts @@ -21,6 +21,49 @@ const COMMANDS: Record = { 'adapter-sync': { summary: 'Re-vendor only the system pb_hooks (A2APP adapter) — no rebuild' }, }; +/** + * Errors must arrive TRUE or agents hallucinate around them. Node's fetch + * throws a bare `TypeError: fetch failed` and hides the real reason + * (ECONNREFUSED, ENOTFOUND, ETIMEDOUT…) in a nested `cause` / + * AggregateError. Observed live: an agent read "✗ fetch failed" from a + * connection-refused to its own STOPPED app and told the user "this + * environment has NO outbound internet access; the code is 100% correct." + * Unwrap the whole chain and, for connection failures to a local app, say + * the one sentence that matters. + */ +function describeError(err: unknown): string { + const parts: string[] = []; + const seen = new Set(); + let current: unknown = err; + while (current !== undefined && current !== null && !seen.has(current)) { + seen.add(current); + if (current instanceof AggregateError) { + for (const sub of current.errors) { + const msg = sub instanceof Error ? sub.message : String(sub); + if (msg) parts.push(msg); + } + current = undefined; + } else if (current instanceof Error) { + if (current.message) parts.push(current.message); + current = (current as Error & { cause?: unknown }).cause; + } else { + parts.push(String(current)); + current = undefined; + } + } + let message = parts.join(' — caused by: '); + if (/ECONNREFUSED|ECONNRESET/.test(message)) { + message += + '\nThe app is NOT RUNNING (connection refused is a dead local server, ' + + 'not a network problem). Relaunch it with living_ui_notify_ready, then retry.'; + } else if (/ENOTFOUND|EAI_AGAIN/.test(message)) { + message += '\nDNS lookup failed for the target host — check the hostname.'; + } else if (/ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT/.test(message)) { + message += '\nThe target did not answer in time — it may be down or unreachable.'; + } + return message || String(err); +} + async function main(): Promise { const [, , name, ...args] = process.argv; @@ -57,7 +100,7 @@ main().then( process.exitCode = code; }, (err: unknown) => { - log.error(err instanceof Error ? err.message : String(err)); + log.error(describeError(err)); process.exitCode = 1; }, ); diff --git a/living-ui-v2/tools/src/commands/probe.ts b/living-ui-v2/tools/src/commands/probe.ts index dbadec22..34bf19bf 100644 --- a/living-ui-v2/tools/src/commands/probe.ts +++ b/living-ui-v2/tools/src/commands/probe.ts @@ -47,6 +47,10 @@ export async function run(args: string[]): Promise { page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 300)); }); + page.on('requestfailed', (req) => { + const failure = req.failure()?.errorText ?? 'request failed'; + consoleErrors.push(`REQUEST FAILED: ${req.method()} ${req.url().slice(0, 200)} — ${failure}`); + }); page.on('response', (res) => { if (res.status() >= 400) consoleErrors.push(`HTTP ${res.status()}: ${res.request().method()} ${res.url().slice(0, 200)}`); }); diff --git a/living-ui-v2/tools/src/commands/validate.egress.test.ts b/living-ui-v2/tools/src/commands/validate.egress.test.ts new file mode 100644 index 00000000..9a142965 --- /dev/null +++ b/living-ui-v2/tools/src/commands/validate.egress.test.ts @@ -0,0 +1,159 @@ +/** + * collectEgressHosts — the three scan outcomes (EXTERNAL-DATA-PLAN §4): + * 1. literal / helper-passed URLs → hosts recorded + * 2. no $http.send → nothing recorded + * 3. $http.send with zero literal hosts in the file → unresolved (file:line) + * Run: node --test tools/src/commands/validate.egress.test.ts + */ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { collectEgressHosts } from './validate.ts'; + +function project(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), 'lui-egress-')); + mkdirSync(join(dir, 'pb', 'pb_hooks'), { recursive: true }); + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(dir, 'pb', 'pb_hooks', name), content); + } + return dir; +} + +test('literal url in $http.send → host recorded', () => { + const dir = project({ + 'ops.pb.js': ` +const OPEN_METEO = 'https://api.open-meteo.com/v1/forecast'; +routerAdd('POST', '/api/ops/weather-refresh', (e) => { + const res = $http.send({ url: OPEN_METEO + '?latitude=1', method: 'GET', timeout: 20 }); + return e.json(200, res.json); +});`, + }); + try { + const scan = collectEgressHosts(dir); + assert.deepEqual(scan.hosts, ['api.open-meteo.com']); + assert.deepEqual(scan.unresolved, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('helper-passed url (yahoo.js shape) → hosts from call-site literals, no false warning', () => { + const dir = project({ + 'yahoo.js': ` +function yahooGet(url) { + const res = $http.send({ url: url, method: 'GET', timeout: 20 }); + return res.json; +} +function chart(symbol) { + return yahooGet('https://query1.finance.yahoo.com/v8/finance/chart/' + symbol); +} +function quote(symbol) { + return yahooGet(\`https://query2.finance.yahoo.com/v7/finance/quote?symbols=\${symbol}\`); +}`, + }); + try { + const scan = collectEgressHosts(dir); + assert.deepEqual(scan.hosts, ['query1.finance.yahoo.com', 'query2.finance.yahoo.com']); + assert.deepEqual(scan.unresolved, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('no egress, loopback-only, comments, and system files → empty scan', () => { + const dir = project({ + 'ops.pb.js': ` +// docs: https://should-not-count.example.com/guide +routerAdd('POST', '/api/ops/items/clear-done', (e) => { + const records = e.app.findRecordsByFilter('items', 'done = true', '', 0, 0); + return e.json(200, { cleared: records.length }); +});`, + 'local.pb.js': ` +routerAdd('GET', '/api/ops/self', (e) => { + const res = $http.send({ url: 'http://127.0.0.1:3100/api/health', method: 'GET', timeout: 5 }); + return e.json(200, res.json); +});`, + '_system_like.js': ` +const res = $http.send({ url: 'https://system-module-not-scanned.example.com', method: 'GET' });`, + }); + try { + const scan = collectEgressHosts(dir); + assert.deepEqual(scan.hosts, []); + // local.pb.js has $http.send and zero non-loopback literals → its own + // egress set is empty, but the loopback literal exists, so it is NOT dark. + assert.deepEqual(scan.unresolved, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('split module (URLs in helper, $http passed as param) → hosts recorded, no warning', () => { + // The observed weather_tracker_d8fb248a shape: the module holds the literal + // but never mentions $http.send; the caller sends but holds no literal. + const dir = project({ + 'weather.js': ` +const OPEN_METEO = 'https://api.open-meteo.com/v1/forecast'; +function refreshWeather(app, http) { + const res = http.send({ url: OPEN_METEO + '?latitude=1', method: 'GET', timeout: 20 }); + return res.json; +} +module.exports = { refreshWeather };`, + 'ops.pb.js': ` +routerAdd('POST', '/api/ops/weather-refresh', (e) => { + const weather = require(\`\${__hooks}/weather.js\`); + const res = $http.send; // reference without a literal in this file + return e.json(200, { current: weather.refreshWeather(e.app, $http) }); +});`, + }); + try { + const scan = collectEgressHosts(dir); + assert.deepEqual(scan.hosts, ['api.open-meteo.com']); + assert.deepEqual(scan.unresolved, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('callIntegration + callAction literals → integrations/actions derived (grant sources)', () => { + const dir = project({ + 'ops.pb.js': ` +routerAdd('POST', '/api/ops/send-reminder', (e) => { + const bridge = require(\`\${__hooks}/_craftbot_bridge.js\`); + const res = bridge.callAction('send_gmail', { to: 'x', subject: 's', body: 'b' }, { confirmIrreversible: true }); + const res2 = bridge.callIntegration("slack", 'POST', '/api/chat.postMessage', {}); + const res3 = bridge.callAction("send_slack_message", { channel: '#g', message: 'm' }); + return e.json(200, { ok: true }); +});`, + }); + try { + const scan = collectEgressHosts(dir); + assert.deepEqual(scan.integrations, ['slack']); + assert.deepEqual(scan.actions, ['send_gmail', 'send_slack_message']); + assert.deepEqual(scan.hosts, []); // bridge calls are not raw egress + assert.deepEqual(scan.unresolved, []); // and not dark — no $http.send here + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('computed destination with no literal anywhere → unresolved with file:line', () => { + const dir = project({ + 'relay.pb.js': ` +routerAdd('POST', '/api/ops/relay', (e) => { + const target = e.requestInfo().body.target; + const res = $http.send({ url: target, method: 'POST', timeout: 10 }); + return e.json(200, res.json); +});`, + }); + try { + const scan = collectEgressHosts(dir); + assert.deepEqual(scan.hosts, []); + assert.equal(scan.unresolved.length, 1); + assert.equal(scan.unresolved[0]?.file, 'relay.pb.js'); + assert.equal(scan.unresolved[0]?.line, 4); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/living-ui-v2/tools/src/commands/validate.ts b/living-ui-v2/tools/src/commands/validate.ts index 1aa3fcd7..84a40142 100644 --- a/living-ui-v2/tools/src/commands/validate.ts +++ b/living-ui-v2/tools/src/commands/validate.ts @@ -7,10 +7,10 @@ * Machine-readable failures: one line per error, `step: message`. */ import { execFileSync } from 'node:child_process'; -import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { verifySystemHashes } from '../lib/hashes.ts'; +import { fileMatchesCanon, recordFileHash, verifySystemHashes } from '../lib/hashes.ts'; import { log } from '../lib/log.ts'; import { ensurePbBinary } from './pb.ts'; @@ -182,6 +182,182 @@ function checkTransactionHandles(projectDir: string): void { } } +/** + * Egress scan (spec EXTERNAL-DATA-PLAN §4): derive the app's outbound surface. + * + * `capabilities.external_hosts` in manifest.json is written by the GATE, never + * declared by the agent — same lifecycle as `.lui/system-hashes.json`. One + * JSON field answers "what does this app talk to?" for build output, users, + * and (later) marketplace review. Born from the weather-tracker incident, + * where an app whose requirements promised live API data shipped + * `Math.random()` and nothing could see the difference. + * + * Scan rules: + * - Agent hook files only. `_*.js` system modules are hash-verified and talk + * only to the loopback bridge via env vars — scanning them would produce + * false "undeterminable destination" warnings. + * - Hosts come from `https?://` string literals in EVERY agent hook file + * (comments stripped) — not only files containing `$http.send`. Literal + * collection, not per-call URL resolution, because real hooks split code + * across helpers: trading-view's yahoo.js takes `url` as a parameter, and + * an observed weather app kept its URLs in a module that received `$http` + * itself as a parameter — the send and the literal need not share a file. + * - Only a PROJECT with `$http.send` and no URL literal anywhere is genuinely + * dark → each such call site is reported as unresolved (file:line). + * Warning today; marketplace ingest is expected to reject it (tier 2). + */ +export interface EgressScan { + hosts: string[]; + /** CraftBot integrations used via bridge.callIntegration('', …) — + * derived from code the same way hosts are. The manifest grant the bridge + * fails closed on is WRITTEN from this: the code is the declaration. + * (Observed live: a weather app's email feature was dead because nothing + * in the platform could mint `capabilities.integrations` at all.) */ + integrations: string[]; + /** CraftBot ACTIONS used via bridge.callAction('', …) — the + * preferred integration surface (semantic params, CraftBot's own + * implementation). capabilities.actions is written from this. */ + actions: string[]; + unresolved: { file: string; line: number }[]; +} + +const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '0.0.0.0', '::1', '[::1]']); + +export function collectEgressHosts(projectDir: string): EgressScan { + const hooksDir = join(projectDir, 'pb', 'pb_hooks'); + const hosts = new Set(); + const integrations = new Set(); + const actions = new Set(); + const darkCallSites: { file: string; line: number }[] = []; + let projectSawLiteral = false; + if (!existsSync(hooksDir)) return { hosts: [], integrations: [], actions: [], unresolved: [] }; + + for (const name of readdirSync(hooksDir)) { + if (!name.endsWith('.js') || name.startsWith('_')) continue; + const source = readFileSync(join(hooksDir, name), 'utf8'); + + // Strip block comments and whole-line // comments so a doc link does not + // count as egress. (Trailing // comments are left alone — cutting them + // naively would truncate the `//` inside 'https://…' string literals.) + const code = source + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, ''); + + // Loopback literals (an app calling its own API) are not egress, but they + // DO prove destinations are visible. + let fileSawLiteral = false; + for (const m of code.matchAll(/['"`](https?:\/\/[^'"`\n$]+)/g)) { + try { + const host = new URL(m[1] ?? '').hostname.toLowerCase(); + if (host === '') continue; + fileSawLiteral = true; + if (!LOOPBACK_HOSTS.has(host)) hosts.add(host); + } catch { + /* not a parseable URL — ignore */ + } + } + if (fileSawLiteral) projectSawLiteral = true; + + // Bridge integrations: the first argument of callIntegration is the id. + for (const m of code.matchAll(/callIntegration\(\s*['"`]([a-z][a-z0-9_]*)['"`]/g)) { + integrations.add(m[1] ?? ''); + } + // Bridge actions: the first argument of callAction is the action name. + for (const m of code.matchAll(/callAction\(\s*['"`]([a-z][a-z0-9_]*)['"`]/g)) { + actions.add(m[1] ?? ''); + } + + // Call sites whose own file holds no literal — dark ONLY if the whole + // project turns out literal-free (decided after the loop). + if (!fileSawLiteral && source.includes('$http.send')) { + for (const call of source.matchAll(/\$http\.send\s*\(/g)) { + const line = source.slice(0, call.index ?? 0).split('\n').length; + darkCallSites.push({ file: name, line }); + } + } + } + return { + hosts: [...hosts].sort(), + integrations: [...integrations].filter(Boolean).sort(), + actions: [...actions].filter(Boolean).sort(), + unresolved: projectSawLiteral ? [] : darkCallSites, + }; +} + +/** Frontend must not call external hosts directly (CORS breaks standalone + * deploys; anything secret would be public). Warning-only: matches only + * fetch('https://… — plain href links in JSX are legitimate. */ +function collectFrontendEgress(projectDir: string): { file: string; line: number; host: string }[] { + const appDir = join(projectDir, 'frontend', 'src', 'app'); + const found: { file: string; line: number; host: string }[] = []; + if (!existsSync(appDir)) return found; + for (const rel of readdirSync(appDir, { recursive: true }) as string[]) { + if (!/\.(ts|tsx)$/.test(rel)) continue; + const abs = join(appDir, rel); + if (statSync(abs).isDirectory()) continue; + const source = readFileSync(abs, 'utf8'); + for (const m of source.matchAll(/fetch\(\s*['"`](https?:\/\/[^'"`\n$]+)/g)) { + try { + const host = new URL(m[1] ?? '').hostname.toLowerCase(); + if (LOOPBACK_HOSTS.has(host)) continue; + const line = source.slice(0, m.index ?? 0).split('\n').length; + found.push({ file: rel, line, host }); + } catch { + /* ignore */ + } + } + } + return found; +} + +/** Write the derived host list into manifest.json's `capabilities` and + * re-record its canon hash. Skips (with a warning) when the manifest does + * not match canon — the tooling must never write on top of, and thereby + * launder, an agent edit; the ownership step will report the tampering. */ +function syncEgressManifest( + projectDir: string, + hosts: string[], + integrations: string[], + actions: string[], +): void { + const manifestPath = join(projectDir, 'manifest.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Record; + const capabilities = { ...((manifest.capabilities as Record) ?? {}) }; + const same = (key: string, next: string[]): boolean => { + const current = Array.isArray(capabilities[key]) ? (capabilities[key] as string[]) : []; + return current.length === next.length && current.every((v, idx) => v === next[idx]); + }; + if ( + same('external_hosts', hosts) && + same('integrations', integrations) && + same('actions', actions) + ) { + return; + } + + const clean = fileMatchesCanon(projectDir, 'manifest.json'); + if (clean === false) { + log.warn('manifest.json differs from canon — skipping egress write (see ownership step)'); + return; + } + if (clean === null) { + log.warn('no hash canon yet — skipping egress manifest write (create/kit-sync records it)'); + return; + } + + if (hosts.length === 0) delete capabilities.external_hosts; + else capabilities.external_hosts = hosts; + if (integrations.length === 0) delete capabilities.integrations; + else capabilities.integrations = integrations; + if (actions.length === 0) delete capabilities.actions; + else capabilities.actions = actions; + if (Object.keys(capabilities).length === 0) delete manifest.capabilities; + else manifest.capabilities = capabilities; + + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); + recordFileHash(projectDir, 'manifest.json'); +} + /** * Append the offending SOURCE to every error that names a location, in any * gate step — agents fix the wrong thing when they only see line numbers. @@ -210,6 +386,18 @@ function annotateErrors(output: string, searchDirs: string[]): string { return output .split('\n') .map((line) => { + // "apply" = migrate-up failure; "run" = the registration-time PANIC a + // syntactically-loaded-but-broken migration triggers. Checked FIRST: + // panic lines also carry goja's synthetic `pb.js:7:9` location, which + // the generic path:line:col matcher would grab (and fail to resolve). + const migration = line.match(/failed to (?:apply|run) migration ([\w.-]+\.js)/); + if (migration !== null) { + const content = readSource(join('pb', 'pb_migrations', migration[1] ?? '')); + if (content !== null) { + const head = content.split('\n').slice(0, 80).join('\n'); + return `${line}\n --- ${migration[1]} (the failing migration) ---\n${head}`; + } + } const paren = line.match(/^(.+?)\((\d+),(\d+)\): /); if (paren !== null) { return annotateAt(line, paren[1] ?? '', Number(paren[2]), Number(paren[3])); @@ -218,14 +406,6 @@ function annotateErrors(output: string, searchDirs: string[]): string { if (colon !== null) { return annotateAt(line, colon[1] ?? '', Number(colon[2]), Number(colon[3])); } - const migration = line.match(/failed to apply migration ([\w.-]+\.js)/); - if (migration !== null) { - const content = readSource(join('pb', 'pb_migrations', migration[1] ?? '')); - if (content !== null) { - const head = content.split('\n').slice(0, 80).join('\n'); - return `${line}\n --- ${migration[1]} (the failing migration) ---\n${head}`; - } - } return line; }) .join('\n'); @@ -236,12 +416,23 @@ function runStep(errors: GateError[], step: string, fn: () => void): void { fn(); log.ok(step); } catch (err) { - const message = - err instanceof Error && 'stdout' in err - ? String((err as Error & { stdout?: unknown }).stdout ?? err.message) - : err instanceof Error - ? err.message - : String(err); + // A spawned tool's failure can live in stdout OR stderr — PocketBase + // PANICS to stderr with an empty stdout (e.g. a bad migration at hook + // registration). Reading only stdout produced a blank error, and an agent + // told "step failed: " retries blind until it gives up. Never + // emit an empty message. + let message = ''; + if (err instanceof Error) { + const spawned = err as Error & { stdout?: unknown; stderr?: unknown }; + message = [spawned.stdout, spawned.stderr] + .map((s) => String(s ?? '').trim()) + .filter((s) => s !== '') + .join('\n'); + if (message === '') message = err.message; + } else { + message = String(err); + } + if (message.trim() === '') message = `${step}: failed with no output (exit status only)`; errors.push({ step, message: message.trim().slice(0, 4000) }); log.error(`${step} failed`); } @@ -343,20 +534,56 @@ export async function run(args: string[]): Promise { try { // NOTE: `pocketbase migrate up` exits 0 even when a migration fails — // it only PRINTS the error. Scan output; never trust the exit code. - const out = execFileSync( - pbBin, - [ - 'migrate', - 'up', - '--dir', - tempData, - '--migrationsDir', - join(projectDir, 'pb', 'pb_migrations'), - '--hooksDir', - join(projectDir, 'pb', 'pb_hooks'), - ], - { stdio: 'pipe', encoding: 'utf8' }, - ); + // NOTE: timeout is NOT optional. A Go-side nil panic (observed cause: + // `new Record('')` instead of the Collection + // object) leaves the process WEDGED — alive, silent, never exiting — + // and without a timeout this step blocks the whole gate forever. + let out = ''; + try { + out = execFileSync( + pbBin, + [ + 'migrate', + 'up', + '--dir', + tempData, + '--migrationsDir', + join(projectDir, 'pb', 'pb_migrations'), + '--hooksDir', + join(projectDir, 'pb', 'pb_hooks'), + ], + { stdio: 'pipe', encoding: 'utf8', timeout: 120_000, killSignal: 'SIGKILL' }, + ); + } catch (err) { + const spawned = err as Error & { + killed?: boolean; + signal?: string; + code?: string; + stdout?: unknown; + stderr?: unknown; + }; + // Node's timeout error is inconsistent across versions: detect the + // kill by any of its three faces, not just `killed`. + const timedOut = + spawned.killed === true || + spawned.signal === 'SIGKILL' || + spawned.code === 'ETIMEDOUT'; + if (timedOut) { + const partial = [spawned.stdout, spawned.stderr] + .map((s) => String(s ?? '').trim()) + .filter((s) => s !== '') + .join('\n'); + throw new Error( + 'migrations ran for >120s and were killed — a migration or hook is ' + + 'blocking the process. Known cause: `new Record()` ' + + 'nil-panics PocketBase and WEDGES it without exiting; the Record ' + + 'constructor needs the Collection OBJECT ' + + '(`new Record(app.findCollectionByNameOrId(\'name\'))`).' + + (partial === '' ? '' : `\nlast output:\n${partial.slice(-2000)}`), + ); + } + throw err; + } const failure = out.split('\n').find((l) => /^\s*Error[:\s]/.test(l)); if (failure !== undefined) { throw new Error( @@ -364,6 +591,16 @@ export async function run(args: string[]): Promise { `save the target first, then use app.findCollectionByNameOrId('').id`, ); } + // A Go panic that PocketBase "recovered" is still a broken migration — + // and the usual trigger is new Record() instead of the object. + const panicked = out.split('\n').find((l) => /RECOVERED FROM PANIC|^panic:/.test(l)); + if (panicked !== undefined) { + throw new Error( + `${panicked.trim()}\nA migration crashed PocketBase internally. Known cause: ` + + `new Record() — the Record constructor needs the Collection ` + + `OBJECT: new Record(app.findCollectionByNameOrId('name')).`, + ); + } } finally { rmSync(tempData, { recursive: true, force: true }); } @@ -375,6 +612,31 @@ export async function run(args: string[]): Promise { checkTransactionHandles(projectDir) ); + runStep(errors, 'egress (external hosts)', () => { + const scan = collectEgressHosts(projectDir); + for (const u of scan.unresolved) { + log.warn( + `${u.file}:${u.line}: outbound call with undeterminable destination — ` + + `keep the base URL as a string literal in this file so it can be recorded`, + ); + } + for (const f of collectFrontendEgress(projectDir)) { + log.warn( + `frontend/src/app/${f.file}:${f.line}: direct fetch to ${f.host} — ` + + `external calls belong in pb_hooks (CORS breaks standalone deploys; ` + + `anything secret would be public)`, + ); + } + syncEgressManifest(projectDir, scan.hosts, scan.integrations, scan.actions); + log.info(scan.hosts.length === 0 ? 'no external hosts' : `talks to: ${scan.hosts.join(', ')}`); + if (scan.integrations.length > 0) { + log.info(`uses integrations: ${scan.integrations.join(', ')}`); + } + if (scan.actions.length > 0) { + log.info(`uses actions: ${scan.actions.join(', ')}`); + } + }); + runStep(errors, 'ownership (system files unmodified)', () => { const drift = verifySystemHashes(projectDir); const problems: string[] = [ diff --git a/living-ui-v2/tools/src/commands/verify.ts b/living-ui-v2/tools/src/commands/verify.ts index c81ded33..e262203d 100644 --- a/living-ui-v2/tools/src/commands/verify.ts +++ b/living-ui-v2/tools/src/commands/verify.ts @@ -50,19 +50,61 @@ export async function run(args: string[]): Promise { page.on('console', (msg) => { if (msg.type() === 'error') consoleErrors.push(msg.text().slice(0, 500)); }); - page.on('response', (res) => { - if (res.status() >= 400) consoleErrors.push(`HTTP ${res.status()}: ${res.request().method()} ${res.url().slice(0, 200)}`); + page.on('response', async (res) => { + if (res.status() < 400) return; + // The status alone starves the fixing agent: a 502 whose body says + // "CITIES is not defined" is diagnosable, a bare "HTTP 502" is a wall + // (observed live — six blind retries). Always attach the body. + let body = ''; + try { + body = (await res.text()).replace(/\s+/g, ' ').trim().slice(0, 300); + } catch { + /* body unavailable (redirect/aborted) — status alone will have to do */ + } + consoleErrors.push( + `HTTP ${res.status()}: ${res.request().method()} ${res.url().slice(0, 200)}` + + (body !== '' ? ` — response: ${body}` : ''), + ); }); page.on('pageerror', (err) => consoleErrors.push(`pageerror: ${err.message.slice(0, 500)}`)); + // Failed REQUESTS never produce a response: the console shows only + // "net::ERR_CONNECTION_REFUSED" with NO URL — an agent once diagnosed a + // nonexistent "Vite dev server" from that blank. Name the URL and cause. + page.on('requestfailed', (req) => { + const failure = req.failure()?.errorText ?? 'request failed'; + consoleErrors.push(`REQUEST FAILED: ${req.method()} ${req.url().slice(0, 200)} — ${failure}`); + }); // NOTE: never wait for 'networkidle' — Living UIs hold a permanent SSE // connection (realtime subscriptions), so the network is never idle. - let loaded = true; - try { - await page.goto(url, { waitUntil: 'load', timeout: 20000 }); - await page.waitForTimeout(1500); // let React mount + realtime settle - } catch { - loaded = false; + // Retry once on (a) load failure or (b) connection-refused RESOURCES + // during the settle window: both are the signature of PocketBase's + // hook-watcher restart blip (~1-2s), and a smoke check landing in that + // window failed healthy apps twice (observed live). A genuinely dead or + // broken app still fails — both attempts. + let loaded = false; + let retried = false; + for (let attempt = 0; attempt < 2; attempt++) { + if (attempt > 0) { + retried = true; + consoleErrors.length = 0; // the blip's first paint is not evidence + await page.waitForTimeout(3000); + } + try { + await page.goto(url, { waitUntil: 'load', timeout: 20000 }); + await page.waitForTimeout(1500); // let React mount + realtime settle + loaded = true; + } catch { + loaded = false; + continue; // load failed → retry once + } + if ( + attempt === 0 && + consoleErrors.some((e) => /ERR_CONNECTION_(REFUSED|RESET)/.test(e)) + ) { + continue; // refused resources on first paint → clean re-check + } + break; // clean (or final) attempt — verdict uses what we have } const mounted = loaded @@ -76,7 +118,12 @@ export async function run(args: string[]): Promise { await page.screenshot({ path: screenshotPath, fullPage: false }).catch(() => {}); - const checks = { loaded, mounted, noConsoleErrors: consoleErrors.length === 0 }; + const checks: Record = { + loaded, + mounted, + noConsoleErrors: consoleErrors.length === 0, + }; + if (retried) checks.retriedLoad = true; // visible, but never fails the verdict const verdict: Verdict = { status: Object.values(checks).every(Boolean) ? 'pass' : 'fail', checks, diff --git a/living-ui-v2/tools/src/lib/hashes.ts b/living-ui-v2/tools/src/lib/hashes.ts index 00ff3618..1a5baa18 100644 --- a/living-ui-v2/tools/src/lib/hashes.ts +++ b/living-ui-v2/tools/src/lib/hashes.ts @@ -63,6 +63,31 @@ export function writeSystemHashes(projectDir: string): void { writeFileSync(join(projectDir, HASH_FILE), JSON.stringify(hashes, null, 2) + '\n'); } +/** Does `relPath` currently match its recorded canon hash? + * true = clean, false = drifted, null = no canon recorded (fresh scaffold + * mid-flight, or a path outside the canon). */ +export function fileMatchesCanon(projectDir: string, relPath: string): boolean | null { + const file = join(projectDir, HASH_FILE); + if (!existsSync(file)) return null; + const recorded = JSON.parse(readFileSync(file, 'utf8')) as Record; + const want = recorded[toPosix(relPath)]; + if (want === undefined) return null; + const abs = join(projectDir, relPath); + if (!existsSync(abs)) return false; + return sha256(abs) === want; +} + +/** Re-record the canon hash for ONE file the tooling itself just wrote + * (e.g. the gate refreshing manifest.json's derived `capabilities`). + * Never call this for agent-editable paths — it would canonize the edit. */ +export function recordFileHash(projectDir: string, relPath: string): void { + const file = join(projectDir, HASH_FILE); + if (!existsSync(file)) return; // no canon yet — create/kit-sync records it + const recorded = JSON.parse(readFileSync(file, 'utf8')) as Record; + recorded[toPosix(relPath)] = sha256(join(projectDir, relPath)); + writeFileSync(file, JSON.stringify(recorded, null, 2) + '\n'); +} + export interface OwnershipDrift { modified: string[]; missing: string[]; diff --git a/skills/living-ui-creator/SKILL.md b/skills/living-ui-creator/SKILL.md index 9f634515..38af9be3 100644 --- a/skills/living-ui-creator/SKILL.md +++ b/skills/living-ui-creator/SKILL.md @@ -33,7 +33,7 @@ Edit ONLY: |------|---------| | `frontend/src/app/` | all UI code | | `pb/pb_migrations/` | schema — one NEW migration per change | -| `pb/pb_hooks/ops.pb.js` + new `*.pb.js` | custom verbs | +| `pb/pb_hooks/ops.pb.js` + new `*.pb.js` / `*.js` modules | custom verbs + their helpers | | `operations.json` | declarations for those verbs (non-`system` entries) | | `LIVING_UI.md` | your plan/context/index — keep current | @@ -50,6 +50,11 @@ export function DueBadge({ overdue }: { overdue: boolean }) { /* compose */ } ## Before coding +0. **If `reference/requirements.md` starts with `MARKETPLACE DECISION: install + `** — do NOT build. Call + `living_ui_marketplace_install(app_id=..., name=..., description=...)`, + then apply only the listed adaptations (modify flow). The user explicitly + chose reuse over a fresh build. 1. Read `agent_file_system/GLOBAL_LIVING_UI.md` — colors, fonts, enforced rules. 2. Read `{project_path}/LIVING_UI.md` and `reference/requirements.md`. The creation wizard interviewed the user and synthesized `requirements.md` — it @@ -57,19 +62,56 @@ export function DueBadge({ overdue }: { overdue: boolean }) { /* compose */ } `LIVING_UI.md`. If it is absent, build from the project description; only ask the user (a FINAL `send_message`, `continue_work=false`) when something is genuinely blocking and you cannot reasonably decide it yourself. -3. A Living UI build is substantial work — the standard run protocol applies +3. **Any feature need data from outside the app? Check, then research.** + FIRST check the `[INTEGRATIONS this app can use]` block already in your + context — if a connected integration's action covers the feature (email = + `send_gmail`), use `bridge.callAction`; nothing to research. Only for + THIRD-PARTY public APIs: research like an engineer — endpoint, auth, + response shape, limits. Spawn a research_agent; never write an + integration hook from memory. + - User named an API/service → research it. If it needs a key, tenant URL, + or account detail you cannot find online, ask the user (final + `send_message`) and build the rest of the app while waiting. + - No API named → research candidates and pick a **keyless public API** + yourself (e.g. Open-Meteo for weather). Choosing the source is your + engineering call — no user round-trip. + - Nothing usable exists → build the honest empty/offline state and REPORT + the blocker in your final message. **Mock or generated data is forbidden** + unless requirements explicitly ask for demo data. +4. A Living UI build is substantial work — the standard run protocol applies as-is (scope, plan, execute, verify, deliver); this skill adds nothing to it. `reference/requirements.md` is the binding spec verification checks against; mirror the feature checklist in `LIVING_UI.md`. ## Per feature: schema → verbs → UI -**Schema** — add a new file in `pb/pb_migrations/` (never edit an applied one). -Follow the starter migration's pattern exactly: field types, `autodate` +**Schema** — add a new file in `pb/pb_migrations/`. **Never edit AND never +rename or delete a migration that has been applied** (i.e. after any +successful launch): the filename is the identity in the live database. +Renaming one makes every boot re-run its "new" replacement into the existing +schema — PocketBase exits before serving anything and the app cannot start +until the original filename is restored. Fixing a migration's mistake = +writing a NEW migration that alters the collection. +The ONLY top-level call is `migrate(upFn, downFn)` — the down/rollback +function is the **second argument**. A top-level `rollback(...)` does not +exist and panics the whole PocketBase process at load. Follow the starter +migration's pattern exactly: field types, `autodate` created/updated, and rules matching the project's `authMode` (`manifest.json`): `''` open rules for `none`; `@request.auth.id != ""` (or owner-scoped `owner = @request.auth.id` with a `relation` to `users`) for `multi-user`. +**Seeding records in a migration:** `new Record(...)` takes the **Collection +OBJECT — never an id string**. Passing `someCollection.id` nil-panics +PocketBase internally and can WEDGE the process (alive, silent, never +serving). The gate kills and reports it, but write it right: + +```js +const locations = app.findCollectionByNameOrId('locations'); // the OBJECT +const record = new Record(locations); +record.set('city_name', 'Manchester'); +app.save(record); +``` + **Relation fields — the #1 migration mistake:** `collectionId` must be the target collection's **ID, never its name**. Save the target collection first, then reference it: @@ -108,6 +150,81 @@ never call ops or filtered queries at page load that 400 without data — gate them behind existence checks (e.g. only call plan ops after a profile exists). The launch verifier fails the app on any first-paint console error. +**External data (third-party APIs)** — Living UIs CAN call the internet, from +**hooks only** (never the frontend: browser CORS breaks and keys would be +visible). Use `$http.send`. + +**THE #1 HOOK TRAP — handlers run in ISOLATED VMs.** Code inside a +`routerAdd`/`cronAdd`/`onRecord*` callback **cannot see file-level `const`s +or functions**: it throws `X is not defined` at REQUEST time, which the gate +(registration-time only) cannot catch. Share logic via a plain `.js` module +and `require()` it INSIDE each callback — module scope IS visible within the +module: + +```js +// pb/pb_hooks/weather.js — a MODULE (plain .js, not .pb.js) +const OPEN_METEO = 'https://api.open-meteo.com/v1/forecast'; // literal → recorded as egress + +function refreshAll(app) { + const res = $http.send({ + url: OPEN_METEO + '?latitude=53.48&longitude=-2.24¤t=temperature_2m,wind_speed_10m', + method: 'GET', + timeout: 20, // ALWAYS set a timeout + }); + if (res.statusCode !== 200) { + throw new Error('weather source returned HTTP ' + res.statusCode); + } + const data = res.json; // ONLY correct way to read the body — pre-parsed. + // res.body is a Go BYTE SLICE: JSON.parse(String(res.body)) throws + // "SyntaxError: Unexpected token at the end" on every response. If you + // remember fetch-style res.body/JSON.parse, that is the WRONG API here. + // …store readings via app.save(...) and return them +} +module.exports = { refreshAll: refreshAll }; +``` + +```js +// pb/pb_hooks/ops.pb.js — the route + the scheduled job use the SAME code path +routerAdd('POST', '/api/ops/weather-refresh', (e) => { + const weather = require(`${__hooks}/weather.js`); // require INSIDE the handler + try { + return e.json(200, { updated: weather.refreshAll(e.app).length }); + } catch (err) { + console.error('weather-refresh failed:', err); // → logs/pocketbase.log — ALWAYS + return e.json(502, { error: String(err) }); // log the CAUSE before the 502; + } // the browser only sees the status +}); + +cronAdd('weatherSync', '*/15 * * * *', () => { + const weather = require(`${__hooks}/weather.js`); + try { weather.refreshAll($app); } + catch (err) { console.error('weatherSync failed:', err); } +}); +``` + +- **Current PB API only:** `app.findRecordsByFilter(...)`, `app.save(...)`, + `app.delete(...)`. `$app.dao()` does **NOT exist** in this PocketBase — it + throws `Object has no member 'dao'`. If you remember `.dao()` from + tutorials, your memory is a major version out of date; copy the working + `items.clear-done` example instead. +- **PB find helpers THROW on no rows — they never return null.** + `findFirstRecordByFilter`/`findRecordById` on zero matches throws NotFound, + which surfaces as a bare 404 response. `if (!rec)` after them is dead code. + Wrap in try/catch (catch = "not found") or use + `findRecordsByFilter(collection, filter, sort, LIMIT, OFFSET)` and check + `.length`. Corollary when debugging: **a 404 from a route you declared + means your HANDLER threw, not that the route is missing** — check + logs/pocketbase.log for the `[handler-error]` line with the real cause. +- Keep base URLs as string **literals** in the module (the tooling records + the app's external hosts in the manifest from them). +- Unreachable source / non-200 → `console.error` the cause, return a clean + error; the UI shows its offline/empty state. **NEVER substitute generated + or random data for real data** — a mock that renders is a lie that passes + review. If the source cannot be reached, the app says so and so do you. +- CraftBot's own connected services (Gmail, Slack, Notion, …) are NOT called + this way — see `references/INTEGRATIONS.md` (the `_craftbot_bridge.js` + helper). Third-party public APIs: direct `$http.send` as above. + **UI** — build in `frontend/src/app/`, importing ONLY from `../kit/index.ts`: - Read data with `useCollection('name', { sort: '-created' })` — it is @@ -130,18 +247,41 @@ Update `LIVING_UI.md` after each feature (entities table, ops list, checklist). app and health-checks it. On errors: read ALL of them, fix ALL of them, call it again. Success = app RUNNING but NOT yet verified. Never start servers manually. -2. `living_ui_walk_verify(project_id="")` — an independent +2. **REALITY CHECK — look at what actually exists, not at what you wrote.** + Success messages lie by omission; stored state does not. While the app + runs: + - `GET /api/_a2app/describe` → does every collection show the FIELDS you + migrated? A collection showing only `id` means your migration silently + did nothing (wrong key, wrong API — the cause doesn't matter, the + emptiness is the proof). + - Trigger one real data flow (call your refresh/main op), then read a + record back (`GET /api/collections//records?perPage=1`) and LOOK + at the values. Missing fields, empty strings, all-zero numbers = the + write silently failed, whatever the op's status code said. + - Any path you CANNOT trigger for real (scheduled email, posts to the + user's accounts): **dry-run it** — `callAction(name, sameParams, + { confirmIrreversible: true, dryRun: true })` validates grant, params, + placeholders and confirmation without executing. A path that was never + run NOR dry-run is not done, whatever the code looks like. + Reason about ANY mismatch between what you intended and what is stored — + fix it before verifying. This catches the failure classes no error + message reports. +3. `living_ui_walk_verify(project_id="")` — an independent sub-agent walks the running app in a real (headless) browser against `reference/requirements.md`. **Success announces the app to the user and completes the build.** Failing features come back as a report: fix them, - then repeat step 1 and step 2. + then repeat step 1 and step 3. **HONESTY RULE:** the app is ready ONLY when `living_ui_walk_verify` returns `status: success`. If you cannot make it pass, tell the user the build -**failed** and exactly what's blocking. Never claim a broken app is ready. +**failed** and exactly what's blocking. Never claim a broken app is ready, +and never present generated data as live data — "live" in your message means +the app fetched it from the real source. ## Debugging +- Full platform reference (bridge, jobs, kit API): + `living-ui-v2/docs/agent-guide.md` (repo-level, read on demand). - Frontend runtime errors: `{project_path}/logs/frontend_console.log` (console.error/warn + uncaught errors are auto-relayed). - Server: `{project_path}/logs/pocketbase.log`. @@ -155,6 +295,9 @@ Update `LIVING_UI.md` after each feature (entities table, ops list, checklist). - Custom fetch layers, polling, or page reloads — use the kit's realtime hooks - Hardcoded colors or raw `