diff --git a/.github/workflows/living-ui-v2.yml b/.github/workflows/living-ui-v2.yml new file mode 100644 index 00000000..ee2e8e16 --- /dev/null +++ b/.github/workflows/living-ui-v2.yml @@ -0,0 +1,61 @@ +name: living-ui-v2 + +# Self-test for the Living UI TEMPLATE code (kit/blueprint/tools) in this repo. +# Scaffolds a throwaway project and runs the local validation gate on it. +# User-made Living UIs never touch this workflow — they validate locally. + +on: + push: + paths: + - 'living-ui-v2/**' + - '.github/workflows/living-ui-v2.yml' + pull_request: + paths: + - 'living-ui-v2/**' + - '.github/workflows/living-ui-v2.yml' + +defaults: + run: + working-directory: living-ui-v2 + +jobs: + gate: + name: gate (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Cache PocketBase binary + uses: actions/cache@v4 + with: + path: | + ~/Library/Caches/craftos-living-ui/pb + ~/.cache/craftos-living-ui/pb + ~\AppData\Local\craftos-living-ui\pb + key: pb-${{ runner.os }}-${{ hashFiles('living-ui-v2/spec/pocketbase.version') }} + + - name: Install workspace + run: npm install + + - name: Typecheck (kit + tools) + run: npm run typecheck + + - name: Lint + run: npx eslint . + + - name: Scaffold demo project + run: node tools/src/cli.ts create "CI Demo" --description "CI validation project" --port 8090 + + - name: Link demo workspace + run: npm install + + - name: Validation gate + run: node tools/src/cli.ts validate examples/ci-demo diff --git a/.gitignore b/.gitignore index 8cb33c08..429e4699 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,5 @@ docs/LIVING_UI_DEVELOPER_GUIDE.md agent_file_system/ACTIONS.md agent_bundle/ **/.craftbot/ -app/data/.file_index/ \ No newline at end of file +app/data/.file_index/ +.playwright-mcp \ No newline at end of file diff --git a/.ruff.toml b/.ruff.toml index a3df4546..63c92f5f 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -12,6 +12,5 @@ extend-exclude = [ "agents/dog_agent/data/action/dog_behaviour.py" = ["E402"] "app/action/action_framework/run_actions_tests.py" = ["E402"] "app/config.py" = ["E402"] -"app/llm_interface.py" = ["E402"] "app/main.py" = ["E402"] "craftos_integrations/__init__.py" = ["E402"] diff --git a/agent_core/core/embedding_interface.py b/agent_core/core/embedding_interface.py index 6b543949..2806254d 100644 --- a/agent_core/core/embedding_interface.py +++ b/agent_core/core/embedding_interface.py @@ -22,7 +22,7 @@ from agent_core.core.models.types import InterfaceType from agent_core.utils.logger import logger -from agent_core.core.llm.google_gemini_client import GeminiAPIError, GeminiClient +from agent_core.core.llm.google_gemini_client import GeminiClient class EmbeddingInterface: @@ -91,26 +91,42 @@ def get_embedding(self, text: str) -> Optional[List[float]]: raise RuntimeError(f"Unknown provider {self.provider!r}") # ───────────────────── Provider-specific helpers ─────────────────── + def _log_classified(self, tag: str, e: Exception) -> None: + """Log *e* through the shared classifier instead of raw str(e).""" + from agent_core.core.impl.llm.errors import classify_llm_error + + info = classify_llm_error(e, provider=self.provider, model=self.model) + logger.error(f"[EMBEDDING] {tag}: {info.message}") + + @staticmethod + def _not_initialised(provider: str, client_name: str) -> "ClassifiedError": + from agent_core.core.errors import ClassifiedError + from agent_core.core.impl.llm.errors import classify_llm_error + + return ClassifiedError( + classify_llm_error( + RuntimeError(f"{client_name} client was not initialised."), + provider=provider, + ) + ) + def _get_openai_embedding(self, text: str) -> Optional[List[float]]: try: response = self.client.embeddings.create(model=self.model, input=text) # OpenAI returns: response.data[0].embedding return response.data[0].embedding # type: ignore[attr-defined] except Exception as e: - logger.exception(f"Error calling OpenAI Embedding API: {e}") + self._log_classified("OpenAI", e) return None def _get_gemini_embedding(self, text: str) -> Optional[List[float]]: if not self._gemini_client: - raise RuntimeError("Gemini client was not initialised.") + raise self._not_initialised("gemini", "Gemini") try: return self._gemini_client.embed_text(self.model, text=text) - except GeminiAPIError as e: - logger.exception(f"Gemini rejected the embedding request: {e}") - return None except Exception as e: - logger.exception(f"Error calling Gemini Embedding API: {e}") + self._log_classified("Gemini", e) return None def _get_byteplus_embedding(self, text: str) -> Optional[List[float]]: @@ -137,7 +153,7 @@ def _get_byteplus_embedding(self, text: str) -> Optional[List[float]]: return None return data.get("embedding") except Exception as e: - logger.exception(f"Error calling BytePlus Embedding API: {e}") + self._log_classified("BytePlus", e) return None def _get_bedrock_embedding(self, text: str) -> Optional[List[float]]: @@ -148,7 +164,7 @@ def _get_bedrock_embedding(self, text: str) -> Optional[List[float]]: (Converse doesn't expose embeddings). """ if not self._bedrock_client: - raise RuntimeError("Bedrock client was not initialised.") + raise self._not_initialised("bedrock", "Bedrock") try: import json as _json @@ -165,7 +181,7 @@ def _get_bedrock_embedding(self, text: str) -> Optional[List[float]]: result = _json.loads(raw) return result.get("embedding") except Exception as e: - logger.exception(f"Error calling Bedrock Embedding API: {e}") + self._log_classified("Bedrock", e) return None def _get_ollama_embedding(self, text: str) -> Optional[List[float]]: @@ -181,5 +197,5 @@ def _get_ollama_embedding(self, text: str) -> Optional[List[float]]: # Ollama returns {"embedding": [floats]} return result.get("embedding", None) except Exception as e: - logger.exception(f"Error calling Ollama Embedding API: {e}") + self._log_classified("Ollama", e) return None diff --git a/agent_core/core/errors.py b/agent_core/core/errors.py new file mode 100644 index 00000000..3daf1a5c --- /dev/null +++ b/agent_core/core/errors.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +""" +Shared error-catalogue primitives. + +`agent_core` never imports from `app` (the dependency runs the other way), so +the category/severity/action vocabulary shared between the LLM classifier +(`agent_core/core/impl/llm/errors.py`) and app-layer call sites +(`app/errors/codebook.py`) lives here. + +`LLMErrorInfo` (in the LLM package) is intentionally NOT made a subclass of +`ErrorInfo` — its `provider` field is positional/non-default and reordering it +behind new defaulted base fields would break its existing consumers. Both +satisfy `ErrorInfoLike` structurally instead. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field, asdict +from enum import Enum +from typing import Any, Dict, List, Optional, Protocol, runtime_checkable + + +class Severity(str, Enum): + INFO = "info" + WARNING = "warning" + ERROR = "error" + CRITICAL = "critical" # aborts a run + + +class ErrorCategory(str, Enum): + AUTH = "auth" # 401/403 — bad/missing key, key revoked + CREDIT = "credit" # 402, "insufficient_quota", "credit_balance_too_low" + RATE_LIMIT = "rate_limit" # 429 — transient + QUOTA = "quota" # 429 + monthly/account scope (separable from per-min) + MODEL = "model" # 404, "model_not_found" + BAD_REQUEST = "bad_request" # 400 — request malformed (context overflow, etc.) + BLOCKED = "blocked" # safety filter (Gemini/Anthropic) + SERVER = "server" # 5xx, "overloaded_error" + CONNECTION = "connection" # network / timeout / DNS + UNKNOWN = "unknown" + # App-layer categories, not produced by the LLM classifier: + VALIDATION = "validation" # malformed input outside an LLM call + NOT_FOUND = "not_found" + CONFIG = "config" # local misconfiguration (e.g. no key set, before any network call) + PERMISSION = "permission" # local/file/OS permission issues + INTERNAL = "internal" # unexpected/bug-shaped exception + + +# Categories where retrying the same request essentially never succeeds — +# these should fail fast instead of consuming a retry budget. RATE_LIMIT, +# SERVER, CONNECTION, and UNKNOWN are left out deliberately: they're the +# genuinely transient cases retries exist for. +FAIL_FAST_CATEGORIES = frozenset( + { + ErrorCategory.AUTH, + ErrorCategory.CREDIT, + ErrorCategory.QUOTA, + ErrorCategory.MODEL, + ErrorCategory.BLOCKED, + ErrorCategory.BAD_REQUEST, + ErrorCategory.CONFIG, + } +) + + +def is_transient(category: ErrorCategory) -> bool: + """Whether retrying the same request has a real chance of succeeding.""" + return category not in FAIL_FAST_CATEGORIES + + +@dataclass +class ErrorAction: + """A clickable affordance attached to an error. + + `url` opens in a new tab; `action` is a frontend-resolved verb such as + "open_settings_model" — handled by the chat component, not by URL nav. + Exactly one of url/action should be set. + """ + + label: str + url: Optional[str] = None + action: Optional[str] = None + + +@dataclass +class ErrorInfo: + """Generic app-wide structured error, for call sites outside the LLM + provider classifier (which uses the richer `LLMErrorInfo`).""" + + category: ErrorCategory + code: str + title: str + message: str + severity: Severity = Severity.ERROR + actions: List[ErrorAction] = field(default_factory=list) + raw_message: Optional[str] = None + context: Dict[str, Any] = field(default_factory=dict) + + @property + def is_transient(self) -> bool: + return is_transient(self.category) + + def to_dict(self) -> Dict[str, Any]: + d = asdict(self) + d["category"] = self.category.value + d["severity"] = self.severity.value + return d + + +@runtime_checkable +class ErrorInfoLike(Protocol): + """Structural type both `ErrorInfo` and `LLMErrorInfo` satisfy.""" + + category: ErrorCategory + title: str + message: str + actions: List[ErrorAction] + + +class ClassifiedError(Exception): + """Wraps a classified `ErrorInfoLike`. + + Presentation code (see `app/agent_base.py:_handle_react_error`) uses the + presence of this type anywhere in an exception's `__cause__`/`__context__` + chain — or an `LLMConsecutiveFailureError` with a populated + `last_error_info` — to tell a recognized, user-actionable failure ("minor" + tier: bad key, no credits, misconfigured provider) apart from a genuinely + unexpected crash ("critical" tier: unclassified bugs, broken agent loop). + Raise this instead of a bare `RuntimeError` at any call site that already + knows what went wrong. + """ + + def __init__(self, info: ErrorInfoLike): + self.info = info + super().__init__(info.message) + + +# ─── Redaction ────────────────────────────────────────────────────────── +# Ported from the now-removed app/security/error_handler.py — applied to raw +# upstream/exception text before it's echoed to the UI (e.g. UNKNOWN/BAD_REQUEST +# fallback messages), not to the curated, hand-written catalogue strings. + +_REDACT_PATTERNS = [ + re.compile(r"/[^/\s]+\.py"), # file paths + re.compile(r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+"), # emails + re.compile(r"://[^/\s]+"), # URLs/hostnames + re.compile(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"), # IPv4 addresses +] + + +def redact(raw: str, max_length: int = 500) -> str: + """Strip file paths/emails/hostnames/IPs from raw exception text.""" + text = raw + for pattern in _REDACT_PATTERNS: + text = pattern.sub("[REDACTED]", text) + if len(text) > max_length: + text = text[:max_length] + "..." + return text diff --git a/agent_core/core/impl/action/router.py b/agent_core/core/impl/action/router.py index e33b6698..2883bc26 100644 --- a/agent_core/core/impl/action/router.py +++ b/agent_core/core/impl/action/router.py @@ -19,6 +19,7 @@ from agent_core.core.protocols.llm import LLMInterfaceProtocol from agent_core.core.impl.llm import LLMCallType from agent_core.core.impl.llm.errors import LLMConsecutiveFailureError +from agent_core.core.errors import ClassifiedError, ErrorCategory, ErrorInfo from agent_core.core.prompts import SELECT_ACTION_PROMPT from agent_core.utils.logger import logger @@ -377,13 +378,24 @@ async def _prompt_for_decision( raise except RuntimeError as e: # LLM provider error (empty response, API error, auth failure, etc.) + # — a recognized, user-actionable failure, not a code bug. The + # attempt-number bookkeeping stays in the log only; the + # user-facing message (ClassifiedError.info.message) stays + # short and skips it. error_msg = str(e) logger.error( f"[ACTION ROUTER] LLM provider error on attempt {attempt + 1}: {error_msg}" ) - last_error = RuntimeError( - f"Unable to generate action decision on attempt {attempt + 1}: {error_msg}. " - f"Check LLM configuration, API credentials, and service availability." + last_error = ClassifiedError( + ErrorInfo( + category=ErrorCategory.UNKNOWN, + code="ACTION_DECISION_FAILED", + title="Action decision failed", + message=( + f"{error_msg.rstrip('.')}. Check LLM configuration, " + f"API credentials, and service availability." + ), + ) ) # After 3 attempts, give up if attempt >= max_retries - 1: diff --git a/agent_core/core/impl/image_gen/interface.py b/agent_core/core/impl/image_gen/interface.py index 8afec5a8..7eecb435 100644 --- a/agent_core/core/impl/image_gen/interface.py +++ b/agent_core/core/impl/image_gen/interface.py @@ -57,16 +57,17 @@ } -def _classify_error(provider: str, exc: Exception, model: str) -> str: - """Render *exc* as a human-readable error string via the shared catalog. +def _classified_error(provider: str, exc: Exception, model: str) -> "ClassifiedError": + """Classify *exc* via the shared catalog and wrap it as a ClassifiedError. Import deferred to call time — agent_core must stay importable without the host `app` package (all app.* imports in this package are function-local by convention). """ - from app.i18n import classify_provider_error + from agent_core.core.errors import ClassifiedError + from app.i18n import classify_provider_error_info - return classify_provider_error(exc, provider=provider, model=model) + return ClassifiedError(classify_provider_error_info(exc, provider=provider, model=model)) # ── File-path helpers ───────────────────────────────────────────────────────── @@ -370,7 +371,7 @@ def _openai_generate( quality=quality, ) except Exception as exc: - raise RuntimeError(_classify_error("openai", exc, self.model)) from exc + raise _classified_error("openai", exc, self.model) from exc usage = getattr(response, "usage", None) if usage is not None: @@ -485,7 +486,7 @@ def _gemini_generate( safety_settings=safety_settings, ) except Exception as exc: - raise RuntimeError(_classify_error("gemini", exc, self.model)) from exc + raise _classified_error("gemini", exc, self.model) from exc usage_md = result.get("usage_metadata") or {} if usage_md: @@ -502,9 +503,19 @@ def _gemini_generate( if not images_data: block_reason = result.get("block_reason") if block_reason: - raise RuntimeError( - f"Gemini blocked the request (safety filter: {block_reason}). " - "Try modifying your prompt or adjusting safety_filter_level." + from agent_core.core.errors import ClassifiedError, ErrorCategory, ErrorInfo, Severity + + raise ClassifiedError( + ErrorInfo( + category=ErrorCategory.BLOCKED, + code="IMAGE_GEN_BLOCKED", + title="Blocked by safety filter", + message=( + f"Gemini blocked the request (safety filter: {block_reason}). " + "Try modifying your prompt or adjusting safety_filter_level." + ), + severity=Severity.ERROR, + ) ) raise RuntimeError( "Gemini returned no image data — try rephrasing your prompt or " diff --git a/agent_core/core/impl/llm/errors.py b/agent_core/core/impl/llm/errors.py index 87a0ef1b..2ea4ec29 100644 --- a/agent_core/core/impl/llm/errors.py +++ b/agent_core/core/impl/llm/errors.py @@ -21,9 +21,28 @@ from __future__ import annotations from dataclasses import dataclass, field, asdict -from enum import Enum from typing import Any, Dict, List, Optional +from agent_core.core.errors import ( + ErrorAction, + ErrorCategory, + Severity, + is_transient, + redact, +) + +__all__ = [ + "ErrorCategory", + "ErrorAction", + "Severity", + "is_transient", + "LLMErrorInfo", + "LLMConsecutiveFailureError", + "classify_llm_error", + "classify_llm_error_message", + "provider_display_name", +] + # Optional provider SDK imports — kept defensive so missing extras don't # break the classifier path. @@ -49,33 +68,9 @@ # ─── Public taxonomy ────────────────────────────────────────────────── - - -class ErrorCategory(str, Enum): - AUTH = "auth" # 401/403 — bad/missing key, key revoked - CREDIT = "credit" # 402, "insufficient_quota", "credit_balance_too_low" - RATE_LIMIT = "rate_limit" # 429 — transient - QUOTA = "quota" # 429 + monthly/account scope (separable from per-min) - MODEL = "model" # 404, "model_not_found" - BAD_REQUEST = "bad_request" # 400 — request malformed (context overflow, etc.) - BLOCKED = "blocked" # safety filter (Gemini/Anthropic) - SERVER = "server" # 5xx, "overloaded_error" - CONNECTION = "connection" # network / timeout / DNS - UNKNOWN = "unknown" - - -@dataclass -class ErrorAction: - """A clickable affordance attached to an error. - - `url` opens in a new tab; `action` is a frontend-resolved verb such as - "open_settings_model" — handled by the chat component, not by URL nav. - Exactly one of url/action should be set. - """ - - label: str - url: Optional[str] = None - action: Optional[str] = None +# ErrorCategory/ErrorAction/Severity/is_transient live in agent_core.core.errors +# (imported above) so app-layer, non-LLM call sites can share the same +# vocabulary without agent_core depending on app. @dataclass @@ -91,10 +86,19 @@ class LLMErrorInfo: actions: List[ErrorAction] = field(default_factory=list) raw_message: Optional[str] = None # truncated raw upstream text for "Show details" request_id: Optional[str] = None # for support tickets + # Appended fields (kept trailing/defaulted so existing positional/keyword + # construction call sites don't break): + code: Optional[str] = None # stable id, e.g. "LLM_AUTH" — auto-derived, see classify_llm_error() + severity: Severity = Severity.ERROR + + @property + def is_transient(self) -> bool: + return is_transient(self.category) def to_dict(self) -> Dict[str, Any]: d = asdict(self) d["category"] = self.category.value + d["severity"] = self.severity.value return d @@ -157,13 +161,24 @@ def provider_display_name(provider: Optional[str]) -> str: MSG_CONNECTION = "Could not reach the provider. Check your network connection." MSG_GENERIC = "Something went wrong calling the AI service." MSG_CONSECUTIVE_FAILURE = "Aborted after consecutive failures." +MSG_FAILED_IMMEDIATELY = "This error can't be fixed by retrying." + + +# Deterministic, auto-derived error code per category — one per ErrorCategory +# value, zero manual maintenance. Not meant to be as fine-grained as a +# per-provider codebook; just enough for log correlation and future +# frontend/i18n lookups. +def _code_for_category(category: ErrorCategory) -> str: + return f"LLM_{category.value.upper()}" # ─── Consecutive-failure exception (preserves last classified info) ─── class LLMConsecutiveFailureError(Exception): - """Raised when LLM calls fail too many times consecutively. + """Raised when LLM calls fail too many times consecutively — or, for + non-transient categories (see FAIL_FAST_CATEGORIES), on the very first + failure. Carries the last classified `LLMErrorInfo` (when known) so the UI can surface the *cause* of the failures, not just the count. @@ -174,11 +189,18 @@ def __init__( failure_count: int, last_error: Optional[Exception] = None, last_error_info: Optional[LLMErrorInfo] = None, + is_immediate: bool = False, ): self.failure_count = failure_count self.last_error = last_error self.last_error_info = last_error_info - message = MSG_CONSECUTIVE_FAILURE.format(count=failure_count) + # Any raise site with failure_count <= 1 is, by definition, a single + # failure — never say "consecutive failures" for one failure, even if + # a call site forgot to pass is_immediate explicitly (e.g. a hard + # per-call timeout raised directly with count=1, not routed through + # LLMInterface._register_failure's fail-fast categorization). + self.is_immediate = is_immediate or failure_count <= 1 + message = MSG_FAILED_IMMEDIATELY if self.is_immediate else MSG_CONSECUTIVE_FAILURE if last_error: message += f" Last error: {last_error}" super().__init__(message) @@ -215,7 +237,9 @@ def classify_llm_error( if info is None: # Don't fabricate a generic message — the raw exception text is # almost always more informative than any stub we could write. - raw = _truncate(str(error)) or "AI service error" + # Redacted since, unlike the curated per-category messages below, + # this echoes the exception's own text verbatim to the UI. + raw = redact(_truncate(str(error)) or "AI service error") info = LLMErrorInfo( category=ErrorCategory.UNKNOWN, title="AI service error", @@ -226,6 +250,8 @@ def classify_llm_error( if model and info.model is None: info.model = model + if info.code is None: + info.code = _code_for_category(info.category) return info @@ -266,8 +292,16 @@ def _try_classify( if requests is not None and isinstance(error, requests.exceptions.RequestException): return _classify_requests(error, provider) - # Gemini's custom error type (raised by our REST client) + # Local precondition failures — raised before any network call (no API + # key configured, so the provider client was never constructed). Must be + # checked before the Gemini substring sniff below: "Gemini client was + # not initialised." would otherwise match "Gemini" and get misclassified + # as a Gemini API-shaped error. msg = str(error) + if isinstance(error, RuntimeError) and "was not initialised" in msg: + return _classify_local_config(error, provider or "unknown") + + # Gemini's custom error type (raised by our REST client) if "Gemini" in msg or "promptFeedback" in msg or "blocked" in msg.lower(): return _classify_gemini_runtime(error, provider or "gemini") @@ -403,6 +437,21 @@ def _classify_openai_compat(exc: Exception, provider: str) -> LLMErrorInfo: # error text in their native language when routed via OpenRouter. category = _refine_category_from_localised(raw_message, category) + # OpenAI's SDK raises the same RateLimitError (429) for both actual + # rate-limiting AND quota/credit exhaustion — normally disambiguated by + # `code == "insufficient_quota"` above, but some accounts/providers + # return 429 with a plain-language credit message and no matching + # structured code. "Rate limited... try again shortly" is actively wrong + # advice when the account is just out of funds, so fall back to sniffing + # the raw text. + if category == ErrorCategory.RATE_LIMIT: + raw_lower = raw_message.lower() + if any( + k in raw_lower + for k in ("no credits", "out of credits", "insufficient_quota", "insufficient quota", "credit balance", "credits remaining") + ): + category = ErrorCategory.CREDIT + # ── Retry-After ──────────────────────────────────────────────── retry_after = _retry_after_seconds(exc) @@ -673,6 +722,24 @@ def _classify_httpx_connection(exc: Exception, provider: Optional[str]) -> LLMEr ) +def _classify_local_config(exc: Exception, provider: str) -> LLMErrorInfo: + """Local precondition failures raised before any network call — e.g. no + API key configured, so the provider client was never constructed. These + are permanent local misconfigurations (CONFIG, fail-fast — see + FAIL_FAST_CATEGORIES), never something a provider actually returned, so + the message is built directly from the raw text instead of going through + the SDK/HTTP-response composition path below.""" + raw = str(exc).strip() + message = f"{raw.rstrip('.')}. Check LLM configuration, API credentials, and service availability." + return LLMErrorInfo( + category=ErrorCategory.CONFIG, + title="Provider not configured", + message=message, + provider=provider, + raw_message=raw, + ) + + def _classify_gemini_runtime(exc: Exception, provider: str) -> LLMErrorInfo: """Gemini's GeminiAPIError — raised when the response shape signals an issue that isn't an HTTP failure (e.g. promptFeedback.blockReason).""" @@ -794,7 +861,7 @@ def _retry_after_seconds(exc: Exception) -> Optional[int]: ErrorCategory.CREDIT: "Out of credits", ErrorCategory.RATE_LIMIT: "Rate limited", ErrorCategory.QUOTA: "Quota exceeded", - ErrorCategory.MODEL: "Incorrect model id", + ErrorCategory.MODEL: "Incorrect model ID", ErrorCategory.BAD_REQUEST: "Bad request", ErrorCategory.BLOCKED: "Blocked by safety filter", ErrorCategory.SERVER: "Provider service unavailable", @@ -920,7 +987,7 @@ def _append_hint( if category == ErrorCategory.MODEL: if "settings" in raw_lower: return f"{base}." - return f"{base}. Use a correct model in Settings." + return f"{base}. Set a valid LLM model in Settings." if category == ErrorCategory.BLOCKED: return f"{base}. Edit your prompt and retry." diff --git a/agent_core/core/impl/llm/interface.py b/agent_core/core/impl/llm/interface.py index 945cb82a..d369d9ba 100644 --- a/agent_core/core/impl/llm/interface.py +++ b/agent_core/core/impl/llm/interface.py @@ -30,9 +30,12 @@ get_cache_config, get_cache_metrics, ) +from agent_core.core.errors import ErrorCategory, FAIL_FAST_CATEGORIES from agent_core.core.impl.llm.errors import ( LLMConsecutiveFailureError, + LLMErrorInfo, classify_llm_error, + provider_display_name, ) from agent_core.core.hooks import ( GetTokenCountHook, @@ -108,6 +111,46 @@ def _model_supports_prefill(model: str) -> bool: return True +def _generic_empty_response_detail(provider: str, model: str) -> str: + """Fallback detail text for an empty LLM response that carries neither a + classified `error_info_obj` nor a raw `error` string. Shared by + `_generate_response_sync` and `_finalize_session_response` — previously + each had its own near-identical text that had drifted apart in wording. + """ + return ( + f"LLM returned empty response. " + f"Provider: {provider}, Model: {model}. " + f"This may indicate: API authentication failure, invalid API key, rate limiting, " + f"connection timeout, or LLM service unavailability. " + f"Check your credentials and API status." + ) + + +def _byteplus_blocked_reason(result: Dict[str, Any]) -> Optional[str]: + """Best-effort detection of content-filter/moderation blocking in a + BytePlus Responses API result that came back with empty content but no + HTTP-level error (status 200, `choices`/`output` just empty). + + Mirrors OpenAI's Responses API `status` / `incomplete_details.reason` + shape, which BytePlus's docs describe this endpoint as following — not + independently verified against a live blocked response, so this only + fires on an unambiguous signal and otherwise returns None, leaving the + existing generic empty-response handling untouched. + """ + status = result.get("status") + if status == "incomplete": + reason = (result.get("incomplete_details") or {}).get("reason") + if reason: + return str(reason) + error = result.get("error") + if isinstance(error, dict): + code = str(error.get("code") or "").lower() + message = str(error.get("message") or "") + if any(k in code for k in ("content_filter", "moderation", "safety")): + return message or code + return None + + class LLMInterface: """LLM interface with multi-provider support and hook-based customization. @@ -493,6 +536,46 @@ def _begin_call( ) # ─────────────────────────── Public helpers ──────────────────────────── + + def _register_failure( + self, + *, + error_info: Optional[LLMErrorInfo], + raw_error: Optional[Exception] = None, + ) -> None: + """Single chokepoint for consecutive-failure bookkeeping. + + Non-transient categories (bad key, out of credits, invalid model, + blocked content, malformed request — see FAIL_FAST_CATEGORIES) abort + immediately: retrying the same request with the same error can't + succeed. Transient categories (rate-limit, server, connection, + unclassified) keep the existing 5-attempt budget. + + Always raises `LLMConsecutiveFailureError` when the run should abort; + otherwise returns normally so the caller can continue its own retry + path. + """ + category = error_info.category if error_info else ErrorCategory.UNKNOWN + if category in FAIL_FAST_CATEGORIES: + logger.critical( + f"[LLM ABORT] Non-transient category={category.value} — failing fast " + f"instead of retrying." + ) + raise LLMConsecutiveFailureError( + 1, last_error=raw_error, last_error_info=error_info, is_immediate=True + ) + + self._consecutive_failures += 1 + logger.warning( + f"[LLM CONSECUTIVE FAILURE] Count: " + f"{self._consecutive_failures}/{self._max_consecutive_failures} " + f"(category={category.value})" + ) + if self._consecutive_failures >= self._max_consecutive_failures: + raise LLMConsecutiveFailureError( + self._consecutive_failures, last_error=raw_error, last_error_info=error_info + ) + def _generate_response_sync( self, system_prompt: Optional[str] = None, @@ -554,30 +637,25 @@ def _generate_response_sync( elif error_msg: error_detail = f"LLM provider returned error: {error_msg}" else: - error_detail = ( - f"LLM returned empty response. " - f"Provider: {self.provider}, Model: {self.model}. " - f"This may indicate: API authentication failure, invalid API key, rate limiting, " - f"connection timeout, or LLM service unavailability. " - f"Check your credentials and API status." + error_detail = _generic_empty_response_detail( + self.provider, self.model ) logger.error(f"[LLM ERROR] {error_detail}") - # Track consecutive failure - self._consecutive_failures += 1 - logger.warning( - f"[LLM CONSECUTIVE FAILURE] Count: {self._consecutive_failures}/{self._max_consecutive_failures}" + # Registers/raises based on category (fail-fast vs retry + # budget) — see _register_failure. Attaches the classified + # info so the agent_base error handler can show the *cause* + # of the failure(s), not just a retry count. raw_error is + # passed even when error_info is None (e.g. BytePlus's + # cache path returning empty content with no exception) so + # a fatal LLMConsecutiveFailureError still carries *some* + # detail instead of falling back to a bare, disconnected + # "Aborted after consecutive failures." — see + # app/agent_base.py:_classify_react_error. + self._register_failure( + error_info=error_info, raw_error=RuntimeError(error_detail) ) - if self._consecutive_failures >= self._max_consecutive_failures: - # Attach the underlying classified info so the agent_base - # error handler can show the *cause* of the 5 failures - # (e.g. "rate-limited on Google AI Studio") instead of a - # meta-message about retry counts. - raise LLMConsecutiveFailureError( - self._consecutive_failures, - last_error_info=error_info, - ) # Use _EmptyResponse so the outer except-Exception block does NOT - # re-increment the counter for this same call (double-counting bug). + # re-register this same call (double-counting bug). raise _EmptyResponse(error_detail) # Success - reset consecutive failure counter @@ -600,25 +678,14 @@ def _generate_response_sync( # Failure already counted above; convert back to RuntimeError for callers. raise RuntimeError(str(e)) from None except Exception as e: - # Track consecutive failure for any other exception - self._consecutive_failures += 1 - logger.warning( - f"[LLM CONSECUTIVE FAILURE] Count: {self._consecutive_failures}/{self._max_consecutive_failures} | Error: {e}" - ) - if self._consecutive_failures >= self._max_consecutive_failures: - # Classify on the way out so the fatal-failure handler can - # surface the cause, not just the count. - try: - info = classify_llm_error( - e, provider=self.provider, model=self.model - ) - except Exception: - info = None - raise LLMConsecutiveFailureError( - self._consecutive_failures, - last_error=e, - last_error_info=info, - ) from e + # Classify on every failure now (not just once the retry budget + # is exhausted) so non-transient categories can fail fast. + try: + info = classify_llm_error(e, provider=self.provider, model=self.model) + except Exception: + info = None + logger.error(f"[LLM ERROR] {e}") + self._register_failure(error_info=info, raw_error=e) raise @profile("llm_generate_response", OperationCategory.LLM) @@ -894,11 +961,10 @@ def _finalize_session_response( """Shared tail for the session-cache provider branches. Mirrors the failure handling in `_generate_response_sync`: an empty - response is treated as a failure, the consecutive-failure counter is - tracked, and the classified cause is surfaced (raising - `LLMConsecutiveFailureError` once the threshold is hit so the agent - aborts instead of retrying forever). On success the counter resets and - the cleaned content is returned. + response is treated as a failure and routed through + `_register_failure` (fail-fast for non-transient categories, retry + budget otherwise). On success the counter resets and the cleaned + content is returned. """ content = (response.get("content") or "").strip() if not content: @@ -909,21 +975,15 @@ def _finalize_session_response( elif error_msg: error_detail = f"LLM provider returned error: {error_msg}" else: - error_detail = ( - f"LLM returned empty response. " - f"Provider: {self.provider}, Model: {self.model}. " - f"This may indicate an API error or service unavailability." + error_detail = _generic_empty_response_detail( + self.provider, self.model ) logger.error(f"[LLM ERROR] {error_detail}") - self._consecutive_failures += 1 - logger.warning( - f"[LLM CONSECUTIVE FAILURE] Count: " - f"{self._consecutive_failures}/{self._max_consecutive_failures}" + # See _generate_response_sync's equivalent call for why + # raw_error is always passed, even when error_info is None. + self._register_failure( + error_info=error_info, raw_error=RuntimeError(error_detail) ) - if self._consecutive_failures >= self._max_consecutive_failures: - raise LLMConsecutiveFailureError( - self._consecutive_failures, last_error_info=error_info - ) raise RuntimeError(error_detail) # Success - reset consecutive failure counter @@ -1758,6 +1818,18 @@ def _generate_openai( cache_type = f"automatic_{call_type}" if call_type else "automatic" try: + if not self.client: + # No API key configured (or client construction failed) — + # shared by openai/minimax/deepseek/moonshot/grok/openrouter/ + # glm/fugu, all of which route through this method. Without + # this guard, `self.client.chat...` below raises a bare + # "'NoneType' object has no attribute 'chat'" — matches the + # explicit "client was not initialised" pattern already used + # for Anthropic/Gemini/Bedrock, so it classifies as CONFIG + # and fails fast instead of a confusing crash. + raise RuntimeError( + f"{provider_display_name(self.provider)} client was not initialised." + ) if messages_override is not None: messages: List[Dict[str, Any]] = messages_override else: @@ -1890,7 +1962,7 @@ def _generate_openai( status = "success" except Exception as exc: exc_obj = exc - logger.error(f"Error calling OpenAI API: {exc}") + logger.debug(f"Error calling OpenAI API: {exc}") total_tokens = token_count_input + token_count_output @@ -1939,7 +2011,6 @@ def _generate_openai( except Exception: pass result["content"] = "" - logger.error(f"[OPENAI_ERROR] {error_str}") else: result["content"] = content or "" @@ -1979,7 +2050,7 @@ def _generate_ollama( status = "success" except Exception as exc: exc_obj = exc - logger.error(f"Error calling Ollama API: {exc}") + logger.debug(f"Error calling Ollama API: {exc}") self._call_log_to_db( system_prompt, @@ -2012,7 +2083,6 @@ def _generate_ollama( except Exception: pass result["content"] = "" - logger.error(f"[OLLAMA_ERROR] {error_str}") else: result["content"] = content or "" return result @@ -2150,7 +2220,7 @@ def _generate_gemini( logger.error(f"Gemini API rejected the prompt: {exc}") except Exception as exc: # pragma: no cover exc_obj = exc - logger.error(f"Error calling Gemini API: {exc}") + logger.debug(f"Error calling Gemini API: {exc}") self._call_log_to_db( system_prompt, @@ -2189,7 +2259,6 @@ def _generate_gemini( except Exception: pass result["content"] = "" - logger.error(f"[GEMINI_ERROR] {error_str}") else: result["content"] = content or "" return result @@ -2247,6 +2316,14 @@ def _generate_byteplus_with_prefix_cache( # Parse response (Responses API format) content = self._parse_responses_api_content(result) + if not content: + blocked_reason = _byteplus_blocked_reason(result) + if blocked_reason: + raise RuntimeError( + f"Response was blocked by the provider's content filter " + f"({blocked_reason})." + ) + # Token usage from Responses API usage = result.get("usage") or {} token_count_input = int(usage.get("input_tokens", 0)) @@ -2306,10 +2383,10 @@ def _generate_byteplus_with_prefix_cache( return self._generate_byteplus_standard(system_prompt, user_prompt) else: exc_obj = e - logger.error(f"Error calling BytePlus Responses API: {e}") + logger.debug(f"Error calling BytePlus Responses API: {e}") except Exception as exc: exc_obj = exc - logger.error(f"Error calling BytePlus Responses API: {exc}") + logger.debug(f"Error calling BytePlus Responses API: {exc}") self._call_log_to_db( system_prompt, @@ -2331,11 +2408,23 @@ def _generate_byteplus_with_prefix_cache( cached_tokens or 0, ) - return { + result_out: Dict[str, Any] = { "tokens_used": total_tokens or 0, - "content": content or "", "cached_tokens": cached_tokens or 0, } + if exc_obj: + error_str = f"{type(exc_obj).__name__}: {str(exc_obj)}" + result_out["error"] = error_str + try: + result_out["error_info_obj"] = classify_llm_error( + exc_obj, provider=self.provider, model=self.model + ) + except Exception: + pass + result_out["content"] = "" + else: + result_out["content"] = content or "" + return result_out def _parse_responses_api_content(self, result: Dict[str, Any]) -> str: """Parse content from BytePlus Responses API response. @@ -2418,6 +2507,13 @@ def _generate_byteplus_standard( or choices[0].get("delta", {}).get("content", "") or "" ).strip() + if not content and choices[0].get("finish_reason") == "content_filter": + # OpenAI-compatible signal for moderation-blocked output — + # HTTP 200 with empty content, otherwise indistinguishable + # from a generic empty response. + raise RuntimeError( + "Response was blocked by the provider's content filter." + ) total_tokens = int(result.get("usage", {}).get("total_tokens", 0)) @@ -2429,7 +2525,7 @@ def _generate_byteplus_standard( except Exception as exc: # pragma: no cover exc_obj = exc - logger.error(f"Error calling BytePlus API: {exc}") + logger.debug(f"Error calling BytePlus API: {exc}") self._call_log_to_db( system_prompt, @@ -2467,7 +2563,6 @@ def _generate_byteplus_standard( except Exception: pass result["content"] = "" - logger.error(f"[BYTEPLUS_ERROR] {error_str}") else: result["content"] = content or "" return result @@ -2619,7 +2714,7 @@ def _generate_anthropic( except Exception as exc: # pragma: no cover exc_obj = exc - logger.error(f"Error calling Anthropic API: {exc}") + logger.debug(f"Error calling Anthropic API: {exc}") self._call_log_to_db( system_prompt, @@ -2659,7 +2754,6 @@ def _generate_anthropic( except Exception: pass result["content"] = "" - logger.error(f"[ANTHROPIC_ERROR] {error_str}") else: result["content"] = content or "" return result @@ -2822,7 +2916,7 @@ def _generate_bedrock( except Exception as exc: # pragma: no cover exc_obj = exc - logger.error(f"Error calling Bedrock Converse API: {exc}") + logger.debug(f"Error calling Bedrock Converse API: {exc}") self._call_log_to_db( system_prompt, @@ -2857,7 +2951,6 @@ def _generate_bedrock( except Exception: pass result["content"] = "" - logger.error(f"[BEDROCK_ERROR] {error_str}") else: result["content"] = content or "" return result diff --git a/agent_core/core/impl/video_gen/interface.py b/agent_core/core/impl/video_gen/interface.py index 57c404ae..55844af3 100644 --- a/agent_core/core/impl/video_gen/interface.py +++ b/agent_core/core/impl/video_gen/interface.py @@ -78,16 +78,17 @@ _AUDIO_CAPABLE_PROVIDERS = {"gemini", "openai", "byteplus"} # all three honor it -def _classify_error(provider: str, exc: Exception, model: str) -> str: - """Render *exc* as a human-readable error string via the shared catalog. +def _classified_error(provider: str, exc: Exception, model: str) -> "ClassifiedError": + """Classify *exc* via the shared catalog and wrap it as a ClassifiedError. Import deferred to call time — agent_core must stay importable without the host `app` package (all app.* imports in this package are function-local by convention). """ - from app.i18n import classify_provider_error + from agent_core.core.errors import ClassifiedError + from app.i18n import classify_provider_error_info - return classify_provider_error(exc, provider=provider, model=model) + return ClassifiedError(classify_provider_error_info(exc, provider=provider, model=model)) # ── File / image helpers ───────────────────────────────────────────────────── @@ -523,10 +524,8 @@ def _openai_generate( pass if not paths: - raise RuntimeError( - _classify_error( - "openai", first_error or RuntimeError("no result"), self.model - ) + raise _classified_error( + "openai", first_error or RuntimeError("no result"), self.model ) return paths @@ -538,7 +537,7 @@ def _poll_openai_video(self, video_id: str, poll_timeout_seconds: int) -> Any: try: obj = self.client.videos.retrieve(video_id) except Exception as exc: - raise RuntimeError(_classify_error("openai", exc, self.model)) from exc + raise _classified_error("openai", exc, self.model) from exc status = getattr(obj, "status", None) if status == "completed": @@ -570,7 +569,7 @@ def _download_openai_video(self, video_id: str) -> bytes: try: content = self.client.videos.download_content(video_id) except Exception as exc: - raise RuntimeError(_classify_error("openai", exc, self.model)) from exc + raise _classified_error("openai", exc, self.model) from exc # The SDK may return bytes directly or an HTTPResponse-like object. if isinstance(content, bytes): @@ -718,7 +717,7 @@ def _gemini_generate( # generate_audio intentionally omitted — see comment above. ) except Exception as exc: - raise RuntimeError(_classify_error("gemini", exc, self.model)) from exc + raise _classified_error("gemini", exc, self.model) from exc operation_name = op.get("name") if not operation_name: @@ -740,9 +739,19 @@ def _gemini_generate( or final.get("error", {}).get("message") ) if block_reason: - raise RuntimeError( - f"Gemini Veo blocked or returned no samples ({block_reason}). " - "Try modifying your prompt or adjusting person_generation." + from agent_core.core.errors import ClassifiedError, ErrorCategory, ErrorInfo, Severity + + raise ClassifiedError( + ErrorInfo( + category=ErrorCategory.BLOCKED, + code="VIDEO_GEN_BLOCKED", + title="Blocked by safety filter", + message=( + f"Gemini Veo blocked or returned no samples ({block_reason}). " + "Try modifying your prompt or adjusting person_generation." + ), + severity=Severity.ERROR, + ) ) raise RuntimeError( "Gemini Veo returned no video samples — try rephrasing your prompt " @@ -769,9 +778,7 @@ def _gemini_generate( try: data = self._gemini_client.download_video(uri, timeout=180) except Exception as exc: - raise RuntimeError( - _classify_error("gemini", exc, self.model) - ) from exc + raise _classified_error("gemini", exc, self.model) from exc elif inline: data = base64.b64decode(inline) else: @@ -800,7 +807,7 @@ def _poll_gemini_operation( try: op = self._gemini_client.poll_video_operation(operation_name) except Exception as exc: - raise RuntimeError(_classify_error("gemini", exc, self.model)) from exc + raise _classified_error("gemini", exc, self.model) from exc if op.get("done"): err = op.get("error") @@ -947,10 +954,8 @@ def _byteplus_generate( ) if not paths: - raise RuntimeError( - _classify_error( - "byteplus", first_error or RuntimeError("no result"), self.model - ) + raise _classified_error( + "byteplus", first_error or RuntimeError("no result"), self.model ) return paths @@ -970,7 +975,7 @@ def _byteplus_submit( timeout=60, ) except Exception as exc: - raise RuntimeError(_classify_error("byteplus", exc, self.model)) from exc + raise _classified_error("byteplus", exc, self.model) from exc if not r.ok: try: @@ -1014,9 +1019,7 @@ def _byteplus_poll( ) r.raise_for_status() except Exception as exc: - raise RuntimeError( - _classify_error("byteplus", exc, self.model) - ) from exc + raise _classified_error("byteplus", exc, self.model) from exc data = r.json() status = (data.get("status") or "").lower() diff --git a/agent_core/core/impl/vlm/interface.py b/agent_core/core/impl/vlm/interface.py index 34dde4cf..5db0114c 100644 --- a/agent_core/core/impl/vlm/interface.py +++ b/agent_core/core/impl/vlm/interface.py @@ -313,8 +313,12 @@ def describe_image_bytes( logger.info(f"[LLM RECV] {cleaned}") return cleaned except Exception as e: - logger.error(f"[ERROR] {e}") - raise + from agent_core.core.errors import ClassifiedError + from agent_core.core.impl.llm.errors import classify_llm_error + + info = classify_llm_error(e, provider=self.provider, model=self.model) + logger.error(f"[VLM] {info.message}") + raise ClassifiedError(info) from e async def generate_response_async( self, diff --git a/agent_core/core/models/chatgpt_subscription_client.py b/agent_core/core/models/chatgpt_subscription_client.py index 30fd29bf..3d376612 100644 --- a/agent_core/core/models/chatgpt_subscription_client.py +++ b/agent_core/core/models/chatgpt_subscription_client.py @@ -599,6 +599,8 @@ def _translate_backend_error(exc: Exception, model: str) -> Exception: entitlement. Surface that as a plan-explanation rather than a model-config error so the user knows to upgrade or switch auth. """ + from agent_core.core.errors import ClassifiedError, ErrorCategory, ErrorInfo, Severity + text = str(exc) if "ChatGPT account" not in text and "not supported when using Codex" not in text: return exc @@ -612,15 +614,25 @@ def _translate_backend_error(exc: Exception, model: str) -> Exception: except Exception: pass if plan == "free" or not plan: - return RuntimeError( + message = ( "ChatGPT subscription is connected but this account has no Plus/Pro/Team " "plan — the Codex backend rejects all models for Free-tier accounts. " "Upgrade at chat.openai.com, disconnect the subscription in Settings, " "or switch back to API-key auth." ) - return RuntimeError( - f"ChatGPT subscription rejected model {model!r}: {text}. " - "Try a different model from the subscription list, or switch to API-key auth." + else: + message = ( + f"ChatGPT subscription rejected model {model!r}: {text}. " + "Try a different model from the subscription list, or switch to API-key auth." + ) + return ClassifiedError( + ErrorInfo( + category=ErrorCategory.CONFIG, + code="CHATGPT_SUBSCRIPTION_REJECTED", + title="Subscription plan rejected", + message=message, + severity=Severity.ERROR, + ) ) diff --git a/agent_core/core/models/factory.py b/agent_core/core/models/factory.py index 462c4a20..91cec198 100644 --- a/agent_core/core/models/factory.py +++ b/agent_core/core/models/factory.py @@ -350,7 +350,9 @@ def create( if not api_key: if deferred: return empty_context - raise ValueError("API key required for OpenAI") + from app.errors import CatalogError, make_error + + raise CatalogError(make_error("CONFIG_NO_API_KEY", provider="OpenAI")) return { "provider": provider, @@ -371,7 +373,9 @@ def create( if not api_key: if deferred: return empty_context - raise ValueError("API key required for Gemini") + from app.errors import CatalogError, make_error + + raise CatalogError(make_error("CONFIG_NO_API_KEY", provider="Gemini")) return { "provider": provider, @@ -389,7 +393,9 @@ def create( if not api_key: if deferred: return empty_context - raise ValueError("API key required for Anthropic") + from app.errors import CatalogError, make_error + + raise CatalogError(make_error("CONFIG_NO_API_KEY", provider="Anthropic")) return { "provider": provider, @@ -407,7 +413,9 @@ def create( if not api_key: if deferred: return empty_context - raise ValueError("API key required for BytePlus") + from app.errors import CatalogError, make_error + + raise CatalogError(make_error("CONFIG_NO_API_KEY", provider="BytePlus")) return { "provider": provider, @@ -498,7 +506,14 @@ def create( if not api_key: if deferred: return empty_context - raise ValueError(f"API key required for {provider}") + from app.errors import CatalogError, make_error + + raise CatalogError( + make_error( + "CONFIG_NO_API_KEY", + provider=_PROVIDER_DISPLAY.get(provider, provider), + ) + ) return { "provider": provider, diff --git a/agent_core/core/prompts/action.py b/agent_core/core/prompts/action.py index 2ee7bc42..d8cfdda8 100644 --- a/agent_core/core/prompts/action.py +++ b/agent_core/core/prompts/action.py @@ -147,7 +147,8 @@ includes any write/mutate (write_file, stream_edit, clipboard_write), wait, and add_action_sets / remove_action_sets / use_skill / unload_skill. Never emit two of the same single-instance action: combine multiple messages -into ONE send, and use ONE update_todos with the full list. +into ONE send, and use ONE update_todos with the COMPLETE list — the payload +replaces the whole list, so any todo you omit is deleted. A FINAL send_message (continue_work absent or false) must be the ONLY action in its step — pairing it with working actions is contradictory. diff --git a/agent_core/core/prompts/application.py b/agent_core/core/prompts/application.py index c9dbe930..488bbc4f 100644 --- a/agent_core/core/prompts/application.py +++ b/agent_core/core/prompts/application.py @@ -5,7 +5,7 @@ Contains prompt templates for Living UI and other application features. """ -LIVING_UI_TASK_INSTRUCTION = """Create a Living UI application. +LIVING_UI_TASK_INSTRUCTION = """Create a Living UI application (V2 — PocketBase + React kit). Project ID: {project_id} Project Name: {project_name} @@ -14,76 +14,61 @@ Theme: {theme} Project Path: {project_path} -Follow the living-ui-creator skill instructions. Here's the workflow: +Follow the living-ui-creator skill. Workflow: 1. Read agent_file_system/GLOBAL_LIVING_UI.md — apply its colors, fonts, and rules -2. Phase 0: Ask the user 2+ batches of questions about data, features, design, and layout -3. Document requirements in LIVING_UI.md -4. Break the app into features, then for each feature: - - Re-read LIVING_UI.md (check what's left) and GLOBAL_LIVING_UI.md (refresh design rules) - - Write backend tests first (backend/tests/) - - Create model + routes to pass tests - - Run pytest to verify - - Create frontend types + components - - Update LIVING_UI.md — mark this feature as done, add models/routes/components you created - Do NOT skip features listed in LIVING_UI.md. A working app with all planned features is the goal. -5. Update LIVING_UI.md with implementation details -6. Call living_ui_notify_ready(project_id="{project_id}") +2. Read {project_path}/LIVING_UI.md (plan/index) and {project_path}/reference/requirements.md. + The creation wizard interviewed the user and synthesized requirements.md — it + is the BINDING spec: implement it EXACTLY and mirror its feature checklist into + LIVING_UI.md before coding. If requirements.md is absent, build from the + Description above; only ask the user (a FINAL send_message, continue_work=false) + when something is blocking and you cannot reasonably decide it yourself. +3. This build IS substantial work — the standard run protocol applies as-is + (scope, plan, execute, verify, deliver). Do not skip it because these + numbered steps exist; they only describe the Living-UI-specific parts. +4. OWNERSHIP RULE (the gate enforces this by hashing): + - You may edit ONLY: frontend/src/app/, pb/pb_migrations/, pb/pb_hooks/ (ops.pb.js + and new *.pb.js files), operations.json (non-system entries), LIVING_UI.md + - NEVER touch: frontend/src/kit/, frontend/src/main.tsx, frontend/src/config.gen.ts, + pb/pb_hooks/_system.pb.js, manifest.json, vite/tsconfig files. + Need a component variant? Wrap the kit component in frontend/src/app/ instead. +5. Build order per feature: + - Schema: add a NEW migration in pb/pb_migrations/ (never edit an applied one); + follow the starter migration's field/rule pattern and the project's authMode + - Custom verbs (beyond CRUD): routerAdd route in pb/pb_hooks/ops.pb.js + a matching + entry in operations.json (the gate fails orphan ops; see items.clear-done example) + - UI: build in frontend/src/app/ from kit parts (import from '../kit/index.ts'); + data via useCollection (realtime — never poll or reload); writes via + getPbClient().call(...) (errors toast automatically) + - Update LIVING_UI.md — mark the feature done, record entities/ops/components +6. Quality bar: empty states with a next action, loading states, confirmation dialog + for destructive actions, toasts on CRUD, responsive layout, kit tokens only + (never hardcoded colors — theming is host-owned) +7. FINISH — two steps, in order: + a. living_ui_notify_ready(project_id="{project_id}") — runs the validation + gate (types, build, migrations-on-fresh-db, ops structure, ownership), + launches, health-checks, smoke-verifies. On errors: read ALL of them, + fix ALL of them, call it again. Success = the app is RUNNING but NOT + yet verified. + b. living_ui_walk_verify(project_id="{project_id}") — an independent + verifier walks the RUNNING app in a real (headless) browser against + reference/requirements.md. Success = the app is announced to the user + and the build is COMPLETE. Failing features come back as a report: + fix them, then repeat (a) and (b). -What a GOOD Living UI looks like: -- Professional web app layout — proper spacing, visual hierarchy, sections, headers -- Uses preset components (Button, Card, Input, Modal, Table from './components/ui') — never raw HTML -- Thoughtful layout: sidebar or top nav, content area with grid/list views, detail panels or modals -- Colors from GLOBAL_LIVING_UI.md applied consistently -- Empty state when no data — the app launches with an empty database, users create their own content -- "Add" actions open forms/modals with proper input fields — never auto-create with placeholder text -- Every item is viewable, editable, and deletable through the UI -- Error handling with toast notifications on API failures -- Responsive design that works on different screen sizes +RUN RULE: this run IS the build — there is no "continue in a later turn". +The ONLY valid ways this run ends: a question to the user (a FINAL +send_message, continue_work=false — the reply wakes the session) or +living_ui_walk_verify returning success. Never end_turn mid-build. -When pytest fails: -- Read ALL errors carefully before fixing — fix ALL issues in one go, not one at a time -- If you see an import error, check ALL files for the same pattern and fix them all -- Maximum 3 pytest attempts per feature. If still failing after 3, review your approach -- Common fix: relative imports (from . import X) → absolute imports (from X import Y) +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 is blocking — NEVER claim the app is ready or usable when the +launch failed. A false "ready" is the worst possible outcome. -External integrations (Gmail, YouTube, Discord, Slack, etc.): -- CraftBot has connected external services — use the integration bridge, NOT custom OAuth -- Import: from services.integration_client import integration -- Call: result = await integration.request("google_workspace", "GET", url) -- NEVER build OAuth flows, ask for API keys, or store credentials -- See the "External Integrations" section in SKILL.md for details and examples +Schema gotcha: relation fields require the TARGET COLLECTION'S ID, not its +name — save the target collection first, then reference +app.findCollectionByNameOrId("").id in the dependent collection. -What to AVOID: -- Flat list of items with no visual structure -- Custom CSS when preset components exist -- Hardcoded test data left in the database -- Buttons that create items without user input -- Everything crammed into one component file -- Relative imports in backend code -- Running uvicorn/npm manually — the launch pipeline handles this -- Editing main.py, main.tsx, manifest.json, or tests/conftest.py — system managed -- Rewriting conftest.py — it has the correct imports and test DB setup already - -Your todo list should follow this EXACT pattern — do NOT add extra sub-steps: -Phase 0: Read global config -Phase 0: Ask user batch 1 (data/features) -Phase 0: Ask user batch 2 (design/layout) -Phase 0: Document requirements in LIVING_UI.md -Phase 1: Plan features -Feature 1 - [name]: Backend (tests + model + routes + pytest) -Feature 1 - [name]: Frontend (types + components + controller) -Feature 2 - [name]: Backend (tests + model + routes + pytest) -Feature 2 - [name]: Frontend (types + components + controller) -Feature 3 - [name]: Backend (tests + model + routes + pytest) -Feature 3 - [name]: Frontend (types + components + controller) -... repeat for each feature ... -Update LIVING_UI.md with implementation details -Call living_ui_notify_ready - -IMPORTANT about features: -- Each feature is a USER-FACING capability (e.g., "Board Items", "Media Attachments", "Search/Filter") -- "Backend Setup" or "Frontend Setup" are NOT features — they are layers -- Each feature MUST have BOTH backend AND frontend todos — never just one -- Keep exactly 2 todos per feature (backend + frontend) — do NOT split into 10+ sub-steps -- Write ALL tests for a feature at once, not one endpoint at a time""" +Debugging: frontend runtime errors are relayed to {project_path}/logs/frontend_console.log; +the PocketBase server log is {project_path}/logs/pocketbase.log.""" diff --git a/agent_file_system/AGENT.md b/agent_file_system/AGENT.md index 4013d20d..c325c025 100644 --- a/agent_file_system/AGENT.md +++ b/agent_file_system/AGENT.md @@ -410,10 +410,11 @@ The harness already handles certain failures so you do not have to. Recognizing - Recovery: the timeout is final for that invocation. Either retry with smaller scope (fewer rows, narrower regex, smaller batch) or split the work into multiple actions. **LLM consecutive-failure circuit breaker** ([agent_core/core/impl/llm/errors.py](agent_core/core/impl/llm/errors.py), [agent_core/core/impl/llm/interface.py](agent_core/core/impl/llm/interface.py)) -- After repeated consecutive LLM failures (auth, network, etc.), the harness raises `LLMConsecutiveFailureError`. -- `_handle_react_error` walks the exception chain (`__cause__`/`__context__`) to detect this and **automatically cancels the task** via `task_manager.mark_task_cancel(...)`. The agent's last instruction is cached in `_llm_retry_instructions[session_id]` for retry-after-fix. -- A `LLM_FATAL_ERROR` UI event is emitted so the user sees a clear failure dialog. -- **Implication:** if you see `MSG_CONSECUTIVE_FAILURE` ("LLM calls have failed N consecutive times. Task aborted to prevent infinite retries."), the task is already gone. Do NOT try to re-create it. The user must check their LLM configuration. +- Non-transient categories (auth, credit, quota, model, blocked, bad request) raise `LLMConsecutiveFailureError` immediately on the first failure — retrying the same request can't fix them. Transient categories (rate-limit, server, connection, unclassified) get a 5-attempt retry budget before the same error is raised. +- `_handle_react_error` walks the exception chain (`__cause__`/`__context__`) to detect this and **halts the run** (`_emit_run_state(session_id, False)`) rather than cancelling the task outright. +- Presentation splits into two tiers: a recognized/classified failure (bad key, no credits, misconfigured provider — anything carrying an `ErrorInfo`, via `LLMConsecutiveFailureError.last_error_info` or a `ClassifiedError`) shows as a short, calm "system"-style message; anything unclassified shows as a red "error" message with full detail — see [agent_core/core/errors.py](agent_core/core/errors.py). +- There is no Retry/Change Model button — the user resumes by sending a normal chat message (e.g. "continue"). `_handle_chat_message` resets the failure counter on any new message, so this just works. +- **Implication:** if you see `MSG_CONSECUTIVE_FAILURE`/`MSG_FAILED_IMMEDIATELY`, the run has halted and is waiting on the user's next message. Do NOT try to keep working. **Action limit (`max_actions_per_task`, minimum 5)** ([agent_core/core/state/types.py](agent_core/core/state/types.py)) - Tracked in `STATE.get_agent_property("action_count")` against `max_actions_per_task`. diff --git a/app/agent_base.py b/app/agent_base.py index 2daeeec4..410a607f 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -24,6 +24,7 @@ import asyncio import os +import re import shutil import traceback import time @@ -67,10 +68,15 @@ from app.internal_action_interface import InternalActionInterface from app.llm import LLMInterface -from agent_core.core.impl.llm.errors import ( - classify_llm_error_message, - LLMConsecutiveFailureError, +from agent_core.core.errors import ( + ClassifiedError, + ErrorCategory, + ErrorInfo, + ErrorInfoLike, + Severity, + redact, ) +from agent_core.core.impl.llm.errors import LLMConsecutiveFailureError from app.vlm_interface import VLMInterface from app.image_gen_interface import ImageGenInterface from app.video_gen_interface import VideoGenInterface @@ -173,8 +179,9 @@ class TriggerData: # turn start: source value → (emoji, label). Without this, non-chat runs # (scheduler fires, background workflows) just start streaming actions # with no visible cause. Sources absent here stay silent — user messages -# have their own chat bubble; continuations, restart notices and living-ui -# plumbing are internal. Closed set keyed on the typed source enum. +# have their own chat bubble; continuations, restart notices, living-ui +# creation (adapter posts its own richer summary) and living-ui import are +# handled elsewhere. Closed set keyed on the typed source enum. TRIGGER_ANNOUNCEMENTS: Dict[str, tuple[str, str]] = { TriggerSource.SCHEDULED.value: ("⏰", "Scheduled task"), TriggerSource.SCHEDULED_ONCE.value: ("⏰", "Scheduled task"), @@ -184,6 +191,10 @@ class TriggerData: TriggerSource.PROACTIVE_PLANNER.value: ("⚙️", "Proactive planning"), TriggerSource.ONBOARDING.value: ("⚙️", "Onboarding workflow"), TriggerSource.SKILL_WORKFLOW.value: ("⚙️", "Skill workflow"), + # NB: LIVING_UI_DEV (creation build) is NOT here — the adapter posts a + # richer "Living UI: / / Building your app now…" + # summary into the project session at creation, which would duplicate. + TriggerSource.LIVING_UI_CRASH_FIX.value: ("🔧", "Fixing your Living UI"), } @@ -238,9 +249,6 @@ def __init__( data_dir=data_dir, chroma_path=chroma_path ) - # Stores original run instructions keyed by session_id for LLM retry after failure - self._llm_retry_instructions: dict[str, str] = {} - # LLM + prompt plumbing (may be deferred if API key not yet configured) self.llm = LLMInterface( provider=llm_provider, @@ -296,6 +304,11 @@ def __init__( agent_file_system_path=AGENT_FILE_SYSTEM_PATH, ) + # A2APP claim gate (spec A2APP-PLAN Phase 1 B10): what this run has + # actually written to a Living UI, and how many messages have been + # withheld for misreporting it. Both reset when the run ends. + self._lui_run_writes: Dict[str, list] = {} + # action layer self.action_library = ActionLibrary(self.llm, db_interface=self.db_interface) @@ -600,6 +613,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 @@ -1050,8 +1080,142 @@ async def _execute_actions( is_running_task=True, ) + # A2APP: when the agent writes to a Living UI, the SYSTEM reports what + # 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: + # they change nothing and need no receipt. + _LUI_WRITE = re.compile( + r"cli\.ts\s+(?:data\s+\S+\s+(?P\S+)\s+(?Pcreate|update|delete)" + r"|run\s+\S+\s+(?P[\w.\-]+))" + ) + + def _report_living_ui_writes( + self, session_id: str, actions_with_input: list, results: list + ) -> None: + """Report what a turn changed, IN CRAFTBOT'S VOICE, and refresh the app. + + Why the system writes it: in the incident that motivated A2APP the + agent wrote a card with an empty due date, read `"due_date":""` in its + own tool output, and told the user "scheduled for tomorrow". Guarding + the write stops the bad data; it does not stop the false sentence. + + Why it is not a separate "System" speaker: it was, and it read badly — + the user saw a grey robot line restating what the assistant then said + again, less precisely ("due tomorrow" against the receipt's "due Fri 31 + Jul") and padded with filler. Delivering the fact AS CraftBot removes + the duplication and the extra narration turn, and keeps the guarantee: + the words come from the stored record, not from the model. + + One line per turn, not per write, so a turn that changes three things + does not produce three bubbles. (A bulk run spread over many turns + still yields many lines — see A2APP-PLAN for the open case.) + + Also the only place `dispatch_living_ui_data_changed` fires on the CLI + path — previously it fired solely from the deprecated `living_ui_http` + action, so agent writes never refreshed the iframe. + """ + try: + session = self.session_manager.get(session_id) + except Exception: + session = None + project_id = getattr(session, "living_ui_project_id", None) if session else None + if not project_id: + return + + summaries = [] + for (action, params), result in zip(actions_with_input, results): + try: + if getattr(action, "name", None) != "run_shell": + continue + command = str((params or {}).get("command") or "") + match = self._LUI_WRITE.search(command) + if match is None: + continue + summary = self._describe_write(session_id, project_id, match, result) + if summary: + summaries.append(summary) + except Exception as e: # a receipt must never break the turn + logger.debug(f"[A2APP] receipt skipped: {e}") + + if not summaries: + return + + if self.event_stream_manager: + text = summaries[0] if len(summaries) == 1 else "\n".join(f"• {s}" for s in summaries) + self.event_stream_manager.log( + kind="living_ui_write", + message=text, + event_type=EventType.AGENT_MESSAGE, + display_message=text, + task_id=session_id, + ) + + try: + from app.living_ui import dispatch_living_ui_data_changed + + dispatch_living_ui_data_changed(project_id) + except Exception as e: + logger.debug(f"[A2APP] data-changed dispatch skipped: {e}") + + def _describe_write( + self, session_id: str, project_id: str, match, result: dict + ) -> Optional[str]: + """One CLI write result -> one plain sentence, or None if there is + nothing the user needs to read.""" + import json as _json + + collection = match.group("collection") + verb = match.group("verb") + target = match.group("op") or f"{collection}.{verb}" + stdout = str((result or {}).get("stdout") or "") + stderr = str((result or {}).get("stderr") or "") + failed = (result or {}).get("status") == "error" or (result or {}).get( + "return_code" + ) not in (0, None) + + # A failure the agent goes on to recover from is NOT an event in the + # user's world — it is an internal retry, and putting it in the chat + # reads like the assistant arguing with itself. The agent still sees it + # (action_end carries the full stderr) and so does anyone who opens the + # actions detail; the conversation stays about what the user asked for. + if failed: + logger.info(f"[A2APP] {target} rejected: {(stderr or stdout).strip()[:200]}") + return None + + record = None + try: + parsed = _json.loads(stdout) + if isinstance(parsed, dict) and "id" in parsed: + record = parsed + except Exception: + record = None + + summary = f"{target} ok" + if record is not None and collection: + try: + from app.living_ui import get_living_ui_manager + from app.living_ui.agent_view import humanise_write + + mgr = get_living_ui_manager() + proj = mgr.get_project(project_id) if mgr else None + base = (proj.backend_url or proj.url) if proj else None + if base: + summary = humanise_write( + base.rstrip("/"), collection, verb or "create", record + ) + except Exception as e: + logger.debug(f"[A2APP] could not humanise receipt: {e}") + + self._lui_run_writes.setdefault(session_id, []).append( + {"collection": collection, "verb": verb, "record": record, "summary": summary} + ) + return summary + def _merge_action_outputs(self, outputs: list) -> dict: """ Merge outputs from parallel actions into single response. @@ -1098,6 +1262,23 @@ async def _finalize_turn( run_ends = bool(action_output.get("run_ends", False)) if run_ends: + # 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 @@ -1332,87 +1513,164 @@ def _sanitize_session_title(response: Optional[str]) -> str: # ----- Error Handling ----- - async def _handle_react_error( - self, + @staticmethod + def _classify_react_error( error: Exception, - session_id: str, - action_output: dict, - ) -> None: - """Handle errors during react execution.""" - tb = traceback.format_exc() - logger.error(f"[REACT ERROR] {error}\n{tb}") + ) -> tuple[bool, LLMConsecutiveFailureError | None, ErrorInfoLike | None]: + """Walk the exception chain (__cause__, __context__) once, looking for: - if not session_id or not self.event_stream_manager: - return + - `LLMConsecutiveFailureError` — the run is fatally halted (5 failed + attempts, or an immediate fail-fast category). Carries the *cause* + of the failure(s) in `.last_error_info` when known. + - `ClassifiedError` — a recognized, user-actionable failure that + didn't hit the consecutive-failure threshold (e.g. the action + router's own 3-attempt budget on an LLM provider error). Doesn't + halt the run. + + Anything else is a genuinely unclassified exception — presentation + treats it as a critical, "broken agent loop" failure. - # Walk the exception chain (__cause__, __context__) to detect the - # fatal-LLM case. We need the LLMConsecutiveFailureError to surface - # the *cause* of the 5 failures (e.g. "rate-limited on Google AI - # Studio"), not the meta-message about retry counts. - is_fatal_llm_error = False - fatal_exc: LLMConsecutiveFailureError | None = None + Returns (is_fatal, fatal_exc_or_None, classified_info_or_None). + """ seen: set[int] = set() exc: BaseException | None = error while exc is not None and id(exc) not in seen: seen.add(id(exc)) if isinstance(exc, LLMConsecutiveFailureError): - is_fatal_llm_error = True - fatal_exc = exc - break + info = exc.last_error_info or AgentBase._consecutive_failure_fallback_info(exc) + return True, exc, info + if isinstance(exc, ClassifiedError): + return False, None, exc.info cause = exc.__cause__ or exc.__context__ if cause is None or cause is exc: break exc = cause + return False, None, None - if ( - is_fatal_llm_error - and fatal_exc is not None - and fatal_exc.last_error_info is not None - ): - cause_msg = fatal_exc.last_error_info.message - user_message = f"Aborted after consecutive failures. {cause_msg}" - elif is_fatal_llm_error and fatal_exc is not None: - user_message = str(fatal_exc) + @staticmethod + def _consecutive_failure_fallback_info( + exc: LLMConsecutiveFailureError, + ) -> Optional[ErrorInfo]: + """Built when a fatal `LLMConsecutiveFailureError` has no classified + `last_error_info` but does carry a raw `last_error` (e.g. BytePlus + returning an empty response with no exception to classify — see + agent_core/core/impl/llm/interface.py's empty-response handling). + + Folds the "gave up after repeated failures" fact into the SAME + message as the underlying cause, minor/system tier, instead of + showing it as a second, disconnected "Aborted after consecutive + failures." bubble with no information about what actually failed. + Returns None only when there's truly nothing to show (falls back to + the critical/unclassified tier). + """ + if exc.last_error is None: + return None + raw = str(exc.last_error).rstrip(".") + suffix = ( + "This can't be fixed by retrying." + if exc.is_immediate + else "Gave up after repeated failures." + ) + return ErrorInfo( + category=ErrorCategory.UNKNOWN, + code="LLM_CONSECUTIVE_FAILURE", + title="Repeated failures", + message=f"{raw}. {suffix}", + ) + + @staticmethod + def _critical_fallback_info(raw_message: str) -> ErrorInfo: + """Built when NO recognized/classified error info is available — + i.e. a genuinely unexpected exception, not a known LLM/config + problem. Shown with full (redacted) technical detail and critical + (red) styling, per the "minor vs critical" presentation split: + recognized failures (bad key, no credits, misconfigured provider) + get a short, calm message; unrecognized ones get the raw detail so + it's clear something actually broke.""" + return ErrorInfo( + category=ErrorCategory.INTERNAL, + code="INTERNAL_UNCLASSIFIED", + title="Unexpected error", + message=redact(raw_message), + severity=Severity.CRITICAL, + ) + + async def _handle_react_error( + self, + error: Exception, + session_id: str, + action_output: dict, + ) -> None: + """Handle errors during react execution. + + Presentation is split into two tiers: + - Minor/user errors (bad key, no credits, invalid model, a + misconfigured provider) — a short, actionable message using the + calm "system" bubble style, no raw exception text. + - Critical failures (anything not recognized as a classified LLM/ + config problem — a genuine bug or crash) — full error detail with + the red "error" styling. + + This is independent of whether the run halts: only a fatal + `LLMConsecutiveFailureError` halts the run (5 failed attempts, or an + immediate fail-fast category); everything else lets the react loop + continue to the next turn while still telling the user what happened. + """ + is_fatal, fatal_exc, classified_info = self._classify_react_error(error) + is_critical = classified_info is None + if is_critical: + # Nothing further down the stack classified/logged this in + # detail — this is the only place a full traceback gets + # captured, so it's worth the ERROR level here. + tb = traceback.format_exc() + logger.error(f"[REACT ERROR] {error}\n{tb}") + raw = str(fatal_exc) if fatal_exc is not None else (str(error) or "AI service error") + info = self._critical_fallback_info(raw) else: - try: - user_message = classify_llm_error_message(error) - except Exception: - user_message = str(error) or "AI service error" + # Already logged with good detail by whichever layer classified + # it (interface.py / router.py) — avoid a second traceback dump. + logger.debug(f"[REACT ERROR] {error}") + info = classified_info + + if not session_id or not self.event_stream_manager: + return try: logger.debug("[REACT ERROR] Logging to event stream") + # event_type=EventType.INTERNAL (not ERROR): this event stays in + # the session stream for LLM self-correction/audit context, but + # EventType.ERROR IS dispatched by EventTransformer (see + # transformer.py's _DISPATCH) regardless of display_message, so + # using it here would let the background event watcher + # (ui_controller._watch_agent_events) render a second, undesired + # chat bubble a poll cycle after the one displayed directly + # below. EventType.INTERNAL maps to _build_hidden and is never + # surfaced — the same pattern already used by + # _send_limit_choice_message. self.event_stream_manager.log( "error", - f"[REACT] {type(error).__name__}: {user_message}", - event_type=EventType.ERROR, - display_message=user_message, + f"[REACT] {type(error).__name__}: {info.message}", + event_type=EventType.INTERNAL, + display_message=None, task_id=session_id, ) self.state_manager.bump_event_stream() - if is_fatal_llm_error: - # Stop the run instead of re-queueing to prevent infinite retries. + if is_fatal: + # Stop the run instead of re-queueing to prevent infinite + # retries. The user resumes by sending a normal chat message + # — _handle_chat_message already resets the failure counter + # on intake, so no separate Retry action is needed. logger.warning( f"[REACT ERROR] LLMConsecutiveFailureError — halting run for " f"session {session_id}." ) self._emit_run_state(session_id, False) - self._llm_retry_instructions[session_id] = ( - "Continue where you left off — the previous attempt was " - "aborted by an AI-provider failure." - ) - if self.ui_controller: - from app.ui_layer.events import UIEvent, UIEventType - - self.ui_controller.event_bus.emit( - UIEvent( - type=UIEventType.LLM_FATAL_ERROR, - data={"session_id": session_id}, - task_id=session_id, - ) - ) + await self._display_react_error(session_id, info, critical=is_critical) else: - # Recoverable turn error: continue the run so the LLM sees - # the error event and can adapt. + # Recoverable turn error: still tell the user what happened, + # but let the run continue so the LLM sees the error event + # and can adapt. + await self._display_react_error(session_id, info, critical=is_critical) await self.trigger_service.emit( TriggerSpec( source=TriggerSource.RUN_CONTINUATION, @@ -1431,6 +1689,32 @@ async def _handle_react_error( exc_info=True, ) + async def _display_react_error( + self, session_id: str, info: ErrorInfoLike, *, critical: bool + ) -> None: + """Show a single error bubble: calm "system" styling for a + recognized, user-actionable failure; red "error" styling with full + detail for an unclassified/critical one. + + Displayed directly via the chat component (like + `_send_limit_choice_message`) instead of round-tripping through a + `UIEvent` on the event bus, so there's no ordering race with the + (invisible) event-stream log entry above. + """ + if not (self.ui_controller and self.ui_controller.active_adapter): + logger.warning("[REACT ERROR] No active UI adapter - error not displayed") + return + from app.ui_layer.components.error_message import build_error_chat_message + + chat = self.ui_controller.active_adapter.chat_component + message = build_error_chat_message( + info, + sender="Error" if critical else "System", + session_id=session_id, + style="error" if critical else "system", + ) + await chat.append_message(message) + # ----- Agent Limits ----- async def _check_agent_limits(self, session_id: str) -> bool: @@ -1448,7 +1732,12 @@ async def _check_agent_limits(self, session_id: str) -> bool: self.event_stream_manager.log( "warning", f"Action limit reached: 100% of the maximum actions ({max_actions} actions) has been used. Waiting for user decision.", - event_type=EventType.SYSTEM, + # EventType.INTERNAL (not SYSTEM): this is context-only — + # EventType.SYSTEM IS dispatched to a chat bubble by + # EventTransformer regardless of display_message, which + # would double up with _send_limit_choice_message's own + # chat bubble below. + event_type=EventType.INTERNAL, display_message=None, task_id=session_id, ) @@ -1462,7 +1751,10 @@ async def _check_agent_limits(self, session_id: str) -> bool: self.event_stream_manager.log( "warning", f"Token limit reached: 100% of the maximum tokens ({max_tokens} tokens) has been used. Waiting for user decision.", - event_type=EventType.SYSTEM, + # See the action-limit branch above: EventType.INTERNAL, + # not SYSTEM, to avoid a second chat bubble alongside + # _send_limit_choice_message's. + event_type=EventType.INTERNAL, display_message=None, task_id=session_id, ) @@ -1514,19 +1806,13 @@ async def _send_limit_choice_message( # Display message with options directly in the chat UI (awaited). if self.ui_controller and self.ui_controller.active_adapter: try: - from app.ui_layer.components.types import ChatMessage, ChatMessageOption + from app.ui_layer.components.types import ChatMessage + from app.ui_layer.components.error_message import continue_stop_options from app.onboarding import onboarding_manager import time as _time agent_name = onboarding_manager.state.agent_name or "Agent" - options = [ - ChatMessageOption( - label="Continue", value="continue_limit", style="primary" - ), - ChatMessageOption( - label="Stop", value="abort_limit", style="danger" - ), - ] + options = continue_stop_options() await self.ui_controller.active_adapter.chat_component.append_message( ChatMessage( sender=agent_name, @@ -1535,6 +1821,7 @@ async def _send_limit_choice_message( timestamp=_time.time(), session_id=session_id, options=options, + requires_choice=True, ) ) except Exception as e: @@ -1608,28 +1895,6 @@ async def handle_limit_abort(self, session_id: str) -> None: ) self.state_manager.bump_event_stream() - async def handle_llm_retry(self, session_id: str) -> None: - """Retry after a fatal LLM failure. Resets the failure counter and resumes the run.""" - self._llm_retry_instructions.pop(session_id, None) - try: - self.llm.reset_failure_counter() - except Exception as e: - logger.debug(f"[LLM_RETRY] Could not reset failure counter: {e}") - - self._emit_run_state(session_id or MAIN_SESSION_ID, True) - await self.trigger_service.emit( - TriggerSpec( - source=TriggerSource.RUN_CONTINUATION, - description=( - "Retry: the previous attempt was aborted by an AI-provider " - "failure. Continue the work from where you left off based " - "on the event stream." - ), - priority=5, - session_id=session_id or MAIN_SESSION_ID, - ) - ) - # ===================================== # Message intake # ===================================== @@ -1738,17 +2003,69 @@ def _build_living_ui_note(living_ui_project_id: str) -> str: try: from app.living_ui import get_living_ui_manager + from app.config import PROJECT_ROOT + + _lui_cli = f"{PROJECT_ROOT}/living-ui-v2/tools/src/cli.ts" mgr = get_living_ui_manager() if mgr: proj = mgr.get_project(living_ui_project_id) if proj: + # The DATA MODEL goes in the prompt, not behind a pointer. + # Twice now the agent has ignored "Read LIVING_UI.md", never + # run `lui ops`, and guessed collection names instead + # (`items`, then `tasks`) — and once invented an enum value + # (`priority: "normal"`) it could not have known was wrong. + # Advisory text does not work on a weak model; context does. + schema = None + try: + from app.living_ui.agent_view import schema_block + + base = proj.backend_url or proj.url + if base: + schema = schema_block(base.rstrip("/")) + except Exception: + schema = None + + model = ( + f"Data model (field(type), * = required):\n{schema}\n" + 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"Read {proj.path}/LIVING_UI.md for app context.\n" - f"If debugging issues, FIRST read these logs:\n" - f" - {proj.path}/backend/logs/subprocess_output.log (crashes, stack traces)\n" - f" - {proj.path}/backend/logs/frontend_console.log (frontend errors, network failures)" + 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" + f"your voice, generated from the stored record. Do NOT send a message repeating\n" + f"it — end the turn. Send a message only to add something that report does not\n" + f"cover: a failure, a question, an answer to a question, or a summary of many\n" + f"changes.\n" + f"To OPERATE the app, use the lui CLI via run_shell with ABSOLUTE paths\n" + f"(the shell's cwd is NOT the repo root):\n" + f' node {_lui_cli} data {proj.path} create --field "value"\n' + f' ALWAYS quote values — an unquoted # starts a shell comment and\n' + f' silently drops the rest of the command.\n' + f" node {_lui_cli} data {proj.path} list --limit 20\n" + f" node {_lui_cli} run {proj.path} --param value\n" + f"If debugging, read {proj.path}/logs/pocketbase.log and logs/frontend_console.log.\n" + f"Using the app needs no skill. To CHANGE its code, or import/diagnose one,\n" + f"load the right Living UI skill first (use_skill); list_skills shows all skills." ) except Exception: pass @@ -2002,7 +2319,11 @@ async def _handle_prompt_enhance(self, user_message: str) -> str: result = json.loads(response) return result.get("enhanced_prompt", "") except Exception as e: - logger.error(f"{classify_provider_error(error=e)}") + logger.error( + classify_provider_error( + e, provider=self.llm.provider, model=getattr(self.llm, "model", "") or "" + ) + ) # ===================================== # Hooks diff --git a/app/config/mcp_config.json b/app/config/mcp_config.json index f77b823f..e90b800e 100644 --- a/app/config/mcp_config.json +++ b/app/config/mcp_config.json @@ -1170,7 +1170,8 @@ "transport": "stdio", "command": "npx", "args": [ - "@playwright/mcp@latest" + "@playwright/mcp@latest", + "--headless" ], "env": {}, "enabled": true diff --git a/app/data/action/browser_probe.py b/app/data/action/browser_probe.py new file mode 100644 index 00000000..d867ff58 --- /dev/null +++ b/app/data/action/browser_probe.py @@ -0,0 +1,95 @@ +"""Headless-browser probe of a running Living UI (walk-verify's hands).""" + +from agent_core import action + + +@action( + name="browser_probe", + description=( + "Drive a RUNNING Living UI in a headless browser (invisible — no " + "window). Executes a scripted sequence of steps and returns per-step " + "results, page text, screenshot file paths, and console errors. Use " + "this to verify UI flows a user would perform: navigate, click " + "buttons, fill forms, read what rendered." + ), + default=False, + mode="CLI", + action_sets=["living_ui"], + parallelizable=False, + input_schema={ + "url": { + "type": "string", + "example": "http://127.0.0.1:3100", + "description": "Base URL of the running app.", + }, + "steps": { + "type": "array", + "example": [ + {"op": "goto", "value": "/"}, + {"op": "click", "selector": "button:has-text('Add')"}, + {"op": "type", "selector": "input", "value": "hello"}, + {"op": "read", "selector": "main"}, + {"op": "screenshot", "value": "after-add"}, + ], + "description": ( + "Ordered steps (max 40). op: goto|click|type|read|wait|screenshot. " + "selector: CSS/Playwright selector. value: path for goto, text " + "for type, ms for wait, filename for screenshot. read with no " + "selector returns the whole page text." + ), + }, + "project_path": { + "type": "string", + "description": "Project dir — screenshots are saved under its logs/verify/.", + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "steps": {"type": "array", "description": "Per-step {op, ok, detail} results."}, + "console_errors": {"type": "array", "description": "Console/page errors seen."}, + }, + test_payload={ + "url": "http://127.0.0.1:3100", + "steps": [{"op": "goto", "value": "/"}], + "simulated_mode": True, + }, +) +async def browser_probe(input_data: dict) -> dict: + import asyncio + import json + from pathlib import Path + + if input_data.get("simulated_mode", False): + return {"status": "success", "steps": [{"op": "goto", "ok": True, "detail": "/"}], "console_errors": []} + + url = (input_data.get("url") or "").strip() + steps = input_data.get("steps") or [] + if not url or not isinstance(steps, list) or not steps: + return {"status": "error", "message": "url and a non-empty steps array are required"} + + from app.config import PROJECT_ROOT + + cli = Path(PROJECT_ROOT) / "living-ui-v2" / "tools" / "src" / "cli.ts" + out_dir = str(Path(input_data.get("project_path") or "/tmp") / "logs" / "verify") + proc = await asyncio.create_subprocess_exec( + "node", str(cli), "probe", "--url", url, "--steps", json.dumps(steps), "--out", out_dir, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + ) + try: + out, _ = await asyncio.wait_for(proc.communicate(), timeout=180) + except asyncio.TimeoutError: + proc.kill() + return {"status": "error", "message": "browser probe timed out after 180s"} + + text = out.decode(errors="replace").strip() + try: + payload = json.loads(text.splitlines()[-1]) + except Exception: + return {"status": "error", "message": f"probe output unparseable: {text[-500:]}"} + if "error" in payload: + return {"status": "error", "message": str(payload["error"])} + return { + "status": "success", + "steps": payload.get("steps", []), + "console_errors": payload.get("consoleErrors", []), + } diff --git a/app/data/action/end_turn.py b/app/data/action/end_turn.py index 7ac75df7..aa39e8ee 100644 --- a/app/data/action/end_turn.py +++ b/app/data/action/end_turn.py @@ -32,6 +32,37 @@ def end_turn(input_data: dict) -> dict: simulated_mode = input_data.get("simulated_mode", False) if not simulated_mode: + # STRUCTURAL GUARD: a Living UI build must never be silently + # abandoned mid-creation. Ending the run leaves the session asleep + # forever (nothing re-wakes it), stranding the user on the creation + # screen. Refuse and keep the run alive. + session_id = input_data.get("_session_id") + if session_id: + try: + from app.living_ui import get_living_ui_manager + + manager = get_living_ui_manager() + project = ( + manager.get_project_by_session_id(session_id) if manager else None + ) + if project is not None and project.status == "creating": + return { + "status": "error", + "message": ( + "REFUSED: this Living UI build is not finished — ending " + "the run now would strand it forever (nothing wakes the " + "session again). Valid ways to stop working: (1) keep " + "building the remaining features, (2) ask the user a " + "question via send_message with wait_for_user_reply=true, " + "or (3) finish with living_ui_notify_ready(project_id=" + f"'{project.id}') and report the result. There is no " + "'continue in a later turn' — this run IS the build." + ), + "end_turn": False, + } + except Exception: + pass # never let the guard itself break turn-ending + import app.internal_action_interface as internal_action_interface internal_action_interface.InternalActionInterface.do_end_turn() 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/integrations/integration_management.py b/app/data/action/integrations/integration_management.py index b416c566..dd773b8f 100644 --- a/app/data/action/integrations/integration_management.py +++ b/app/data/action/integrations/integration_management.py @@ -374,7 +374,15 @@ def connect_integration(input_data: dict) -> dict: } except Exception as e: - return {"status": "error", "message": f"Connection failed: {str(e)}"} + from app.errors import make_error + + info = make_error("CONNECTION_FAILED", target=integration_id, detail=str(e)) + return { + "status": "error", + "message": info.message, + "error_category": info.category.value, + "error_code": info.code, + } @action( diff --git a/app/data/action/living_ui_actions.py b/app/data/action/living_ui_actions.py index 4ea8105f..d4e2375e 100644 --- a/app/data/action/living_ui_actions.py +++ b/app/data/action/living_ui_actions.py @@ -1,19 +1,26 @@ """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", description=( - "Create and register a new Living UI project from the template. " - "Call this FIRST when building a Living UI from a chat request — i.e. " - "when your task instruction does NOT already contain a 'Project ID' and " - "'Project Path' (those come pre-scaffolded from the Create Living UI modal). " - "This copies the project template (backend/, frontend/, config/), allocates " - "ports, and registers the project so it appears in the user's Living UI list. " - "Returns the project_id and an absolute project_path — use project_path as the " - "base for ALL subsequent file operations so files land in the right folders." + "Create and register a new Living UI project from the template, then " + "dispatch the build to the project's dedicated session. Call this when " + "the user asks for a new Living UI in a regular chat — i.e. when your " + "task instruction does NOT already contain a 'Project ID' and 'Project " + "Path' (those come pre-scaffolded from the Create Living UI modal). " + "This copies the project template (backend/, frontend/, config/), " + "allocates ports, registers the project in the user's Living UI list, " + "and queues the build run in the project's own session. After it " + "returns, inform the user the build has started and end your turn — " + "do NOT write project files or call living_ui_notify_ready yourself." ), default=False, mode="CLI", @@ -28,7 +35,11 @@ "description": { "type": "string", "example": "A dashboard that forecasts stock performance.", - "description": "Short description of what the app does.", + "description": ( + "Description of what the app does. Include EVERY requirement " + "the user has given so far — it becomes the build instruction " + "for the project's session." + ), }, "features": { "type": "array", @@ -41,6 +52,15 @@ "example": "system", "description": "UI theme. Defaults to 'system'.", }, + "auth_mode": { + "type": "string", + "enum": ["none", "multi-user"], + "example": "none", + "description": ( + "Auth mode from the requirements: 'none' for a personal local " + "tool (default), 'multi-user' when the app needs accounts." + ), + }, }, output_schema={ "status": { @@ -51,12 +71,12 @@ "project_id": { "type": "string", "example": "abc12345", - "description": "The created project ID. Pass this to living_ui_notify_ready.", + "description": "The created project ID.", }, "project_path": { "type": "string", "example": "/workspace/living_ui/stock_forecaster_abc12345", - "description": "Absolute base path. Use this for ALL file operations.", + "description": "Absolute project path on disk.", }, "frontend_port": {"type": "integer", "description": "Allocated frontend port."}, "backend_port": {"type": "integer", "description": "Allocated backend port."}, @@ -77,9 +97,6 @@ async def living_ui_scaffold(input_data: dict) -> dict: description = input_data.get("description", "").strip() features = input_data.get("features") or [] theme = input_data.get("theme", "system") - # _session_id is injected by the ActionManager; for a Living UI task it equals - # the task id, which the progress/todo broadcast hooks key off of. - session_id = input_data.get("_session_id") simulated_mode = input_data.get("simulated_mode", False) if not name or not description: @@ -96,7 +113,11 @@ async def living_ui_scaffold(input_data: dict) -> dict: } try: - from app.living_ui import get_living_ui_manager, broadcast_living_ui_created + from app.living_ui import ( + get_living_ui_manager, + broadcast_living_ui_created, + broadcast_living_ui_progress, + ) manager = get_living_ui_manager() if not manager: @@ -117,17 +138,43 @@ async def living_ui_scaffold(input_data: dict) -> dict: description=description, features=features, theme=theme, + auth_mode=input_data.get("auth_mode", "none"), ) - # Associate the project with the running task so the agent's todos and - # progress stream to the Living UI view, then mark it as in-progress. - if session_id: - manager.set_project_task(project.id, session_id) - manager.update_project_status(project.id, "creating") - - # Register it in the browser's project list immediately (modal-parity). + # Register it in the browser's project list immediately and show the + # creation screen (modal-parity). await broadcast_living_ui_created(project.to_dict()) + await broadcast_living_ui_progress( + project.id, "initializing", 10, "Project created, starting development..." + ) + + # Hand the build off to the project's dedicated session (parity with + # the browser "+" flow): start_development_run ensures the session + # exists, marks the project as creating, and fires a LIVING_UI_DEV + # trigger carrying the full build instruction, so todos/progress/ + # questions stream to the Living UI view. + dev_session_id = await manager.start_development_run(project.id) + if dev_session_id: + return { + "status": "success", + "project_id": project.id, + "project_path": project.path, + "frontend_port": project.port, + "backend_port": project.backend_port, + "message": ( + f"Project '{project.name}' scaffolded at {project.path}. " + f"The build has been dispatched to the project's dedicated " + f"session — do NOT build it in this session, do NOT write " + f"project files, and do NOT call living_ui_notify_ready " + f"here. Tell the user the build has started and that " + f"progress and any setup questions will appear in the " + f"'{project.name}' Living UI tab, then end your turn." + ), + } + # Fallback — session runtime not bound (e.g. headless/test contexts): + # keep the legacy inline-build contract in the calling session. + manager.update_project_status(project.id, "creating") return { "status": "success", "project_id": project.id, @@ -137,7 +184,7 @@ async def living_ui_scaffold(input_data: dict) -> dict: "message": ( f"Project '{project.name}' scaffolded at {project.path}. " f"Use this absolute path as the base for ALL file operations " - f"(e.g. {project.path}/backend/models.py, {project.path}/frontend/). " + f"(e.g. {project.path}/frontend/src/app/, {project.path}/pb/pb_migrations/). " f"Do NOT write to bare relative paths. When the build is complete, " f'call living_ui_notify_ready(project_id="{project.id}").' ), @@ -149,10 +196,13 @@ async def living_ui_scaffold(input_data: dict) -> dict: @action( name="living_ui_notify_ready", description=( - "Launch, verify, and serve a Living UI project. " - "Call this after building the Living UI code. " - "This action installs dependencies, runs tests, starts the backend and frontend, " - "and notifies the browser. Returns test errors if anything fails." + "Launch or RELAUNCH a Living UI project: installs dependencies, runs the " + "validation gate, restarts backend and frontend, notifies the browser. " + "Call this ONLY after CREATING or CHANGING the app's CODE (migrations, " + "hooks, frontend). An app that is already running does NOT need it — " + "adding, editing or deleting DATA never requires a relaunch, and calling " + "it then rebuilds and restarts a live app for no reason. " + "Returns test errors if anything fails." ), default=False, mode="CLI", @@ -202,7 +252,7 @@ async def living_ui_notify_ready(input_data: dict) -> dict: } try: - from app.living_ui import get_living_ui_manager, broadcast_living_ui_ready + from app.living_ui import get_living_ui_manager manager = get_living_ui_manager() if not manager: @@ -215,28 +265,368 @@ async def living_ui_notify_ready(input_data: dict) -> dict: result = await manager.launch_and_verify(project_id) if result["status"] == "success": - # Notify browser that the UI is ready url = result.get("url", "") - port = result.get("port", 0) - await broadcast_living_ui_ready(project_id, url, port) + _proj_ok = manager.get_project(project_id) + if _proj_ok is not None: + _proj_ok._gate_fp = None + _proj_ok._gate_fp_count = 0 + + # 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": f"Living UI {project_id} is now ready at {url}", + "message": ( + f"App launched at {url} — gate, health and smoke checks " + "passed. NOT VERIFIED YET: now call " + f'living_ui_walk_verify(project_id="{project_id}") to run ' + "the independent verifier against the running app. The " + "build is complete ONLY when that returns success — do " + "NOT tell the user the app is ready before then." + ), } else: # Return errors directly so the agent can fix them errors = result.get("errors", []) errors_str = "\n".join(errors[:10]) + + # CIRCUIT BREAKER: detect fix attempts that change nothing. The + # fingerprint lives on the in-memory project (this module does not + # persist between action calls). + breaker_note = "" + project = manager.get_project(project_id) + if project is not None: + fp = hash((result.get("step"), errors_str)) + same = getattr(project, "_gate_fp", None) == fp + count = (getattr(project, "_gate_fp_count", 0) + 1) if same else 1 + project._gate_fp = fp + project._gate_fp_count = count + if count >= 6: + breaker_note = ( + f"\n\nSTOP: the EXACT same error has now occurred {count} times " + "in a row. The build is stuck — do NOT try again. Report the " + "failure honestly to the user with a final send_message " + "(state what is blocking and what you tried) and end the run." + ) + elif count >= 3: + breaker_note = ( + f"\n\nWARNING: this is the IDENTICAL error {count} times in a " + "row — your edits are NOT changing the outcome. Do not repeat " + "the same fix. Re-read the annotated error above: the caret " + "marks the EXACT offending expression (there may be several " + "similar ones on the line — fix the one under the caret). " + "Verify your edit actually changed that expression before " + "re-running." + ) return { "status": "error", "message": f"Launch failed at step: {result.get('step', 'unknown')}", "test_errors": errors[:10], - "details": f"Fix these errors and call living_ui_notify_ready again:\n{errors_str}", + "details": ( + f"Fix these errors and call living_ui_notify_ready again:\n{errors_str}" + + breaker_note + ), } except Exception as e: return {"status": "error", "message": f"Failed to launch: {str(e)}"} +@action( + name="living_ui_walk_verify", + description=( + "Run the independent walk-verify sub-agent against the RUNNING Living " + "UI project: a real browser (headless) drives the app " + "feature-by-feature against reference/requirements.md. A clean " + "verdict announces the app to the user — the ONLY way a Living UI " + "BUILD completes. Observed defects return the failure report: fix, " + "relaunch with living_ui_notify_ready, then call this again. " + "Requires the app to be running (living_ui_notify_ready first). " + "ONLY after building or modifying the app's CODE. NEVER after a data " + "change: it drives a real browser and CLICKS through the UI, including " + "buttons that create records, so running it against an app holding the " + "user's data can alter that data." + ), + default=False, + mode="CLI", + action_sets=["living_ui"], + parallelizable=False, + input_schema={ + "project_id": { + "type": "string", + "example": "abc12345", + "description": "The Living UI project ID (provided in task instruction).", + }, + }, + output_schema={ + "status": { + "type": "string", + "example": "success", + "description": "'success' = app verified and announced ready.", + }, + "message": { + "type": "string", + "example": "Living UI abc12345 is now ready (5 features walk-verified).", + "description": "Outcome summary.", + }, + "test_errors": { + "type": "array", + "example": ["- Onboarding — FAIL — form does not save"], + "description": "Observed defects when verification fails.", + }, + }, + test_payload={ + "project_id": "test123", + "simulated_mode": True, + }, +) +async def living_ui_walk_verify(input_data: dict) -> dict: + """Independent feature verification of the running app; announces the + app on a clean verdict.""" + project_id = input_data.get("project_id", "") + if input_data.get("simulated_mode"): + return { + "status": "success", + "message": f"Living UI {project_id} verified (simulated).", + } + if not project_id: + return {"status": "error", "message": "project_id is required"} + + try: + import asyncio as _asyncio + + from app.living_ui import ( + broadcast_living_ui_progress, + broadcast_living_ui_ready, + get_living_ui_manager, + ) + from app.living_ui.walk_verify import run_walk_verify + + manager = get_living_ui_manager() + project = manager.get_project(project_id) if manager else None + if project is None: + return {"status": "error", "message": f"Unknown project: {project_id}"} + if project.status != "running": + return { + "status": "error", + "message": ( + "The app is not running — call living_ui_notify_ready " + "first, then verify." + ), + } + url = f"http://127.0.0.1:{project.port}" + + try: + await broadcast_living_ui_progress( + project_id, + "verifying", + 92, + "Walk-verify: independently testing features against " + "the requirements (this takes a minute)…", + ) + except Exception: + pass + try: + # Belt-and-suspenders ceiling above the runner's own 30-min wall + # cap: even if the verifier wedges, the turn must end. Timeout = + # tooling failure (blocked), never an app defect. + report = await _asyncio.wait_for(run_walk_verify(project), timeout=2100) + except _asyncio.TimeoutError: + report = {"kind": "blocked", "passed": [], "defects": [], + "raw": "walk_verify exceeded the 35-minute ceiling"} + except Exception as verify_err: + report = {"kind": "blocked", "passed": [], "defects": [], + "raw": f"walk_verify crashed: {verify_err}"} + + kind = (report or {}).get("kind") + passed_n = len((report or {}).get("passed") or []) + try: + if kind == "defects": + outcome = ( + f"Walk-verify: {len(report['defects']) or 'some'} " + "feature(s) FAILED — fixing before launch" + ) + elif kind == "pass": + outcome = f"Walk-verify PASSED: {passed_n} feature(s) work" + elif kind == "incomplete": + outcome = ( + f"Walk-verify: {passed_n} passed, coverage incomplete " + "(some features NOT REACHED)" + ) + else: + outcome = "Walk-verify BLOCKED (tooling) — smoke checks only" + await broadcast_living_ui_progress(project_id, "verifying", 96, outcome) + 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. 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], + } + + # 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": + caveat = "" + elif kind == "incomplete": + 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": + caveat = ( + "The independent verifier could not run (browser/tooling " + "issue) — the app passed launch and smoke checks only; no " + "feature was browser-verified." + ) + else: + 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 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)}"} + + @action( name="living_ui_restart", description=( @@ -420,179 +810,16 @@ async def living_ui_report_progress(input_data: dict) -> dict: } -@action( - name="living_ui_import_external", - description=( - "Import an external app as a Living UI project. " - "Use this when the user wants to add an existing app (Go, Node.js, Python, Rust, static site) " - "to their Living UI dashboard. The agent should first analyze the app source code to determine " - "the runtime, build/install command, start command, and health check strategy, then call this action." - ), - action_sets=["living_ui"], - input_schema={ - "name": { - "type": "string", - "description": "Display name for the project.", - "example": "Glance Dashboard", - }, - "description": { - "type": "string", - "description": "Brief app description.", - "example": "Self-hosted dashboard", - }, - "source_path": { - "type": "string", - "description": "Absolute path to the app source code.", - "example": "/path/to/app", - }, - "app_runtime": { - "type": "string", - "description": "Runtime: node, python, go, rust, docker, static, or unknown.", - "example": "go", - }, - "install_command": { - "type": "string", - "description": "Command to install/build the app (empty if none needed).", - "example": "go build -o app .", - }, - "start_command": { - "type": "string", - "description": "Command to start the app. Use {{PORT}} placeholder for port.", - "example": "./app --port {{PORT}}", - }, - "health_strategy": { - "type": "string", - "description": "Health check: http_get, tcp, or process_alive.", - "example": "http_get", - }, - "health_url": { - "type": "string", - "description": "Health check URL (for http_get). Use {{PORT}} placeholder.", - "example": "http://localhost:{{PORT}}/health", - }, - "port_env_var": { - "type": "string", - "description": "Env var name for port injection (e.g., PORT). Empty if app uses command-line flag.", - "example": "PORT", - }, - "project_id": { - "type": "string", - "description": ( - "If the task instruction provided a pre-created project_id " - "(a tab already shown to the user), pass it here so the import " - "populates that tab. Omit otherwise." - ), - "example": "a1b2c3d4", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "project": {"type": "object", "description": "Project info dict."}, - }, -) -async def living_ui_import_external(input_data: dict) -> dict: - """Import an external app as a Living UI project.""" - try: - from app.living_ui import get_living_ui_manager - - manager = get_living_ui_manager() - if not manager: - return {"status": "error", "message": "Living UI manager not available."} - - result = await manager.import_external_app( - name=input_data.get("name", "External App"), - description=input_data.get("description", ""), - source_path=input_data["source_path"], - app_runtime=input_data.get("app_runtime", "unknown"), - install_command=input_data.get("install_command", ""), - start_command=input_data.get("start_command", ""), - health_strategy=input_data.get("health_strategy", "tcp"), - health_url=input_data.get("health_url", ""), - port_env_var=input_data.get("port_env_var", "PORT"), - project_id=input_data.get("project_id") or None, - ) - return result - except Exception as e: - return {"status": "error", "message": f"Import failed: {str(e)}"} - - -@action( - name="living_ui_import_zip", - description=( - "Import a Living UI project from a ZIP file. " - "The ZIP should contain a previously exported Living UI project. " - "A new project ID and ports are allocated automatically. " - "After importing, launch the project with living_ui_notify_ready." - ), - action_sets=["living_ui"], - input_schema={ - "zip_path": { - "type": "string", - "description": "Absolute path to the ZIP file.", - "example": "/path/to/project.zip", - }, - "name": { - "type": "string", - "description": "Display name for the imported project (optional, auto-detected from manifest).", - "example": "My App", - }, - "project_id": { - "type": "string", - "description": ( - "If the task instruction provided a pre-created project_id " - "(a tab already shown to the user), pass it here so the import " - "populates that tab. Omit otherwise." - ), - "example": "a1b2c3d4", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "project_id": {"type": "string", "example": "a1b2c3d4"}, - "message": {"type": "string"}, - }, -) -async def living_ui_import_zip(input_data: dict) -> dict: - """Import a Living UI project from a ZIP file.""" - try: - from app.living_ui import get_living_ui_manager - - manager = get_living_ui_manager() - if not manager: - return {"status": "error", "message": "Living UI manager not available."} - - zip_path = input_data.get("zip_path", "") - name = input_data.get("name", "") - project_id = input_data.get("project_id") or None - - if not zip_path: - return {"status": "error", "message": "zip_path is required."} - - project = await manager.import_project_zip(zip_path, name, project_id) - - # Clean up the ZIP file after successful import - import os - - try: - os.unlink(zip_path) - except Exception: - pass - - return { - "status": "success", - "project_id": project.id, - "message": f"Imported '{project.name}' ({project.id}). Call living_ui_notify_ready to launch it.", - "project": project.to_dict(), - } - except Exception as e: - return {"status": "error", "message": f"ZIP import failed: {str(e)}"} @action( name="living_ui_http", description=( - "Send an HTTP request to a running Living UI project's backend. " - "Use this to read or modify data in your Living UI (e.g., add a card to a kanban, fetch a list). " + "FALLBACK ONLY — prefer the lui CLI via run_shell " + "(node /living-ui-v2/tools/src/cli.ts ops|run|data — ABSOLUTE path; the exact commands are in the [INTERACTING WITH LIVING UI] note) to " + "operate a Living UI. Use this action only when the shell is " + "unavailable. Sends an HTTP request to a running Living UI project's " + "backend to read or modify data (e.g., add a card to a kanban, fetch a list). " "Pass the project_id and the API path (e.g., '/api/boards/2/cards'); the URL is resolved from the " "project's registered backend. This bypasses the loopback SSRF restriction safely because the " "target is a known Living UI process." @@ -889,3 +1116,247 @@ def living_ui_http(input_data: dict) -> dict: "elapsed_ms": 0, "message": str(e), } + + +@action( + name="living_ui_marketplace_list", + description=( + "List the Living UI marketplace catalogue: pre-built apps the user " + "can install by id. Use when the user asks what apps are available " + "or wants to install something by name (list first to resolve the id)." + ), + default=False, + mode="CLI", + action_sets=["living_ui"], + parallelizable=True, + input_schema={}, + output_schema={ + "status": {"type": "string", "example": "success", "description": "'success' or 'error'."}, + "apps": { + "type": "array", + "example": [{"id": "kanban-board", "name": "Kanban Board", "description": "Tasks in columns"}], + "description": "Catalogue entries (id, name, description, ...).", + }, + "message": {"type": "string", "description": "Summary line."}, + }, + test_payload={"simulated_mode": True}, +) +async def living_ui_marketplace_list(input_data: dict) -> dict: + """Fetch the marketplace catalogue (GitHub-hosted JSON).""" + if input_data.get("simulated_mode"): + return {"status": "success", "apps": [], "message": "0 apps (simulated)."} + import asyncio + import json as _json + import re as _re + import ssl + import urllib.request + + CATALOGUE_URL = ( + "https://raw.githubusercontent.com/CraftOS-dev/" + "living-ui-marketplace/main/catalogue.json" + ) + + def _fetch() -> dict: + try: + import certifi + + ctx = ssl.create_default_context(cafile=certifi.where()) + except Exception: + ctx = ssl.create_default_context() + req = urllib.request.Request(CATALOGUE_URL, headers={"User-Agent": "CraftBot"}) + with urllib.request.urlopen(req, timeout=20, context=ctx) as r: + raw = r.read().decode() + # Tolerate trailing commas in hand-edited JSON. + return _json.loads(_re.sub(r",\s*([}\]])", r"\1", raw)) + + try: + catalogue = await asyncio.get_event_loop().run_in_executor(None, _fetch) + apps = catalogue.get("apps", []) + return { + "status": "success", + "apps": apps, + "message": ( + f"{len(apps)} marketplace app(s) available. Install with " + 'living_ui_marketplace_install(app_id="").' + ), + } + except Exception as e: + return {"status": "error", "apps": [], "message": f"Could not fetch catalogue: {e}"} + + +@action( + name="living_ui_marketplace_install", + description=( + "Install a pre-built Living UI app from the marketplace by id " + "(resolve ids with living_ui_marketplace_list). Downloads the app, " + "registers it as a project, and runs the full launch pipeline. " + "Marketplace apps are pre-built — no walk-verify needed; report the " + "returned URL to the user." + ), + default=False, + mode="CLI", + action_sets=["living_ui"], + parallelizable=False, + irreversible=True, + input_schema={ + "app_id": { + "type": "string", + "example": "kanban-board", + "description": "The app id from the marketplace catalogue.", + }, + "name": { + "type": "string", + "example": "My Kanban", + "description": "Optional display name (defaults to the catalogue name/app id).", + }, + "description": { + "type": "string", + "example": "Team task board", + "description": "Optional project description.", + }, + }, + output_schema={ + "status": {"type": "string", "example": "success", "description": "'success' or 'error'."}, + "message": {"type": "string", "description": "Outcome with the app URL on success."}, + "project_id": {"type": "string", "description": "The new project id on success."}, + }, + test_payload={"app_id": "test-app", "simulated_mode": True}, +) +async def living_ui_marketplace_install(input_data: dict) -> dict: + """Download, register and launch a marketplace app.""" + app_id = (input_data.get("app_id") or "").strip() + if input_data.get("simulated_mode"): + return { + "status": "success", + "project_id": "abc12345", + "message": f"Installed '{app_id}' at http://localhost:3100 (simulated).", + } + if not app_id: + return {"status": "error", "message": "app_id is required"} + + try: + from app.living_ui import ( + broadcast_living_ui_created, + broadcast_living_ui_ready, + get_living_ui_manager, + ) + + manager = get_living_ui_manager() + if not manager: + return {"status": "error", "message": "Living UI manager not initialized."} + + result = await manager.install_from_marketplace( + app_id=app_id, + app_name=input_data.get("name") or app_id, + app_description=input_data.get("description") or "", + ) + if result.get("status") != "success": + return { + "status": "error", + "message": result.get("error") or "Installation failed.", + } + + project = result.get("project") or {} + project_id = project.get("id", "") + url = result.get("url") or project.get("url") or "" + # Surface it in the sidebar + viewport like the UI-driven install. + try: + await broadcast_living_ui_created(project) + live = manager.get_project(project_id) + if live is not None and live.port: + await broadcast_living_ui_ready(project_id, url, live.port) + except Exception: + pass + return { + "status": "success", + "project_id": project_id, + "message": ( + f"Marketplace app '{app_id}' installed and running at {url}. " + "Tell the user it is ready." + ), + } + except Exception as e: + return {"status": "error", "message": f"Install failed: {str(e)}"} + + +@action( + name="living_ui_import_zip", + description=( + "Import a Living UI V2 project from an exported ZIP file (round-trip " + "with export): registers it as a NEW project with fresh identity and " + "port, strips shipped credentials, and re-vendors the kit. The " + "project is registered STOPPED — launch it with " + "living_ui_notify_ready, then living_ui_walk_verify. Only V2 Living " + "UI exports are supported (foreign apps/repos are not)." + ), + default=False, + mode="CLI", + action_sets=["living_ui"], + parallelizable=False, + irreversible=True, + input_schema={ + "zip_path": { + "type": "string", + "example": "/Users/me/Downloads/my-app-export.zip", + "description": "Absolute path to the exported Living UI ZIP.", + }, + "name": { + "type": "string", + "example": "My Imported App", + "description": "Optional display name (defaults to the export's name).", + }, + }, + output_schema={ + "status": {"type": "string", "example": "success", "description": "'success' or 'error'."}, + "project_id": {"type": "string", "description": "The new project id."}, + "project_path": {"type": "string", "description": "Absolute project path."}, + "message": {"type": "string", "description": "Next steps."}, + }, + test_payload={"zip_path": "/tmp/test.zip", "simulated_mode": True}, +) +async def living_ui_import_zip(input_data: dict) -> dict: + """Import a V2 export ZIP as a new registered project.""" + zip_path = (input_data.get("zip_path") or "").strip() + if input_data.get("simulated_mode"): + return { + "status": "success", + "project_id": "abc12345", + "project_path": "/workspace/living_ui/imported_abc12345", + "message": "Imported (simulated).", + } + if not zip_path: + return {"status": "error", "message": "zip_path is required"} + + import os + + if not os.path.isfile(zip_path): + return {"status": "error", "message": f"File not found: {zip_path}"} + + try: + from app.living_ui import broadcast_living_ui_created, get_living_ui_manager + + manager = get_living_ui_manager() + if not manager: + return {"status": "error", "message": "Living UI manager not initialized."} + + project = await manager.import_project_zip( + zip_path, name=input_data.get("name") + ) + try: + await broadcast_living_ui_created(project.to_dict()) + except Exception: + pass + return { + "status": "success", + "project_id": project.id, + "project_path": project.path, + "message": ( + f"Imported as '{project.name}' ({project.id}) at {project.path}. " + f"Now launch it: living_ui_notify_ready(project_id=\"{project.id}\"), " + f"then living_ui_walk_verify(project_id=\"{project.id}\")." + ), + } + except ValueError as e: + return {"status": "error", "message": str(e)} + except Exception as e: + return {"status": "error", "message": f"Import failed: {str(e)}"} diff --git a/app/data/action/send_message.py b/app/data/action/send_message.py index c79bddc9..e1dacb6c 100644 --- a/app/data/action/send_message.py +++ b/app/data/action/send_message.py @@ -67,17 +67,6 @@ async def send_message(input_data: dict) -> dict: message, session_id=session_id ) - # Mirror a final question onto the Living UI creation screen (no-op - # unless this session belongs to a Living UI project) so the user can - # answer from the Living UI page even with the chat panel closed. - if not continue_work and session_id: - try: - from app.living_ui import broadcast_living_ui_question - - await broadcast_living_ui_question(session_id, message) - except Exception: - pass - # Return 'success' for test compatibility, but keep 'ok' in production if needed status = "success" if simulated_mode else "ok" return { diff --git a/app/data/action/update_todos.py b/app/data/action/update_todos.py index 7d2bfd12..ad1ae5d3 100644 --- a/app/data/action/update_todos.py +++ b/app/data/action/update_todos.py @@ -22,16 +22,21 @@ input_schema={ "todos": { "type": "array", - "description": 'Array of todo objects. Each object MUST have exactly 2 keys: \'content\' (string: the task text) and \'status\' (string: \'pending\'|\'in_progress\'|\'completed\'). Example: [{"content": "Do X", "status": "completed"}, {"content": "Do Y", "status": "in_progress"}]', + "description": 'Array of todo objects — this payload REPLACES the whole list, so ALWAYS send the complete list (every item you want to keep, not just changes). Each object MUST have exactly 2 keys: \'content\' (string: the task text) and \'status\' (string: \'pending\'|\'in_progress\'|\'completed\'). Example: [{"content": "Do X", "status": "completed"}, {"content": "Do Y", "status": "in_progress"}]', "required": True, - } + }, }, output_schema={ "status": { "type": "string", "example": "success", "description": "Indicates if the update was successful", - } + }, + "message": { + "type": "string", + "example": "List now has 7 todos (3 completed, 1 in progress, 3 pending).", + "description": "Summary of the FULL merged list after this update.", + }, }, test_payload={ "todos": [ @@ -62,6 +67,20 @@ def update_todos(input_data: dict) -> dict: todos, session_id=input_data.get("_session_id") ) status = "success" if result.get("status") in ("ok", "success") else "error" - return {"status": status} + # Echo the resulting list state — the payload replaces the whole list, + # so this is the model's (and the activity feed's) immediate feedback + # on what the list actually became after this call. + updated = result.get("todos", []) or [] + counts = {"completed": 0, "in_progress": 0, "pending": 0} + for t in updated: + key = t.get("status", "pending") + counts[key] = counts.get(key, 0) + 1 + return { + "status": status, + "message": ( + f"List now has {len(updated)} todos ({counts['completed']} completed, " + f"{counts['in_progress']} in progress, {counts['pending']} pending)." + ), + } return {"status": "success"} diff --git a/app/data/action/web_fetch.py b/app/data/action/web_fetch.py index cd418e06..554ffd81 100644 --- a/app/data/action/web_fetch.py +++ b/app/data/action/web_fetch.py @@ -99,6 +99,7 @@ def web_fetch(input_data: dict) -> dict: import tempfile from urllib.parse import urlparse from datetime import datetime, timezone + from app.errors import make_error as catalog_make_error # --- Helper functions (must be inside for sandboxed execution) --- @@ -425,9 +426,11 @@ def save_content_file(content, file_url, sess_id): error_type = type(e).__name__ if "Timeout" in error_type: - msg = f"Request timed out after {timeout} seconds." + msg = catalog_make_error("CONNECTION_TIMEOUT", target=url).message elif "ConnectionError" in error_type: - msg = f"Connection error: {str(e)}" + msg = catalog_make_error( + "CONNECTION_FAILED", target=url, detail=str(e) + ).message elif "HTTPError" in error_type: msg = f"HTTP error: {str(e)}" else: diff --git a/app/data/agent_file_system_template/AGENT.md b/app/data/agent_file_system_template/AGENT.md index abced1e0..8b2edca9 100644 --- a/app/data/agent_file_system_template/AGENT.md +++ b/app/data/agent_file_system_template/AGENT.md @@ -462,10 +462,11 @@ The harness already handles certain failures so you do not have to. Recognizing - Recovery: the timeout is final for that invocation. Either retry with smaller scope (fewer rows, narrower regex, smaller batch) or split the work into multiple actions. **LLM consecutive-failure circuit breaker** ([agent_core/core/impl/llm/errors.py](agent_core/core/impl/llm/errors.py), [agent_core/core/impl/llm/interface.py](agent_core/core/impl/llm/interface.py)) -- After repeated consecutive LLM failures (auth, network, etc.), the harness raises `LLMConsecutiveFailureError`. -- `_handle_react_error` walks the exception chain (`__cause__`/`__context__`) to detect this and **automatically cancels the task** via `task_manager.mark_task_cancel(...)`. The agent's last instruction is cached in `_llm_retry_instructions[session_id]` for retry-after-fix. -- A `LLM_FATAL_ERROR` UI event is emitted so the user sees a clear failure dialog. -- **Implication:** if you see `MSG_CONSECUTIVE_FAILURE` ("LLM calls have failed N consecutive times. Task aborted to prevent infinite retries."), the task is already gone. Do NOT try to re-create it. The user must check their LLM configuration. +- Non-transient categories (auth, credit, quota, model, blocked, bad request) raise `LLMConsecutiveFailureError` immediately on the first failure — retrying the same request can't fix them. Transient categories (rate-limit, server, connection, unclassified) get a 5-attempt retry budget before the same error is raised. +- `_handle_react_error` walks the exception chain (`__cause__`/`__context__`) to detect this and **halts the run** (`_emit_run_state(session_id, False)`) rather than cancelling the task outright. +- Presentation splits into two tiers: a recognized/classified failure (bad key, no credits, misconfigured provider — anything carrying an `ErrorInfo`, via `LLMConsecutiveFailureError.last_error_info` or a `ClassifiedError`) shows as a short, calm "system"-style message; anything unclassified shows as a red "error" message with full detail — see [agent_core/core/errors.py](agent_core/core/errors.py). +- There is no Retry/Change Model button — the user resumes by sending a normal chat message (e.g. "continue"). `_handle_chat_message` resets the failure counter on any new message, so this just works. +- **Implication:** if you see `MSG_CONSECUTIVE_FAILURE`/`MSG_FAILED_IMMEDIATELY`, the run has halted and is waiting on the user's next message. Do NOT try to keep working. **Action limit (`max_actions_per_task`, minimum 5)** ([agent_core/core/state/types.py](agent_core/core/state/types.py)) - Tracked in `STATE.get_agent_property("action_count")` against `max_actions_per_task`. @@ -1404,7 +1405,7 @@ clipboard clipboard_read, clipboard_write comms send_message_with_attachment -living_ui living_ui_http, living_ui_import_external, living_ui_import_zip, +- Importing external apps/ZIPs is temporarily unavailable (V1 import removed; V2 import workflow pending). living_ui_notify_ready, living_ui_report_progress, living_ui_restart per-platform integrations Discord, Slack, Telegram, Notion, LinkedIn, Jira, GitHub, diff --git a/app/data/living_ui_modules/auth/AuthService.ts b/app/data/living_ui_modules/auth/AuthService.ts deleted file mode 100644 index 7d8ca015..00000000 --- a/app/data/living_ui_modules/auth/AuthService.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Auth Service — handles login, registration, token storage, and authenticated requests. - * - * Copy this file into your project's frontend/services/ directory. - * - * Usage: - * import { authService } from './services/AuthService' - * await authService.login('email@example.com', 'password') - * const user = await authService.getMe() - * authService.logout() - */ - -import type { AuthUser, LoginResponse, MembershipInfo, InviteInfo } from '../auth_types' - -const TOKEN_KEY = 'auth_token' - -class AuthService { - private backendUrl: string - - constructor() { - this.backendUrl = (window as any).__CRAFTBOT_BACKEND_URL__ || 'http://localhost:3101' - } - - getToken(): string | null { - return localStorage.getItem(TOKEN_KEY) - } - - private setToken(token: string): void { - localStorage.setItem(TOKEN_KEY, token) - } - - private clearToken(): void { - localStorage.removeItem(TOKEN_KEY) - } - - isAuthenticated(): boolean { - return !!this.getToken() - } - - /** - * Make an authenticated fetch request. Automatically adds the Bearer token. - */ - async authFetch(url: string, options: RequestInit = {}): Promise { - const token = this.getToken() - const headers: Record = { - 'Content-Type': 'application/json', - ...(options.headers as Record || {}), - } - if (token) { - headers['Authorization'] = `Bearer ${token}` - } - return fetch(url, { ...options, headers }) - } - - async register(email: string, username: string, password: string): Promise { - const resp = await fetch(`${this.backendUrl}/api/auth/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, username, password }), - }) - if (!resp.ok) { - const err = await resp.json().catch(() => ({ detail: 'Registration failed' })) - throw new Error(err.detail || 'Registration failed') - } - const data: LoginResponse = await resp.json() - this.setToken(data.token) - return data - } - - async login(email: string, password: string): Promise { - const resp = await fetch(`${this.backendUrl}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), - }) - if (!resp.ok) { - const err = await resp.json().catch(() => ({ detail: 'Login failed' })) - throw new Error(err.detail || 'Invalid email or password') - } - const data: LoginResponse = await resp.json() - this.setToken(data.token) - return data - } - - async getMe(): Promise { - const token = this.getToken() - if (!token) return null - try { - const resp = await this.authFetch(`${this.backendUrl}/api/auth/me`) - if (!resp.ok) { - this.clearToken() - return null - } - const data = await resp.json() - return data.user - } catch { - this.clearToken() - return null - } - } - - logout(): void { - this.clearToken() - } - - // ── Profile ────────────────────────────────────────────────── - - async updateProfile(updates: { username?: string; email?: string }): Promise { - const resp = await this.authFetch(`${this.backendUrl}/api/auth/me`, { - method: 'PUT', - body: JSON.stringify(updates), - }) - if (!resp.ok) { - const err = await resp.json().catch(() => ({ detail: 'Update failed' })) - throw new Error(err.detail || 'Update failed') - } - return (await resp.json()).user - } - - async changePassword(currentPassword: string, newPassword: string): Promise { - const resp = await this.authFetch(`${this.backendUrl}/api/auth/me/password`, { - method: 'PUT', - body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), - }) - if (!resp.ok) { - const err = await resp.json().catch(() => ({ detail: 'Password change failed' })) - throw new Error(err.detail || 'Password change failed') - } - } - - // ── Membership ─────────────────────────────────────────────── - - async getMembers(resourceType: string, resourceId: number): Promise { - const resp = await this.authFetch(`${this.backendUrl}/api/auth/members/${resourceType}/${resourceId}`) - if (!resp.ok) return [] - return (await resp.json()).members || [] - } - - async addMember(resourceType: string, resourceId: number, userId: number, role = 'member'): Promise { - const resp = await this.authFetch(`${this.backendUrl}/api/auth/members/${resourceType}/${resourceId}`, { - method: 'POST', - body: JSON.stringify({ user_id: userId, role }), - }) - if (!resp.ok) { - const err = await resp.json().catch(() => ({ detail: 'Failed to add member' })) - throw new Error(err.detail || 'Failed to add member') - } - return (await resp.json()).membership - } - - async removeMember(resourceType: string, resourceId: number, userId: number): Promise { - const resp = await this.authFetch(`${this.backendUrl}/api/auth/members/${resourceType}/${resourceId}/${userId}`, { - method: 'DELETE', - }) - if (!resp.ok) { - const err = await resp.json().catch(() => ({ detail: 'Failed to remove member' })) - throw new Error(err.detail || 'Failed to remove member') - } - } - - // ── Invites ────────────────────────────────────────────────── - - async createInvite(resourceType: string, resourceId: number, defaultRole = 'member', maxUses?: number): Promise { - const resp = await this.authFetch(`${this.backendUrl}/api/auth/invites`, { - method: 'POST', - body: JSON.stringify({ resource_type: resourceType, resource_id: resourceId, default_role: defaultRole, max_uses: maxUses }), - }) - if (!resp.ok) { - const err = await resp.json().catch(() => ({ detail: 'Failed to create invite' })) - throw new Error(err.detail || 'Failed to create invite') - } - return (await resp.json()).invite - } - - async acceptInvite(code: string): Promise { - const resp = await this.authFetch(`${this.backendUrl}/api/auth/invites/${code}/accept`, { - method: 'POST', - }) - if (!resp.ok) { - const err = await resp.json().catch(() => ({ detail: 'Failed to accept invite' })) - throw new Error(err.detail || 'Failed to accept invite') - } - return (await resp.json()).membership - } -} - -export const authService = new AuthService() diff --git a/app/data/living_ui_modules/auth/README.md b/app/data/living_ui_modules/auth/README.md deleted file mode 100644 index 8a77482b..00000000 --- a/app/data/living_ui_modules/auth/README.md +++ /dev/null @@ -1,230 +0,0 @@ -# Auth Module — Multi-User Support for Living UI - -Self-contained authentication with SQLite + bcrypt + JWT. No external services needed. - -## Features -- User registration and login (email + password) -- First user automatically becomes admin -- JWT token auth (24h expiry, stored in localStorage) -- Role-based access (admin, member) -- Pre-built React components (LoginPage, RegisterPage, UserMenu) - -## Integration Steps - -### Backend - -1. Copy these files into `backend/`: - - `auth_models.py` — User model - - `auth_service.py` — password hashing + JWT - - `auth_middleware.py` — FastAPI dependencies (get_current_user, require_admin) - - `auth_routes.py` — /auth/register, /auth/login, /auth/me, /auth/users - -2. Append to `backend/requirements.txt`: - ``` - bcrypt>=4.0.0 - PyJWT>=2.8.0 - ``` - -3. In `backend/routes.py`, import and include the auth router: - ```python - from auth_routes import router as auth_router - router.include_router(auth_router) - ``` - -4. Import `User` in `models.py` so the table is created: - ```python - from auth_models import User # noqa: F401 - ``` - -5. Add `user_id` to your data models: - ```python - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - ``` - -6. Protect routes with auth dependency: - ```python - from auth_middleware import get_current_user - - @router.get("/my-items") - def get_my_items(user = Depends(get_current_user), db = Depends(get_db)): - return db.query(Item).filter(Item.user_id == user.id).all() - ``` - -### Frontend - -1. Copy `auth_types.ts` into `frontend/` -2. Copy `AuthService.ts` into `frontend/services/` -3. Copy `AuthProvider.tsx`, `LoginPage.tsx`, `RegisterPage.tsx`, `UserMenu.tsx` into `frontend/components/auth/` - -4. Wrap your app in AuthProvider (in App.tsx): - ```tsx - import { AuthProvider, useAuth } from './components/auth/AuthProvider' - import { LoginPage } from './components/auth/LoginPage' - import { RegisterPage } from './components/auth/RegisterPage' - - function App() { - return ( - - - - ) - } - - function AuthGate() { - const { isAuthenticated, loading } = useAuth() - const [page, setPage] = useState<'login' | 'register'>('login') - - if (loading) return
Loading...
- if (!isAuthenticated) { - return page === 'login' - ? setPage('register')} /> - : setPage('login')} /> - } - return - } - ``` - -5. Add UserMenu to your header: - ```tsx - import { UserMenu } from './components/auth/UserMenu' - -
-

My App

- -
- ``` - -6. Use `authService.authFetch()` instead of `fetch()` for authenticated API calls: - ```typescript - import { authService } from './services/AuthService' - const resp = await authService.authFetch(`${BACKEND_URL}/api/my-items`) - ``` - -### Tests - -Copy `tests/test_auth.py` into `backend/tests/`. Run: -``` -cd backend && python -m pytest tests/test_auth.py -v -``` - -## Membership — Connecting Users to Resources - -The auth module includes a generic **Membership** system for linking users to app resources -(projects, boards, teams, etc.) and an **Invite** system for shareable join links. - -### How it works - -When a user creates a resource (e.g., a project), also create a Membership: -```python -from auth_models import Membership - -@router.post("/projects") -def create_project(data: ..., user = Depends(get_current_user), db = Depends(get_db)): - project = Project(name=data.name, created_by=user.id) - db.add(project) - db.flush() # Get project.id - - # Make creator the owner - membership = Membership(user_id=user.id, resource_type="project", - resource_id=project.id, role="owner") - db.add(membership) - db.commit() - return project.to_dict() -``` - -### Filtering by membership - -Only show resources the user is a member of: -```python -@router.get("/projects") -def get_my_projects(user = Depends(get_current_user), db = Depends(get_db)): - project_ids = [m.resource_id for m in db.query(Membership).filter_by( - user_id=user.id, resource_type="project" - ).all()] - return db.query(Project).filter(Project.id.in_(project_ids)).all() -``` - -### Protecting routes by membership - -Use `require_membership` to ensure the user belongs to the resource: -```python -from auth_middleware import require_membership - -@router.get("/projects/{project_id}/tasks") -def get_tasks(project_id: int, - member = Depends(require_membership("project")), - db = Depends(get_db)): - # Only runs if user is a member of this project - return db.query(Task).filter_by(project_id=project_id).all() -``` - -### Invite links - -Users can generate invite codes to share: -``` -POST /api/auth/invites → creates invite code for a resource -POST /api/auth/invites/{code}/accept → joins the resource -``` - -## Frontend Components for Membership - -### MemberList — show who's in a resource - -```tsx -import { MemberList } from './components/auth/MemberList' - -// In your project settings or sidebar: - -``` - -### InviteModal — create & accept invite codes - -```tsx -import { InviteModal } from './components/auth/InviteModal' - - setShowInvite(false)} -/> -``` - -The modal has two sections: -- **Create invite** — generates a code the owner can share -- **Join with code** — paste an invite code to join - -### ProfilePage — edit account & change password - -```tsx -import { ProfilePage } from './components/auth/ProfilePage' - -// As a page or modal content: -{showProfile && setShowProfile(false)} />} -``` - -### UserMenu — already includes link to profile - -The `UserMenu` component shows the user dropdown with sign-out. The agent should add -a "Profile" option that opens `ProfilePage`. - -## API Endpoints - -| Method | Path | Auth | Description | -|--------|------|------|-------------| -| POST | /api/auth/register | No | Create account (first user = admin) | -| POST | /api/auth/login | No | Login, returns JWT | -| GET | /api/auth/me | Yes | Get current user | -| PUT | /api/auth/me | Yes | Update profile (username, email) | -| PUT | /api/auth/me/password | Yes | Change password | -| POST | /api/auth/logout | No | Client-side logout | -| GET | /api/auth/users | Admin | List all users | -| GET | /api/auth/members/{type}/{id} | Member | List members of a resource | -| POST | /api/auth/members/{type}/{id} | Owner | Add a member to a resource | -| DELETE | /api/auth/members/{type}/{id}/{uid} | Owner | Remove a member | -| POST | /api/auth/invites | Owner | Create an invite link | -| POST | /api/auth/invites/{code}/accept | Yes | Accept invite and join | diff --git a/app/data/living_ui_modules/auth/auth_types.ts b/app/data/living_ui_modules/auth/auth_types.ts deleted file mode 100644 index 42ad071b..00000000 --- a/app/data/living_ui_modules/auth/auth_types.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Auth TypeScript interfaces. - * - * Copy this file into your project's frontend/ directory. - */ - -export interface AuthUser { - id: number - email: string - username: string - role: 'admin' | 'member' - isActive: boolean - createdAt: string -} - -export interface AuthState { - user: AuthUser | null - token: string | null - isAuthenticated: boolean - loading: boolean -} - -export interface LoginResponse { - user: AuthUser - token: string -} - -export interface MembershipInfo { - id: number - userId: number - resourceType: string - resourceId: number - role: string - joinedAt: string - user: AuthUser | null -} - -export interface InviteInfo { - id: number - code: string - resourceType: string - resourceId: number - defaultRole: string - isActive: boolean - maxUses: number | null - useCount: number - createdAt: string -} diff --git a/app/data/living_ui_modules/auth/backend/auth_middleware.py b/app/data/living_ui_modules/auth/backend/auth_middleware.py deleted file mode 100644 index fbaa7d82..00000000 --- a/app/data/living_ui_modules/auth/backend/auth_middleware.py +++ /dev/null @@ -1,125 +0,0 @@ -""" -Auth Middleware — FastAPI dependencies for protecting routes. - -Copy this file into your project's backend/ directory. - -Usage in routes: - from auth_middleware import get_current_user, require_admin - - @router.get("/my-items") - def get_my_items(user: User = Depends(get_current_user), db: Session = Depends(get_db)): - return db.query(Item).filter(Item.user_id == user.id).all() - - @router.get("/admin/users") - def list_users(user: User = Depends(require_admin), db: Session = Depends(get_db)): - return [u.to_dict() for u in db.query(User).all()] -""" - -from fastapi import Depends, Header, HTTPException -from sqlalchemy.orm import Session - -from auth_models import User, Membership -from auth_service import verify_token -from database import get_db - - -def get_current_user( - authorization: str = Header(None), - db: Session = Depends(get_db), -) -> User: - """FastAPI dependency that extracts and validates the Bearer token.""" - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException(status_code=401, detail="Not authenticated") - - token = authorization.split(" ", 1)[1] - try: - payload = verify_token(token) - except Exception: - raise HTTPException(status_code=401, detail="Invalid or expired token") - - user_id = int(payload.get("sub", 0)) - user = db.query(User).filter(User.id == user_id, User.is_active.is_(True)).first() - if not user: - raise HTTPException(status_code=401, detail="User not found") - - return user - - -def require_admin(user: User = Depends(get_current_user)) -> User: - """FastAPI dependency that requires the current user to be an admin.""" - if user.role != "admin": - raise HTTPException(status_code=403, detail="Admin access required") - return user - - -def require_membership(resource_type: str): - """ - Factory that returns a FastAPI dependency requiring membership in a resource. - - The route must have a path parameter matching the resource_id. - - Usage: - @router.get("/projects/{project_id}/tasks") - def get_tasks( - project_id: int, - user: User = Depends(get_current_user), - member: Membership = Depends(require_membership("project")), - db: Session = Depends(get_db), - ): - return db.query(Task).filter_by(project_id=project_id).all() - """ - from fastapi import Request - - def dependency( - request: Request, - user: User = Depends(get_current_user), - db: Session = Depends(get_db), - ) -> Membership: - # Extract resource_id from path params — try common patterns - resource_id = ( - request.path_params.get(f"{resource_type}_id") - or request.path_params.get("resource_id") - or request.path_params.get("id") - ) - if not resource_id: - raise HTTPException( - status_code=400, detail=f"Missing {resource_type}_id in path" - ) - - # Global admins bypass membership check - if user.role == "admin": - membership = ( - db.query(Membership) - .filter_by( - user_id=user.id, - resource_type=resource_type, - resource_id=int(resource_id), - ) - .first() - ) - if membership: - return membership - # Admin without membership — create a synthetic one for compatibility - return Membership( - user_id=user.id, - resource_type=resource_type, - resource_id=int(resource_id), - role="admin", - ) - - membership = ( - db.query(Membership) - .filter_by( - user_id=user.id, - resource_type=resource_type, - resource_id=int(resource_id), - ) - .first() - ) - if not membership: - raise HTTPException( - status_code=403, detail=f"Not a member of this {resource_type}" - ) - return membership - - return dependency diff --git a/app/data/living_ui_modules/auth/backend/auth_models.py b/app/data/living_ui_modules/auth/backend/auth_models.py deleted file mode 100644 index 40a6c897..00000000 --- a/app/data/living_ui_modules/auth/backend/auth_models.py +++ /dev/null @@ -1,164 +0,0 @@ -""" -Auth Models — User accounts and resource membership for multi-user Living UI apps. - -Copy this file into your project's backend/ directory. -Import in your models.py: - from auth_models import User, Membership # noqa: F401 -""" - -import secrets -from datetime import datetime -from sqlalchemy import ( - Column, - Integer, - String, - Boolean, - DateTime, - ForeignKey, - UniqueConstraint, -) -from sqlalchemy.orm import relationship -from models import Base - - -class User(Base): - __tablename__ = "users" - - id = Column(Integer, primary_key=True) - email = Column(String(255), unique=True, nullable=False, index=True) - username = Column(String(100), unique=True, nullable=False) - password_hash = Column(String(255), nullable=False) - role = Column(String(50), default="member") # "admin" or "member" - is_active = Column(Boolean, default=True) - created_at = Column(DateTime, default=datetime.utcnow) - - memberships = relationship( - "Membership", back_populates="user", cascade="all, delete-orphan" - ) - - def to_dict(self): - return { - "id": self.id, - "email": self.email, - "username": self.username, - "role": self.role, - "isActive": self.is_active, - "createdAt": self.created_at.isoformat() if self.created_at else None, - } - - -class Membership(Base): - """ - Generic membership — links a user to any app resource (project, board, team, etc.). - - Usage: - # Add user to a project as editor - m = Membership(user_id=1, resource_type="project", resource_id=5, role="editor") - db.add(m) - - # Get all members of a project - members = db.query(Membership).filter_by(resource_type="project", resource_id=5).all() - - # Get all projects a user belongs to - project_ids = db.query(Membership.resource_id).filter_by( - user_id=1, resource_type="project" - ).all() - - # Check if user is a member - is_member = db.query(Membership).filter_by( - user_id=1, resource_type="project", resource_id=5 - ).first() is not None - """ - - __tablename__ = "memberships" - __table_args__ = ( - UniqueConstraint( - "user_id", "resource_type", "resource_id", name="uq_membership" - ), - ) - - id = Column(Integer, primary_key=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) - resource_type = Column( - String(50), nullable=False - ) # "project", "board", "team", etc. - resource_id = Column(Integer, nullable=False, index=True) - role = Column( - String(50), default="member" - ) # "owner", "admin", "editor", "viewer", "member" - invite_code = Column(String(64), nullable=True) # For pending invites - joined_at = Column(DateTime, default=datetime.utcnow) - - user = relationship("User", back_populates="memberships") - - def to_dict(self): - return { - "id": self.id, - "userId": self.user_id, - "resourceType": self.resource_type, - "resourceId": self.resource_id, - "role": self.role, - "joinedAt": self.joined_at.isoformat() if self.joined_at else None, - "user": self.user.to_dict() if self.user else None, - } - - -class Invite(Base): - """ - Invite links — generate a code that anyone can use to join a resource. - - Usage: - # Create invite link for a project - invite = Invite.create(resource_type="project", resource_id=5, created_by=1) - db.add(invite) - # Share the code: invite.code - - # Accept invite - invite = db.query(Invite).filter_by(code="abc123", is_active=True).first() - membership = Membership(user_id=2, resource_type=invite.resource_type, - resource_id=invite.resource_id, role=invite.default_role) - """ - - __tablename__ = "invites" - - id = Column(Integer, primary_key=True) - code = Column(String(64), unique=True, nullable=False, index=True) - resource_type = Column(String(50), nullable=False) - resource_id = Column(Integer, nullable=False) - default_role = Column(String(50), default="member") - created_by = Column(Integer, ForeignKey("users.id"), nullable=False) - is_active = Column(Boolean, default=True) - max_uses = Column(Integer, nullable=True) # None = unlimited - use_count = Column(Integer, default=0) - created_at = Column(DateTime, default=datetime.utcnow) - - @classmethod - def create( - cls, - resource_type: str, - resource_id: int, - created_by: int, - default_role: str = "member", - max_uses: int = None, - ): - return cls( - code=secrets.token_urlsafe(16), - resource_type=resource_type, - resource_id=resource_id, - created_by=created_by, - default_role=default_role, - max_uses=max_uses, - ) - - def to_dict(self): - return { - "id": self.id, - "code": self.code, - "resourceType": self.resource_type, - "resourceId": self.resource_id, - "defaultRole": self.default_role, - "isActive": self.is_active, - "maxUses": self.max_uses, - "useCount": self.use_count, - "createdAt": self.created_at.isoformat() if self.created_at else None, - } diff --git a/app/data/living_ui_modules/auth/backend/auth_routes.py b/app/data/living_ui_modules/auth/backend/auth_routes.py deleted file mode 100644 index ba8e8b81..00000000 --- a/app/data/living_ui_modules/auth/backend/auth_routes.py +++ /dev/null @@ -1,344 +0,0 @@ -""" -Auth Routes — registration, login, user management endpoints. - -Copy this file into your project's backend/ directory. -Then import and include the router in routes.py: - - from auth_routes import router as auth_router - # ... at the bottom of routes.py: - router.include_router(auth_router) -""" - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel -from sqlalchemy.orm import Session - -from auth_models import User, Membership, Invite -from auth_middleware import get_current_user, require_admin -from auth_service import hash_password, verify_password, create_token -from database import get_db - -router = APIRouter(prefix="/auth", tags=["auth"]) - - -class RegisterRequest(BaseModel): - email: str - username: str - password: str - - -class LoginRequest(BaseModel): - email: str - password: str - - -@router.post("/register") -def register(data: RegisterRequest, db: Session = Depends(get_db)): - """Register a new user. First user automatically becomes admin.""" - # Check for existing user - if db.query(User).filter(User.email == data.email).first(): - raise HTTPException(status_code=400, detail="Email already registered") - if db.query(User).filter(User.username == data.username).first(): - raise HTTPException(status_code=400, detail="Username already taken") - - # First user is admin - is_first_user = db.query(User).count() == 0 - role = "admin" if is_first_user else "member" - - user = User( - email=data.email, - username=data.username, - password_hash=hash_password(data.password), - role=role, - ) - db.add(user) - db.commit() - db.refresh(user) - - token = create_token(user.id) - return {"user": user.to_dict(), "token": token} - - -@router.post("/login") -def login(data: LoginRequest, db: Session = Depends(get_db)): - """Login with email and password.""" - user = db.query(User).filter(User.email == data.email).first() - if not user or not verify_password(data.password, user.password_hash): - raise HTTPException(status_code=401, detail="Invalid email or password") - if not user.is_active: - raise HTTPException(status_code=403, detail="Account is deactivated") - - token = create_token(user.id) - return {"user": user.to_dict(), "token": token} - - -@router.get("/me") -def get_me(user: User = Depends(get_current_user)): - """Get the current authenticated user.""" - return {"user": user.to_dict()} - - -@router.post("/logout") -def logout(): - """Logout — client should delete the stored token.""" - return {"message": "Logged out"} - - -@router.get("/users") -def list_users( - user: User = Depends(require_admin), - db: Session = Depends(get_db), -): - """List all users (admin only).""" - users = db.query(User).order_by(User.created_at.desc()).all() - return {"users": [u.to_dict() for u in users]} - - -# ============================================================================ -# Profile — update own account -# ============================================================================ - - -class UpdateProfileRequest(BaseModel): - username: str = None - email: str = None - - -@router.put("/me") -def update_profile( - data: UpdateProfileRequest, - user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Update current user's profile.""" - if data.email and data.email != user.email: - if db.query(User).filter(User.email == data.email, User.id != user.id).first(): - raise HTTPException(status_code=400, detail="Email already in use") - user.email = data.email - if data.username and data.username != user.username: - if ( - db.query(User) - .filter(User.username == data.username, User.id != user.id) - .first() - ): - raise HTTPException(status_code=400, detail="Username already taken") - user.username = data.username - db.commit() - db.refresh(user) - return {"user": user.to_dict()} - - -class ChangePasswordRequest(BaseModel): - current_password: str - new_password: str - - -@router.put("/me/password") -def change_password( - data: ChangePasswordRequest, - user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Change current user's password.""" - if not verify_password(data.current_password, user.password_hash): - raise HTTPException(status_code=400, detail="Current password is incorrect") - if len(data.new_password) < 6: - raise HTTPException( - status_code=400, detail="Password must be at least 6 characters" - ) - user.password_hash = hash_password(data.new_password) - db.commit() - return {"message": "Password updated"} - - -# ============================================================================ -# Membership — link users to resources (projects, boards, teams, etc.) -# ============================================================================ - - -def _check_membership( - db: Session, - user: User, - resource_type: str, - resource_id: int, - required_roles: tuple = None, -) -> None: - """Verify user has access to a resource. Raises 403 if not. - - Args: - required_roles: If set, user must have one of these roles (e.g., ("owner", "admin")). - If None, any membership is sufficient. - """ - if user.role == "admin": - return # Global admins bypass all checks - membership = ( - db.query(Membership) - .filter_by( - user_id=user.id, resource_type=resource_type, resource_id=resource_id - ) - .first() - ) - if not membership: - raise HTTPException(status_code=403, detail="Not a member of this resource") - if required_roles and membership.role not in required_roles: - raise HTTPException( - status_code=403, detail=f"Requires role: {' or '.join(required_roles)}" - ) - - -@router.get("/members/{resource_type}/{resource_id}") -def get_members( - resource_type: str, - resource_id: int, - user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Get all members of a resource. Caller must be a member.""" - _check_membership(db, user, resource_type, resource_id) - members = ( - db.query(Membership) - .filter_by(resource_type=resource_type, resource_id=resource_id) - .all() - ) - return {"members": [m.to_dict() for m in members]} - - -class AddMemberRequest(BaseModel): - user_id: int - role: str = "member" - - -@router.post("/members/{resource_type}/{resource_id}") -def add_member( - resource_type: str, - resource_id: int, - data: AddMemberRequest, - user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Add a user to a resource. Caller must be owner/admin of the resource.""" - _check_membership(db, user, resource_type, resource_id, ("owner", "admin")) - - existing = ( - db.query(Membership) - .filter_by( - user_id=data.user_id, resource_type=resource_type, resource_id=resource_id - ) - .first() - ) - if existing: - raise HTTPException(status_code=400, detail="User is already a member") - - membership = Membership( - user_id=data.user_id, - resource_type=resource_type, - resource_id=resource_id, - role=data.role, - ) - db.add(membership) - db.commit() - db.refresh(membership) - return {"membership": membership.to_dict()} - - -@router.delete("/members/{resource_type}/{resource_id}/{user_id}") -def remove_member( - resource_type: str, - resource_id: int, - user_id: int, - user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Remove a user from a resource. Caller must be owner/admin or removing themselves.""" - if user.id != user_id: - _check_membership(db, user, resource_type, resource_id, ("owner", "admin")) - - membership = ( - db.query(Membership) - .filter_by( - user_id=user_id, resource_type=resource_type, resource_id=resource_id - ) - .first() - ) - if not membership: - raise HTTPException(status_code=404, detail="Membership not found") - - db.delete(membership) - db.commit() - return {"message": "Member removed"} - - -# ============================================================================ -# Invites — shareable links to join a resource -# ============================================================================ - - -class CreateInviteRequest(BaseModel): - resource_type: str - resource_id: int - default_role: str = "member" - max_uses: int = None - - -@router.post("/invites") -def create_invite( - data: CreateInviteRequest, - user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Create an invite link for a resource. Caller must be owner/admin.""" - _check_membership( - db, user, data.resource_type, data.resource_id, ("owner", "admin") - ) - - invite = Invite.create( - resource_type=data.resource_type, - resource_id=data.resource_id, - created_by=user.id, - default_role=data.default_role, - max_uses=data.max_uses, - ) - db.add(invite) - db.commit() - db.refresh(invite) - return {"invite": invite.to_dict()} - - -@router.post("/invites/{code}/accept") -def accept_invite( - code: str, - user: User = Depends(get_current_user), - db: Session = Depends(get_db), -): - """Accept an invite and join the resource.""" - invite = db.query(Invite).filter_by(code=code, is_active=True).first() - if not invite: - raise HTTPException(status_code=404, detail="Invite not found or expired") - - if invite.max_uses and invite.use_count >= invite.max_uses: - raise HTTPException(status_code=410, detail="Invite has reached maximum uses") - - # Check if already a member - existing = ( - db.query(Membership) - .filter_by( - user_id=user.id, - resource_type=invite.resource_type, - resource_id=invite.resource_id, - ) - .first() - ) - if existing: - return {"membership": existing.to_dict(), "message": "Already a member"} - - membership = Membership( - user_id=user.id, - resource_type=invite.resource_type, - resource_id=invite.resource_id, - role=invite.default_role, - ) - invite.use_count += 1 - db.add(membership) - db.commit() - db.refresh(membership) - return {"membership": membership.to_dict()} diff --git a/app/data/living_ui_modules/auth/backend/auth_service.py b/app/data/living_ui_modules/auth/backend/auth_service.py deleted file mode 100644 index a6639737..00000000 --- a/app/data/living_ui_modules/auth/backend/auth_service.py +++ /dev/null @@ -1,53 +0,0 @@ -""" -Auth Service — password hashing and JWT token management. - -Copy this file into your project's backend/ directory. -""" - -import secrets -from datetime import datetime, timedelta -from pathlib import Path - -import bcrypt -import jwt - -# JWT secret stored in a file so it survives restarts but isn't committed -_SECRET_PATH = Path(__file__).parent / ".jwt_secret" -_JWT_ALGORITHM = "HS256" -_TOKEN_EXPIRY_HOURS = 24 - - -def get_or_create_secret() -> str: - """Read JWT secret from file, or generate and save a new one.""" - if _SECRET_PATH.exists(): - return _SECRET_PATH.read_text(encoding="utf-8").strip() - secret = secrets.token_hex(32) - _SECRET_PATH.write_text(secret, encoding="utf-8") - return secret - - -def hash_password(password: str) -> str: - """Hash a password with bcrypt.""" - return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") - - -def verify_password(password: str, password_hash: str) -> bool: - """Verify a password against a bcrypt hash.""" - return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8")) - - -def create_token(user_id: int, expires_hours: int = _TOKEN_EXPIRY_HOURS) -> str: - """Create a JWT token for a user.""" - secret = get_or_create_secret() - payload = { - "sub": str(user_id), - "exp": datetime.utcnow() + timedelta(hours=expires_hours), - "iat": datetime.utcnow(), - } - return jwt.encode(payload, secret, algorithm=_JWT_ALGORITHM) - - -def verify_token(token: str) -> dict: - """Verify a JWT token. Returns the payload or raises jwt.InvalidTokenError.""" - secret = get_or_create_secret() - return jwt.decode(token, secret, algorithms=[_JWT_ALGORITHM]) diff --git a/app/data/living_ui_modules/auth/backend/tests/test_auth.py b/app/data/living_ui_modules/auth/backend/tests/test_auth.py deleted file mode 100644 index d176aca1..00000000 --- a/app/data/living_ui_modules/auth/backend/tests/test_auth.py +++ /dev/null @@ -1,247 +0,0 @@ -""" -Auth Module Tests — validates registration, login, token auth, and admin access. - -Copy this file into your project's backend/tests/ directory. -Run: cd backend && python -m pytest tests/test_auth.py -v -""" - -import pytest -from fastapi.testclient import TestClient -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -from sqlalchemy.pool import StaticPool - -from models import Base -from main import app -from database import get_db - - -# Test database — in-memory SQLite -test_engine = create_engine( - "sqlite://", - connect_args={"check_same_thread": False}, - poolclass=StaticPool, -) -TestSession = sessionmaker(autocommit=False, autoflush=False, bind=test_engine) - - -def override_get_db(): - db = TestSession() - try: - yield db - finally: - db.close() - - -@pytest.fixture(autouse=True) -def setup_db(): - """Create fresh tables for each test.""" - # Import auth models so they're registered with Base - import auth_models # noqa: F401 - - Base.metadata.create_all(bind=test_engine) - yield - Base.metadata.drop_all(bind=test_engine) - - -@pytest.fixture -def client(): - app.dependency_overrides[get_db] = override_get_db - with TestClient(app) as c: - yield c - app.dependency_overrides.clear() - - -class TestRegistration: - def test_register_first_user_is_admin(self, client): - resp = client.post( - "/api/auth/register", - json={ - "email": "admin@example.com", - "username": "admin", - "password": "secure123", - }, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["user"]["role"] == "admin" - assert "token" in data - - def test_register_second_user_is_member(self, client): - client.post( - "/api/auth/register", - json={ - "email": "admin@example.com", - "username": "admin", - "password": "secure123", - }, - ) - resp = client.post( - "/api/auth/register", - json={ - "email": "user@example.com", - "username": "user1", - "password": "secure123", - }, - ) - assert resp.status_code == 200 - assert resp.json()["user"]["role"] == "member" - - def test_register_duplicate_email(self, client): - client.post( - "/api/auth/register", - json={ - "email": "test@example.com", - "username": "user1", - "password": "pass123", - }, - ) - resp = client.post( - "/api/auth/register", - json={ - "email": "test@example.com", - "username": "user2", - "password": "pass123", - }, - ) - assert resp.status_code == 400 - assert "already registered" in resp.json()["detail"] - - def test_register_duplicate_username(self, client): - client.post( - "/api/auth/register", - json={ - "email": "a@example.com", - "username": "sameuser", - "password": "pass123", - }, - ) - resp = client.post( - "/api/auth/register", - json={ - "email": "b@example.com", - "username": "sameuser", - "password": "pass123", - }, - ) - assert resp.status_code == 400 - assert "already taken" in resp.json()["detail"] - - -class TestLogin: - def test_login_success(self, client): - client.post( - "/api/auth/register", - json={ - "email": "test@example.com", - "username": "testuser", - "password": "mypassword", - }, - ) - resp = client.post( - "/api/auth/login", - json={ - "email": "test@example.com", - "password": "mypassword", - }, - ) - assert resp.status_code == 200 - assert "token" in resp.json() - - def test_login_wrong_password(self, client): - client.post( - "/api/auth/register", - json={ - "email": "test@example.com", - "username": "testuser", - "password": "correct", - }, - ) - resp = client.post( - "/api/auth/login", - json={ - "email": "test@example.com", - "password": "wrong", - }, - ) - assert resp.status_code == 401 - - def test_login_nonexistent_user(self, client): - resp = client.post( - "/api/auth/login", - json={ - "email": "nobody@example.com", - "password": "pass", - }, - ) - assert resp.status_code == 401 - - -class TestAuthenticatedAccess: - def _register_and_get_token(self, client, email="test@example.com"): - resp = client.post( - "/api/auth/register", - json={ - "email": email, - "username": email.split("@")[0], - "password": "pass123", - }, - ) - return resp.json()["token"] - - def test_get_me(self, client): - token = self._register_and_get_token(client) - resp = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) - assert resp.status_code == 200 - assert resp.json()["user"]["email"] == "test@example.com" - - def test_get_me_no_token(self, client): - resp = client.get("/api/auth/me") - assert resp.status_code == 401 - - def test_get_me_invalid_token(self, client): - resp = client.get("/api/auth/me", headers={"Authorization": "Bearer invalid"}) - assert resp.status_code == 401 - - -class TestAdminAccess: - def test_admin_can_list_users(self, client): - resp = client.post( - "/api/auth/register", - json={ - "email": "admin@example.com", - "username": "admin", - "password": "pass123", - }, - ) - token = resp.json()["token"] - resp = client.get( - "/api/auth/users", headers={"Authorization": f"Bearer {token}"} - ) - assert resp.status_code == 200 - assert len(resp.json()["users"]) == 1 - - def test_member_cannot_list_users(self, client): - # First user is admin - client.post( - "/api/auth/register", - json={ - "email": "admin@example.com", - "username": "admin", - "password": "pass123", - }, - ) - # Second user is member - resp = client.post( - "/api/auth/register", - json={ - "email": "member@example.com", - "username": "member", - "password": "pass123", - }, - ) - token = resp.json()["token"] - resp = client.get( - "/api/auth/users", headers={"Authorization": f"Bearer {token}"} - ) - assert resp.status_code == 403 diff --git a/app/data/living_ui_modules/auth/frontend/AuthLayout.tsx b/app/data/living_ui_modules/auth/frontend/AuthLayout.tsx deleted file mode 100644 index 9d0414f5..00000000 --- a/app/data/living_ui_modules/auth/frontend/AuthLayout.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Auth Layout — shared wrapper for login, register, and profile pages. - * Also exports FormField for consistent label + input pairs. - * - * Copy this file into your project's frontend/components/auth/ directory. - */ - -import { ReactNode } from 'react' -import { Card, Input, Alert } from '../ui' - -// ── Centered card layout for auth pages ──────────────────────── - -interface AuthLayoutProps { - title: string - children: ReactNode - error?: string - footer?: ReactNode -} - -export function AuthLayout({ title, children, error, footer }: AuthLayoutProps) { - return ( -
- -

- {title} -

- {error && {error}} - {children} - {footer} -
-
- ) -} - -// ── Label + Input pair ───────────────────────────────────────── - -interface FormFieldProps { - label: string - type?: string - value: string - onChange: (value: string) => void - placeholder?: string - required?: boolean - readOnly?: boolean -} - -const labelStyle: React.CSSProperties = { - display: 'block', fontSize: 'var(--text-sm)', - fontWeight: 'var(--font-weight-medium)' as any, - marginBottom: 'var(--space-1)', color: 'var(--text-secondary)', -} - -export function FormField({ label, type = 'text', value, onChange, placeholder, required, readOnly }: FormFieldProps) { - return ( -
- - onChange(e.target.value)} - placeholder={placeholder} - required={required} - readOnly={readOnly} - /> -
- ) -} - -// ── Switch link ("Don't have an account? Sign up") ───────────── - -interface AuthSwitchLinkProps { - text: string - linkText: string - onClick: () => void -} - -export function AuthSwitchLink({ text, linkText, onClick }: AuthSwitchLinkProps) { - return ( -

- {text}{' '} - -

- ) -} diff --git a/app/data/living_ui_modules/auth/frontend/AuthProvider.tsx b/app/data/living_ui_modules/auth/frontend/AuthProvider.tsx deleted file mode 100644 index 64a624d1..00000000 --- a/app/data/living_ui_modules/auth/frontend/AuthProvider.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Auth Provider — React context for authentication state. - * - * Copy this file into your project's frontend/components/auth/ directory. - * - * Usage in App.tsx: - * import { AuthProvider, useAuth } from './components/auth/AuthProvider' - * - * function App() { - * return ( - * - * - * - * ) - * } - * - * function AppContent() { - * const { user, isAuthenticated, logout } = useAuth() - * if (!isAuthenticated) return - * return - * } - */ - -import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react' -import type { AuthUser, AuthState } from '../../auth_types' -import { authService } from '../../services/AuthService' - -interface AuthContextValue extends AuthState { - login: (email: string, password: string) => Promise - register: (email: string, username: string, password: string) => Promise - logout: () => void -} - -const AuthContext = createContext(null) - -export function useAuth(): AuthContextValue { - const ctx = useContext(AuthContext) - if (!ctx) throw new Error('useAuth must be used within ') - return ctx -} - -export function AuthProvider({ children }: { children: ReactNode }) { - const [state, setState] = useState({ - user: null, - token: authService.getToken(), - isAuthenticated: false, - loading: true, - }) - - // Validate existing token on mount - useEffect(() => { - const validate = async () => { - const user = await authService.getMe() - setState({ - user, - token: authService.getToken(), - isAuthenticated: !!user, - loading: false, - }) - } - validate() - }, []) - - const login = useCallback(async (email: string, password: string) => { - const { user, token } = await authService.login(email, password) - setState({ user, token, isAuthenticated: true, loading: false }) - }, []) - - const register = useCallback(async (email: string, username: string, password: string) => { - const { user, token } = await authService.register(email, username, password) - setState({ user, token, isAuthenticated: true, loading: false }) - }, []) - - const logout = useCallback(() => { - authService.logout() - setState({ user: null, token: null, isAuthenticated: false, loading: false }) - }, []) - - return ( - - {children} - - ) -} diff --git a/app/data/living_ui_modules/auth/frontend/InviteModal.tsx b/app/data/living_ui_modules/auth/frontend/InviteModal.tsx deleted file mode 100644 index 15d17a01..00000000 --- a/app/data/living_ui_modules/auth/frontend/InviteModal.tsx +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Invite Modal — create and share invite links for a resource. - * - * Copy this file into your project's frontend/components/auth/ directory. - * - * Usage: - * import { InviteModal } from './components/auth/InviteModal' - * setShowInvite(false)} - * /> - */ - -import { useState } from 'react' -import { Button, Input, Alert, Modal } from '../ui' -import { authService } from '../../services/AuthService' - -interface InviteModalProps { - resourceType: string - resourceId: number - isOpen: boolean - onClose: () => void -} - -export function InviteModal({ resourceType, resourceId, isOpen, onClose }: InviteModalProps) { - const [inviteCode, setInviteCode] = useState('') - const [loading, setLoading] = useState(false) - const [error, setError] = useState('') - const [copied, setCopied] = useState(false) - - // Accept invite state - const [joinCode, setJoinCode] = useState('') - const [joining, setJoining] = useState(false) - const [joinSuccess, setJoinSuccess] = useState(false) - - const handleCreateInvite = async () => { - setLoading(true) - setError('') - try { - const invite = await authService.createInvite(resourceType, resourceId) - setInviteCode(invite.code) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create invite') - } finally { - setLoading(false) - } - } - - const handleCopy = () => { - navigator.clipboard.writeText(inviteCode) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - - const handleJoin = async () => { - if (!joinCode.trim()) return - setJoining(true) - setError('') - try { - await authService.acceptInvite(joinCode.trim()) - setJoinSuccess(true) - setTimeout(() => { onClose(); setJoinSuccess(false); setJoinCode('') }, 1500) - } catch (err) { - setError(err instanceof Error ? err.message : 'Invalid invite code') - } finally { - setJoining(false) - } - } - - const handleClose = () => { - setInviteCode('') - setError('') - setCopied(false) - setJoinCode('') - setJoinSuccess(false) - onClose() - } - - if (!isOpen) return null - - return ( - -
- {error && {error}} - - {/* Create Invite Section */} -
-

- Create Invite Link -

- {inviteCode ? ( -
- - -
- ) : ( - - )} -

- Share this code with others so they can join. -

-
- - {/* Divider */} -
-
- or -
-
- - {/* Join Section */} -
-

- Join with Code -

- {joinSuccess ? ( - Joined successfully! - ) : ( -
- setJoinCode(e.target.value)} - placeholder="Paste invite code" - style={{ flex: 1 }} - /> - -
- )} -
-
- - ) -} diff --git a/app/data/living_ui_modules/auth/frontend/LoginPage.tsx b/app/data/living_ui_modules/auth/frontend/LoginPage.tsx deleted file mode 100644 index 7eabd526..00000000 --- a/app/data/living_ui_modules/auth/frontend/LoginPage.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Login Page — email + password form using preset UI components. - * - * Copy this file into your project's frontend/components/auth/ directory. - */ - -import { useState } from 'react' -import { Button } from '../ui' -import { useAuth } from './AuthProvider' -import { AuthLayout, FormField, AuthSwitchLink } from './AuthLayout' - -interface LoginPageProps { - onSwitchToRegister: () => void -} - -export function LoginPage({ onSwitchToRegister }: LoginPageProps) { - const { login } = useAuth() - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [error, setError] = useState('') - const [loading, setLoading] = useState(false) - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setError('') - setLoading(true) - try { - await login(email, password) - } catch (err) { - setError(err instanceof Error ? err.message : 'Login failed') - } finally { - setLoading(false) - } - } - - return ( - } - > -
- - - - -
- ) -} diff --git a/app/data/living_ui_modules/auth/frontend/MemberList.tsx b/app/data/living_ui_modules/auth/frontend/MemberList.tsx deleted file mode 100644 index 64328ac3..00000000 --- a/app/data/living_ui_modules/auth/frontend/MemberList.tsx +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Member List — shows members of a resource with role badges and remove button. - * - * Copy this file into your project's frontend/components/auth/ directory. - * - * Usage: - * import { MemberList } from './components/auth/MemberList' - * - */ - -import { useState, useEffect, useCallback } from 'react' -import { Button, Badge, Alert } from '../ui' -import { useAuth } from './AuthProvider' -import { authService } from '../../services/AuthService' -import type { MembershipInfo } from '../../auth_types' - -interface MemberListProps { - resourceType: string - resourceId: number - currentUserRole?: string // caller's role in this resource (for showing remove buttons) -} - -export function MemberList({ resourceType, resourceId, currentUserRole }: MemberListProps) { - const { user } = useAuth() - const [members, setMembers] = useState([]) - const [error, setError] = useState('') - const [removing, setRemoving] = useState(null) - - const canManage = currentUserRole === 'owner' || currentUserRole === 'admin' || user?.role === 'admin' - - const loadMembers = useCallback(async () => { - try { - const data = await authService.getMembers(resourceType, resourceId) - setMembers(data) - } catch { - setError('Failed to load members') - } - }, [resourceType, resourceId]) - - useEffect(() => { loadMembers() }, [loadMembers]) - - const handleRemove = async (userId: number) => { - setRemoving(userId) - try { - await authService.removeMember(resourceType, resourceId, userId) - setMembers(prev => prev.filter(m => m.userId !== userId)) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to remove member') - } finally { - setRemoving(null) - } - } - - if (error) return {error} - - return ( -
- {members.length === 0 ? ( -

No members yet

- ) : ( - members.map(member => ( -
- {/* Avatar */} -
- {member.user?.username?.charAt(0).toUpperCase() || '?'} -
- - {/* Info */} -
-
- {member.user?.username || `User #${member.userId}`} - {member.userId === user?.id && ( - (you) - )} -
-
- {member.user?.email} -
-
- - {/* Role badge */} - - {member.role} - - - {/* Remove button */} - {canManage && member.role !== 'owner' && member.userId !== user?.id && ( - - )} -
- )) - )} -
- ) -} diff --git a/app/data/living_ui_modules/auth/frontend/ProfilePage.tsx b/app/data/living_ui_modules/auth/frontend/ProfilePage.tsx deleted file mode 100644 index 6d5a6dab..00000000 --- a/app/data/living_ui_modules/auth/frontend/ProfilePage.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Profile Page — edit username, email, and change password. - * - * Copy this file into your project's frontend/components/auth/ directory. - * - * Usage: - * import { ProfilePage } from './components/auth/ProfilePage' - * {showProfile && setShowProfile(false)} />} - */ - -import { useState } from 'react' -import { Button, Card, Alert } from '../ui' -import { useAuth } from './AuthProvider' -import { FormField } from './AuthLayout' -import { authService } from '../../services/AuthService' - -interface ProfilePageProps { - onClose?: () => void -} - -export function ProfilePage({ onClose }: ProfilePageProps) { - const { user, logout } = useAuth() - - const [username, setUsername] = useState(user?.username || '') - const [email, setEmail] = useState(user?.email || '') - const [profileMsg, setProfileMsg] = useState('') - const [profileErr, setProfileErr] = useState('') - const [profileLoading, setProfileLoading] = useState(false) - - const [currentPassword, setCurrentPassword] = useState('') - const [newPassword, setNewPassword] = useState('') - const [confirmPassword, setConfirmPassword] = useState('') - const [passwordMsg, setPasswordMsg] = useState('') - const [passwordErr, setPasswordErr] = useState('') - const [passwordLoading, setPasswordLoading] = useState(false) - - const handleUpdateProfile = async (e: React.FormEvent) => { - e.preventDefault() - setProfileMsg(''); setProfileErr('') - setProfileLoading(true) - try { - await authService.updateProfile({ username, email }) - setProfileMsg('Profile updated') - } catch (err) { - setProfileErr(err instanceof Error ? err.message : 'Update failed') - } finally { - setProfileLoading(false) - } - } - - const handleChangePassword = async (e: React.FormEvent) => { - e.preventDefault() - setPasswordMsg(''); setPasswordErr('') - if (newPassword !== confirmPassword) { setPasswordErr('Passwords do not match'); return } - if (newPassword.length < 6) { setPasswordErr('Password must be at least 6 characters'); return } - setPasswordLoading(true) - try { - await authService.changePassword(currentPassword, newPassword) - setPasswordMsg('Password changed') - setCurrentPassword(''); setNewPassword(''); setConfirmPassword('') - } catch (err) { - setPasswordErr(err instanceof Error ? err.message : 'Password change failed') - } finally { - setPasswordLoading(false) - } - } - - if (!user) return null - - return ( -
- {onClose && ( -
-

Profile

- -
- )} - - -

- Account Info -

- {profileMsg && {profileMsg}} - {profileErr && {profileErr}} -
- - - - -
- - -

- Change Password -

- {passwordMsg && {passwordMsg}} - {passwordErr && {passwordErr}} -
- - - - - -
- - -

- Sign Out -

-

- You will need to sign in again to access your account. -

- -
-
- ) -} diff --git a/app/data/living_ui_modules/auth/frontend/RegisterPage.tsx b/app/data/living_ui_modules/auth/frontend/RegisterPage.tsx deleted file mode 100644 index e6e35096..00000000 --- a/app/data/living_ui_modules/auth/frontend/RegisterPage.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Register Page — email, username, password form using preset UI components. - * - * Copy this file into your project's frontend/components/auth/ directory. - */ - -import { useState } from 'react' -import { Button } from '../ui' -import { useAuth } from './AuthProvider' -import { AuthLayout, FormField, AuthSwitchLink } from './AuthLayout' - -interface RegisterPageProps { - onSwitchToLogin: () => void -} - -export function RegisterPage({ onSwitchToLogin }: RegisterPageProps) { - const { register } = useAuth() - const [email, setEmail] = useState('') - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [confirmPassword, setConfirmPassword] = useState('') - const [error, setError] = useState('') - const [loading, setLoading] = useState(false) - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setError('') - - if (password !== confirmPassword) { - setError('Passwords do not match') - return - } - if (password.length < 6) { - setError('Password must be at least 6 characters') - return - } - - setLoading(true) - try { - await register(email, username, password) - } catch (err) { - setError(err instanceof Error ? err.message : 'Registration failed') - } finally { - setLoading(false) - } - } - - return ( - } - > -
- - - - - - -
- ) -} diff --git a/app/data/living_ui_modules/auth/frontend/UserMenu.tsx b/app/data/living_ui_modules/auth/frontend/UserMenu.tsx deleted file mode 100644 index 3726ca84..00000000 --- a/app/data/living_ui_modules/auth/frontend/UserMenu.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/** - * User Menu — dropdown showing current user with logout option. - * - * Copy this file into your project's frontend/components/auth/ directory. - * Place in your app's header/nav bar. - * - * Usage: - * import { UserMenu } from './components/auth/UserMenu' - *
- *

My App

- * - *
- */ - -import { useState, useRef, useEffect } from 'react' -import { useAuth } from './AuthProvider' -import { Badge } from '../ui' - -export function UserMenu() { - const { user, logout } = useAuth() - const [open, setOpen] = useState(false) - const ref = useRef(null) - - // Close on outside click - useEffect(() => { - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) - } - document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, []) - - if (!user) return null - - return ( -
- - - {open && ( -
-
-
- {user.username} -
-
- {user.email} -
- - {user.role} - -
- -
- )} -
- ) -} diff --git a/app/data/living_ui_modules/auth/requirements.txt b/app/data/living_ui_modules/auth/requirements.txt deleted file mode 100644 index c9f6a53d..00000000 --- a/app/data/living_ui_modules/auth/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -bcrypt>=4.0.0 -PyJWT>=2.8.0 diff --git a/app/data/living_ui_sidecar/proxy.py b/app/data/living_ui_sidecar/proxy.py deleted file mode 100644 index a3f51128..00000000 --- a/app/data/living_ui_sidecar/proxy.py +++ /dev/null @@ -1,233 +0,0 @@ -""" -Living UI Sidecar Proxy - -A lightweight reverse proxy that sits in front of external apps, -injecting Living UI features (console capture, health checks, logging) -without modifying the original app. - -Usage: - python proxy.py --app-port 3109 --proxy-port 3108 - -Architecture: - Browser → This proxy (port 3108) → External app (port 3109) - ↓ - - Injects console/network capture into HTML responses - - Provides /health, /api/logs endpoints - - Captures frontend logs to logs/frontend_console.log - - Forwards everything else transparently -""" - -import argparse -import logging -import sys -from datetime import datetime -from pathlib import Path -from typing import List, Optional - -import httpx -from fastapi import FastAPI, Request, Response -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse -from pydantic import BaseModel - -# Setup logging -LOG_DIR = ( - Path(__file__).parent.parent / "logs" - if (Path(__file__).parent.parent / "logs").exists() - else Path("logs") -) -LOG_DIR.mkdir(parents=True, exist_ok=True) - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s | %(levelname)-8s | %(message)s", - handlers=[ - logging.FileHandler(LOG_DIR / "sidecar.log", encoding="utf-8"), - logging.StreamHandler(sys.stderr), - ], -) -logger = logging.getLogger("sidecar") - -# Parse args -parser = argparse.ArgumentParser() -parser.add_argument( - "--app-port", type=int, required=True, help="Port of the actual app" -) -parser.add_argument("--proxy-port", type=int, required=True, help="Port for this proxy") -args, _ = parser.parse_known_args() - -APP_URL = f"http://localhost:{args.app_port}" -FRONTEND_LOG_PATH = LOG_DIR / "frontend_console.log" - -# Console capture script to inject into HTML responses -CAPTURE_SCRIPT = """ - -""" - -# FastAPI app -app = FastAPI(title="Living UI Sidecar Proxy") -app.add_middleware( - CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] -) - -http_client = httpx.AsyncClient(base_url=APP_URL, timeout=30, follow_redirects=True) - - -# ── Living UI endpoints (handled by sidecar, not forwarded) ────────── - - -@app.get("/health") -async def health(): - """Health check — verifies both sidecar and app are running.""" - try: - resp = await http_client.get("/", timeout=5) - app_ok = resp.status_code < 500 - except Exception: - app_ok = False - return { - "status": "healthy" if app_ok else "degraded", - "sidecar": "ok", - "app": "ok" if app_ok else "down", - } - - -class LogEntry(BaseModel): - level: str - message: str - timestamp: Optional[str] = None - - -class LogBatch(BaseModel): - entries: List[LogEntry] - - -@app.post("/api/logs") -async def capture_logs(data: LogBatch): - """Receive frontend console logs from the injected capture script.""" - with open(FRONTEND_LOG_PATH, "a", encoding="utf-8") as f: - for entry in data.entries: - ts = entry.timestamp or datetime.utcnow().isoformat() - f.write(f"{ts} | {entry.level.upper():<7} | {entry.message}\n") - return {"status": "ok", "count": len(data.entries)} - - -# ── Reverse proxy (forwards everything else to the app) ────────────── - - -@app.api_route( - "/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"] -) -async def proxy(request: Request, path: str): - """Forward all requests to the actual app, inject capture script into HTML responses.""" - # Build the proxied URL - url = f"/{path}" - if request.url.query: - url += f"?{request.url.query}" - - # Forward headers (skip host) - headers = dict(request.headers) - headers.pop("host", None) - - try: - body = await request.body() - resp = await http_client.request( - method=request.method, - url=url, - headers=headers, - content=body if body else None, - ) - except httpx.ConnectError: - return JSONResponse({"error": "App not responding"}, status_code=502) - except Exception as e: - return JSONResponse({"error": str(e)}, status_code=502) - - # Check if response is HTML — inject capture script - content_type = resp.headers.get("content-type", "") - response_body = resp.content - - if "text/html" in content_type: - html = response_body.decode("utf-8", errors="replace") - # Inject capture script before or at end - if "" in html.lower(): - idx = html.lower().rfind("") - html = html[:idx] + CAPTURE_SCRIPT + html[idx:] - else: - html += CAPTURE_SCRIPT - response_body = html.encode("utf-8") - - # Build response with original headers - response_headers = dict(resp.headers) - response_headers.pop("content-length", None) # Will be recalculated - response_headers.pop("content-encoding", None) # We may have modified the content - response_headers.pop("transfer-encoding", None) - - return Response( - content=response_body, - status_code=resp.status_code, - headers=response_headers, - ) - - -if __name__ == "__main__": - import uvicorn - - logger.info( - f"Starting sidecar proxy: localhost:{args.proxy_port} → localhost:{args.app_port}" - ) - uvicorn.run(app, host="0.0.0.0", port=args.proxy_port, log_level="warning") diff --git a/app/data/living_ui_sidecar/requirements.txt b/app/data/living_ui_sidecar/requirements.txt deleted file mode 100644 index 609f6748..00000000 --- a/app/data/living_ui_sidecar/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -fastapi>=0.104.0 -uvicorn>=0.24.0 -httpx>=0.24.0 diff --git a/app/data/living_ui_template/.env.example b/app/data/living_ui_template/.env.example deleted file mode 100644 index 3bf1d1ec..00000000 --- a/app/data/living_ui_template/.env.example +++ /dev/null @@ -1,10 +0,0 @@ -# Living UI Environment Variables - -# CraftBot WebSocket URL for agent communication -VITE_CRAFTBOT_WS_URL=ws://localhost:7926 - -# Backend API URL (if using Python backend) -VITE_API_URL=http://localhost:{{BACKEND_PORT}} - -# Add your API keys and secrets below -# VITE_API_KEY=your_api_key_here diff --git a/app/data/living_ui_template/LIVING_UI.md b/app/data/living_ui_template/LIVING_UI.md deleted file mode 100644 index 3ef7acb5..00000000 --- a/app/data/living_ui_template/LIVING_UI.md +++ /dev/null @@ -1,80 +0,0 @@ -# {{PROJECT_NAME}} - -{{PROJECT_DESCRIPTION}} - -## Overview - - - -## Requirements - - - -### Entities & Data Model - - -### Layout & Design - - -### Features - - -### Assumptions - - -## Data Model - -### Backend Models (backend/models.py) - - - -| Model | Purpose | Key Fields | -|-------|---------|------------| -| Example | Description | field1, field2 | - -## API Endpoints - -### Custom Routes (backend/routes.py) - - - -| Method | Path | Description | -|--------|------|-------------| -| GET | /example | Description | -| POST | /example | Description | - -## Frontend Components - -### Components (frontend/components/) - - - -| Component | Purpose | -|-----------|---------| -| MainView.tsx | Main UI layout | - -## Key Files - -| File | Purpose | -|------|---------| -| backend/models.py | Database models | -| backend/routes.py | API endpoints | -| frontend/types.ts | TypeScript interfaces | -| frontend/AppController.ts | State management | -| frontend/components/MainView.tsx | Main UI | - -## State Flow - -``` -User Action → Frontend Component → AppController → Backend API → SQLite DB - ↓ - Update UI State -``` - -## Testing - - - -1. Create a new item -2. Refresh the page -3. Verify item persists diff --git a/app/data/living_ui_template/backend/database.py b/app/data/living_ui_template/backend/database.py deleted file mode 100644 index 44910980..00000000 --- a/app/data/living_ui_template/backend/database.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -Living UI Database Configuration - -SQLite database setup for persistent state storage. -Uses synchronous SQLite with SQLAlchemy for simplicity and reliability. -""" - -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -from models import Base -from pathlib import Path -import logging - -logger = logging.getLogger(__name__) - -# Database file stored in the project directory -DATABASE_PATH = Path(__file__).parent / "living_ui.db" -DATABASE_URL = f"sqlite:///{DATABASE_PATH}" - -# Create engine with check_same_thread=False for FastAPI compatibility -engine = create_engine( - DATABASE_URL, - connect_args={"check_same_thread": False}, - echo=False, # Set to True for SQL debugging -) - -# Enable WAL mode for better concurrent read/write performance (multi-user) -from sqlalchemy import event - - -@event.listens_for(engine, "connect") -def _set_sqlite_pragma(dbapi_connection, connection_record): - cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA journal_mode=WAL") - cursor.close() - - -# Session factory -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - - -async def init_db(): - """Initialize database tables.""" - logger.info(f"[Database] Creating tables at {DATABASE_PATH}") - Base.metadata.create_all(bind=engine) - - # Ensure default app state exists - from models import AppState - - db = SessionLocal() - try: - state = db.query(AppState).first() - if not state: - state = AppState() - db.add(state) - db.commit() - logger.info("[Database] Created default app state") - finally: - db.close() - - -def get_db(): - """ - Dependency to get database session. - - Usage in routes: - @router.get("/items") - def get_items(db: Session = Depends(get_db)): - return db.query(Item).all() - """ - db = SessionLocal() - try: - yield db - finally: - db.close() diff --git a/app/data/living_ui_template/backend/health_checker.py b/app/data/living_ui_template/backend/health_checker.py deleted file mode 100644 index dbf06e88..00000000 --- a/app/data/living_ui_template/backend/health_checker.py +++ /dev/null @@ -1,156 +0,0 @@ -""" -Living UI Backend Health Checker - -Background thread that periodically verifies the backend is healthy. -Checks both the HTTP health endpoint and database connectivity. -Writes status to logs/health_status.json for the manager watchdog to read. -Self-terminates if too many consecutive failures occur. -""" - -import json -import logging -import os -import threading -import urllib.request -from datetime import datetime -from pathlib import Path - -logger = logging.getLogger(__name__) - -LOG_DIR = Path(__file__).parent / "logs" - -_checker_thread: threading.Thread | None = None -_stop_event = threading.Event() - -# Number of consecutive failures before self-terminating -MAX_CONSECUTIVE_FAILURES = 5 -CHECK_INTERVAL_SECONDS = 60 -HEALTH_STATUS_FILE = LOG_DIR / "health_status.json" - - -def _write_status( - health_ok: bool, - db_ok: bool, - consecutive_failures: int, - error: str | None = None, -): - """Write current health status to JSON file for external monitoring.""" - LOG_DIR.mkdir(parents=True, exist_ok=True) - status = { - "last_check": datetime.now().isoformat(), - "health_endpoint": "ok" if health_ok else "fail", - "db_connectivity": "ok" if db_ok else "fail", - "consecutive_failures": consecutive_failures, - "error": error, - } - try: - HEALTH_STATUS_FILE.write_text(json.dumps(status, indent=2), encoding="utf-8") - except Exception as e: - logger.warning(f"[HealthChecker] Failed to write status file: {e}") - - -def _check_health_endpoint(port: int) -> bool: - """Hit the local /health endpoint.""" - try: - url = f"http://localhost:{port}/health" - resp = urllib.request.urlopen(url, timeout=5) - return resp.status == 200 - except Exception: - return False - - -def _check_db() -> bool: - """Verify database connectivity with a simple query.""" - try: - from sqlalchemy import text - from database import engine - - with engine.connect() as conn: - conn.execute(text("SELECT 1")) - return True - except Exception: - return False - - -def _run_checker(port: int): - """Main checker loop running in a background thread.""" - consecutive_failures = 0 - - # Wait a bit before first check to let the server fully start - if _stop_event.wait(timeout=15): - return - - logger.info( - f"[HealthChecker] Started - checking every {CHECK_INTERVAL_SECONDS}s " - f"(max {MAX_CONSECUTIVE_FAILURES} consecutive failures before exit)" - ) - - while not _stop_event.is_set(): - health_ok = _check_health_endpoint(port) - db_ok = _check_db() - - if health_ok and db_ok: - if consecutive_failures > 0: - logger.info( - f"[HealthChecker] Recovered after {consecutive_failures} failure(s)" - ) - consecutive_failures = 0 - _write_status(health_ok, db_ok, consecutive_failures) - else: - consecutive_failures += 1 - error_parts = [] - if not health_ok: - error_parts.append("health endpoint not responding") - if not db_ok: - error_parts.append("database connectivity failed") - error_msg = "; ".join(error_parts) - - logger.warning( - f"[HealthChecker] Check failed ({consecutive_failures}/{MAX_CONSECUTIVE_FAILURES}): {error_msg}" - ) - _write_status(health_ok, db_ok, consecutive_failures, error=error_msg) - - if consecutive_failures >= MAX_CONSECUTIVE_FAILURES: - logger.critical( - f"[HealthChecker] {MAX_CONSECUTIVE_FAILURES} consecutive failures - " - f"self-terminating. Last error: {error_msg}" - ) - _write_status( - health_ok, - db_ok, - consecutive_failures, - error=f"SELF-TERMINATED: {error_msg}", - ) - # Hard exit so the manager watchdog detects the crash and can restart - os._exit(1) - - _stop_event.wait(timeout=CHECK_INTERVAL_SECONDS) - - -def start_health_checker(port: int): - """Start the background health checker thread.""" - global _checker_thread - - if _checker_thread is not None and _checker_thread.is_alive(): - logger.warning("[HealthChecker] Already running") - return - - _stop_event.clear() - _checker_thread = threading.Thread( - target=_run_checker, args=(port,), daemon=True, name="health-checker" - ) - _checker_thread.start() - logger.info(f"[HealthChecker] Starting for port {port}") - - -def stop_health_checker(): - """Stop the background health checker thread.""" - global _checker_thread - - if _checker_thread is None: - return - - _stop_event.set() - _checker_thread.join(timeout=5) - _checker_thread = None - logger.info("[HealthChecker] Stopped") diff --git a/app/data/living_ui_template/backend/logger.py b/app/data/living_ui_template/backend/logger.py deleted file mode 100644 index cd6608c2..00000000 --- a/app/data/living_ui_template/backend/logger.py +++ /dev/null @@ -1,76 +0,0 @@ -""" -Living UI Backend Logger - -Persistent file-based logging for Living UI backend. -Logs are written to the project's logs/ directory with automatic rotation. -Each session (server start) creates a new log file, old logs are retained. -""" - -import logging -import os -import sys -from datetime import datetime -from pathlib import Path - -# Log directory lives inside the project's backend folder -LOG_DIR = Path(__file__).parent / "logs" -LOG_DIR.mkdir(parents=True, exist_ok=True) - - -def setup_logging() -> logging.Logger: - """ - Configure persistent file-based logging for the backend. - - Creates a timestamped log file per session so each server run - is independently traceable. Also logs to stderr for subprocess capture. - - Returns: - The root logger, configured with file + stream handlers. - """ - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - log_file = LOG_DIR / f"backend_{timestamp}.log" - - formatter = logging.Formatter( - "%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d - %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - - # File handler - captures everything (DEBUG+) - file_handler = logging.FileHandler(log_file, encoding="utf-8") - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(formatter) - - # Stream handler - INFO+ to stderr (captured by manager subprocess pipes) - stream_handler = logging.StreamHandler(sys.stderr) - stream_handler.setLevel(logging.INFO) - stream_handler.setFormatter(formatter) - - # Configure root logger - root_logger = logging.getLogger() - root_logger.setLevel(logging.DEBUG) - root_logger.addHandler(file_handler) - root_logger.addHandler(stream_handler) - - # Also capture uvicorn logs into the same file - for uvi_logger_name in ("uvicorn", "uvicorn.access", "uvicorn.error"): - uvi_logger = logging.getLogger(uvi_logger_name) - uvi_logger.handlers = [] # Remove default handlers - uvi_logger.addHandler(file_handler) - uvi_logger.addHandler(stream_handler) - uvi_logger.propagate = False - - root_logger.info(f"[Logger] Session log started: {log_file}") - root_logger.info(f"[Logger] Python {sys.version}") - root_logger.info(f"[Logger] CWD: {os.getcwd()}") - - return root_logger - - -def cleanup_old_logs(keep: int = 20): - """Remove old log files, keeping the most recent `keep` files.""" - log_files = sorted(LOG_DIR.glob("backend_*.log"), reverse=True) - for old_log in log_files[keep:]: - try: - old_log.unlink() - except Exception: - pass diff --git a/app/data/living_ui_template/backend/main.py b/app/data/living_ui_template/backend/main.py deleted file mode 100644 index 8f93b11e..00000000 --- a/app/data/living_ui_template/backend/main.py +++ /dev/null @@ -1,137 +0,0 @@ -""" -Living UI Python Backend - -FastAPI backend for Living UI projects. -Provides REST API for state management and data persistence. - -To run manually: - uvicorn main:app --port {{BACKEND_PORT}} --reload -""" - -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from contextlib import asynccontextmanager -from routes import router -from database import init_db -from logger import setup_logging, cleanup_old_logs -from pathlib import Path -import logging - -# Initialize persistent file-based logging before anything else -setup_logging() -cleanup_old_logs(keep=20) -logger = logging.getLogger(__name__) - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """Initialize database on startup.""" - logger.info("[Backend] Initializing database...") - await init_db() - logger.info("[Backend] Database initialized") - yield - logger.info("[Backend] Shutting down...") - - -app = FastAPI( - title="{{PROJECT_NAME}} API", - description="Backend API for {{PROJECT_NAME}} Living UI", - version="1.0.0", - lifespan=lifespan, -) - -# CORS configuration for frontend -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Include routes -app.include_router(router, prefix="/api") - -# Auto-include additional routers from routes/ directory (if any) -import importlib -import pkgutil - -_routes_dir = Path(__file__).parent / "routes" -if _routes_dir.exists() and (_routes_dir / "__init__.py").exists(): - for _imp, _mod, _pkg in pkgutil.iter_modules([str(_routes_dir)]): - _m = importlib.import_module(f"routes.{_mod}") - if hasattr(_m, "router"): - app.include_router(_m.router, prefix="/api") - - -@app.get("/health") -async def health_check(): - """Health check endpoint for process management.""" - return {"status": "healthy", "project": "{{PROJECT_ID}}"} - - -# ============================================================================ -# Frontend Console Log Capture (registered on app directly, not on router, -# so it survives agent rewrites of routes.py) -# ============================================================================ -from pydantic import BaseModel -from typing import List, Optional -from datetime import datetime - -_FRONTEND_LOG_PATH = Path(__file__).parent / "logs" / "frontend_console.log" - - -class _FrontendLogEntry(BaseModel): - level: str - message: str - timestamp: Optional[str] = None - - -class _FrontendLogBatch(BaseModel): - entries: List[_FrontendLogEntry] - - -@app.post("/api/logs") -async def capture_frontend_logs(data: _FrontendLogBatch): - """Capture frontend console logs for agent debugging.""" - _FRONTEND_LOG_PATH.parent.mkdir(parents=True, exist_ok=True) - with open(_FRONTEND_LOG_PATH, "a", encoding="utf-8") as f: - for entry in data.entries: - ts = entry.timestamp or datetime.utcnow().isoformat() - f.write(f"{ts} | {entry.level.upper():<5} | {entry.message}\n") - return {"status": "ok", "count": len(data.entries)} - - -# ============================================================================ -# Serve frontend static files (built by Vite) — enables single-port access -# for LAN/tunnel sharing. Must be registered LAST (catch-all). -# ============================================================================ -from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse - -_DIST_DIR = Path(__file__).parent.parent / "dist" -_DIST_ASSETS = _DIST_DIR / "assets" -if _DIST_DIR.exists() and _DIST_ASSETS.exists(): - _CONFIG_DIR = Path(__file__).parent.parent / "config" - - @app.get("/config/manifest.json") - async def serve_manifest(): - manifest = _CONFIG_DIR / "manifest.json" - if manifest.exists(): - return FileResponse(manifest) - return {"error": "manifest not found"} - - app.mount("/assets", StaticFiles(directory=str(_DIST_ASSETS)), name="assets") - - @app.get("/{path:path}") - async def spa_fallback(path: str): - file_path = _DIST_DIR / path - if file_path.is_file(): - return FileResponse(file_path) - return FileResponse(_DIST_DIR / "index.html") - - -if __name__ == "__main__": - import uvicorn - - uvicorn.run(app, host="0.0.0.0", port={{BACKEND_PORT}}) diff --git a/app/data/living_ui_template/backend/models.py b/app/data/living_ui_template/backend/models.py deleted file mode 100644 index dbf4143a..00000000 --- a/app/data/living_ui_template/backend/models.py +++ /dev/null @@ -1,142 +0,0 @@ -""" -Living UI Data Models - -SQLAlchemy models for data persistence. -Includes a flexible AppState model for storing arbitrary JSON state, -plus example Item model for reference. -""" - -from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, JSON -from sqlalchemy.ext.declarative import declarative_base -from datetime import datetime -from typing import Dict, Any - -Base = declarative_base() - - -class AppState(Base): - """ - Flexible application state storage. - - Stores the entire app state as JSON, allowing any structure. - This is the primary model used by the default state management. - - The agent should extend this with custom models for complex data needs. - """ - - __tablename__ = "app_state" - - id = Column(Integer, primary_key=True, default=1) - data = Column(JSON, default=dict) # Stores arbitrary state as JSON - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary for API response.""" - return { - "id": self.id, - "data": self.data or {}, - "createdAt": self.created_at.isoformat() if self.created_at else None, - "updatedAt": self.updated_at.isoformat() if self.updated_at else None, - } - - def update_data(self, updates: Dict[str, Any]) -> None: - """Merge updates into existing data.""" - current = self.data or {} - current.update(updates) - self.data = current - self.updated_at = datetime.utcnow() - - -# ============================================================================ -# Example models for reference - Agent should customize these -# ============================================================================ - - -class UISnapshot(Base): - """ - UI state snapshot for agent observation. - - Frontend periodically posts UI state here. - Agent can GET this to observe the UI without WebSocket. - """ - - __tablename__ = "ui_snapshot" - - id = Column(Integer, primary_key=True, default=1) - html_structure = Column(Text, nullable=True) # Simplified DOM structure - visible_text = Column(JSON, default=list) # Array of visible text content - input_values = Column(JSON, default=dict) # Form field values - component_state = Column(JSON, default=dict) # Registered component states - current_view = Column(String(255), nullable=True) # Current route/view - viewport = Column(JSON, default=dict) # Window dimensions, scroll position - timestamp = Column(DateTime, default=datetime.utcnow) - - def to_dict(self) -> Dict[str, Any]: - return { - "htmlStructure": self.html_structure, - "visibleText": self.visible_text or [], - "inputValues": self.input_values or {}, - "componentState": self.component_state or {}, - "currentView": self.current_view, - "viewport": self.viewport or {}, - "timestamp": self.timestamp.isoformat() if self.timestamp else None, - } - - -class UIScreenshot(Base): - """ - UI screenshot for agent visual observation. - - Frontend captures and posts screenshot here. - Agent can GET this to see the UI visually. - """ - - __tablename__ = "ui_screenshot" - - id = Column(Integer, primary_key=True, default=1) - image_data = Column(Text, nullable=True) # Base64 encoded PNG - width = Column(Integer, nullable=True) - height = Column(Integer, nullable=True) - timestamp = Column(DateTime, default=datetime.utcnow) - - def to_dict(self) -> Dict[str, Any]: - return { - "imageData": self.image_data, - "width": self.width, - "height": self.height, - "timestamp": self.timestamp.isoformat() if self.timestamp else None, - } - - -class Item(Base): - """ - Example model for list-based data (todos, notes, etc.) - - Customize or replace this model based on your Living UI needs. - """ - - __tablename__ = "items" - - id = Column(Integer, primary_key=True, index=True) - title = Column(String(255), nullable=False) - description = Column(Text, nullable=True) - completed = Column(Boolean, default=False) - order = Column(Integer, default=0) - extra_data = Column( - JSON, default=dict - ) # Flexible extra data (avoid 'metadata' - reserved in SQLAlchemy) - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - def to_dict(self) -> Dict[str, Any]: - return { - "id": self.id, - "title": self.title, - "description": self.description, - "completed": self.completed, - "order": self.order, - "extraData": self.extra_data or {}, - "createdAt": self.created_at.isoformat() if self.created_at else None, - "updatedAt": self.updated_at.isoformat() if self.updated_at else None, - } diff --git a/app/data/living_ui_template/backend/requirements.txt b/app/data/living_ui_template/backend/requirements.txt deleted file mode 100644 index a850540e..00000000 --- a/app/data/living_ui_template/backend/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -# Living UI Backend Dependencies -fastapi>=0.104.0 -uvicorn>=0.24.0 -sqlalchemy>=2.0.0 -pydantic>=2.0.0 -pytest>=7.0.0 -httpx>=0.24.0 diff --git a/app/data/living_ui_template/backend/routes.py b/app/data/living_ui_template/backend/routes.py deleted file mode 100644 index 85dff98e..00000000 --- a/app/data/living_ui_template/backend/routes.py +++ /dev/null @@ -1,418 +0,0 @@ -""" -Living UI API Routes - -REST API endpoints for state management and data operations. -Provides both generic state storage and example CRUD operations. -""" - -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session -from pydantic import BaseModel -from typing import Dict, Any, List, Optional -from database import get_db -from models import AppState, Item, UISnapshot, UIScreenshot -from datetime import datetime -import logging - -logger = logging.getLogger(__name__) -router = APIRouter() - - -# ============================================================================ -# Pydantic Schemas -# ============================================================================ - - -class StateUpdate(BaseModel): - """Schema for updating app state.""" - - data: Dict[str, Any] - - -class ActionRequest(BaseModel): - """Schema for executing an action.""" - - action: str - payload: Optional[Dict[str, Any]] = None - - -class ItemCreate(BaseModel): - """Schema for creating an item.""" - - title: str - description: Optional[str] = None - extra_data: Optional[Dict[str, Any]] = None - - -class ItemUpdate(BaseModel): - """Schema for updating an item.""" - - title: Optional[str] = None - description: Optional[str] = None - completed: Optional[bool] = None - order: Optional[int] = None - extra_data: Optional[Dict[str, Any]] = None - - -class UISnapshotUpdate(BaseModel): - """Schema for updating UI snapshot.""" - - htmlStructure: Optional[str] = None - visibleText: Optional[List[str]] = None - inputValues: Optional[Dict[str, Any]] = None - componentState: Optional[Dict[str, Any]] = None - currentView: Optional[str] = None - viewport: Optional[Dict[str, Any]] = None - - -class UIScreenshotUpdate(BaseModel): - """Schema for updating UI screenshot.""" - - imageData: str # Base64 encoded PNG - width: Optional[int] = None - height: Optional[int] = None - - -# ============================================================================ -# State Management Routes (Primary API) -# ============================================================================ - - -@router.get("/state") -def get_state(db: Session = Depends(get_db)) -> Dict[str, Any]: - """ - Get the current application state. - - Returns the stored state data, or empty dict if no state exists. - Frontend calls this on mount to restore state. - """ - state = db.query(AppState).first() - if not state: - state = AppState(data={}) - db.add(state) - db.commit() - db.refresh(state) - return state.data or {} - - -@router.put("/state") -def update_state(update: StateUpdate, db: Session = Depends(get_db)) -> Dict[str, Any]: - """ - Update the application state. - - Merges the provided data with existing state. - Returns the complete updated state. - """ - state = db.query(AppState).first() - if not state: - state = AppState(data=update.data) - db.add(state) - else: - state.update_data(update.data) - db.commit() - db.refresh(state) - logger.info(f"[Routes] State updated: {list(update.data.keys())}") - return state.data or {} - - -@router.post("/state/replace") -def replace_state(update: StateUpdate, db: Session = Depends(get_db)) -> Dict[str, Any]: - """ - Replace the entire application state. - - Unlike PUT /state which merges, this completely replaces the state. - Use with caution. - """ - state = db.query(AppState).first() - if not state: - state = AppState(data=update.data) - db.add(state) - else: - state.data = update.data - db.commit() - db.refresh(state) - logger.info("[Routes] State replaced") - return state.data or {} - - -@router.delete("/state") -def clear_state(db: Session = Depends(get_db)) -> Dict[str, str]: - """ - Clear all application state. - - Resets state to empty dict. - """ - state = db.query(AppState).first() - if state: - state.data = {} - db.commit() - logger.info("[Routes] State cleared") - return {"status": "cleared"} - - -@router.post("/action") -def execute_action( - request: ActionRequest, db: Session = Depends(get_db) -) -> Dict[str, Any]: - """ - Execute a named action. - - This is a generic endpoint for custom actions. - The agent should customize this based on the Living UI's needs. - - Example actions: - - {"action": "reset"} - Reset to initial state - - {"action": "increment", "payload": {"key": "counter"}} - """ - action = request.action - payload = request.payload or {} - - logger.info(f"[Routes] Executing action: {action}") - - # Get current state - state = db.query(AppState).first() - if not state: - state = AppState(data={}) - db.add(state) - - current_data = state.data or {} - - # Handle built-in actions - if action == "reset": - state.data = {} - db.commit() - return {"status": "reset", "data": {}} - - elif action == "increment": - key = payload.get("key", "counter") - current_data[key] = current_data.get(key, 0) + 1 - state.data = current_data - db.commit() - return {"status": "incremented", "data": current_data} - - elif action == "decrement": - key = payload.get("key", "counter") - current_data[key] = current_data.get(key, 0) - 1 - state.data = current_data - db.commit() - return {"status": "decremented", "data": current_data} - - # Custom actions should be added here by the agent - # Example: - # elif action == "feed_pet": - # current_data["pet"]["hunger"] = min(100, current_data.get("pet", {}).get("hunger", 50) + 25) - # state.data = current_data - # db.commit() - # return {"status": "fed", "data": current_data} - - else: - # Unknown action - return current state without changes - logger.warning(f"[Routes] Unknown action: {action}") - return {"status": "unknown_action", "action": action, "data": current_data} - - -# ============================================================================ -# Item CRUD Routes (Example for list-based data) -# ============================================================================ - - -@router.get("/items") -def list_items(db: Session = Depends(get_db)) -> List[Dict[str, Any]]: - """Get all items, ordered by their order field.""" - items = db.query(Item).order_by(Item.order, Item.id).all() - return [item.to_dict() for item in items] - - -@router.post("/items") -def create_item(data: ItemCreate, db: Session = Depends(get_db)) -> Dict[str, Any]: - """Create a new item.""" - # Get max order to put new item at end - max_order = db.query(Item).count() - item = Item( - title=data.title, - description=data.description, - extra_data=data.extra_data or {}, - order=max_order, - ) - db.add(item) - db.commit() - db.refresh(item) - logger.info(f"[Routes] Created item: {item.id}") - return item.to_dict() - - -@router.get("/items/{item_id}") -def get_item(item_id: int, db: Session = Depends(get_db)) -> Dict[str, Any]: - """Get a specific item by ID.""" - item = db.query(Item).filter(Item.id == item_id).first() - if not item: - raise HTTPException(status_code=404, detail="Item not found") - return item.to_dict() - - -@router.put("/items/{item_id}") -def update_item( - item_id: int, data: ItemUpdate, db: Session = Depends(get_db) -) -> Dict[str, Any]: - """Update an existing item.""" - item = db.query(Item).filter(Item.id == item_id).first() - if not item: - raise HTTPException(status_code=404, detail="Item not found") - - if data.title is not None: - item.title = data.title - if data.description is not None: - item.description = data.description - if data.completed is not None: - item.completed = data.completed - if data.order is not None: - item.order = data.order - if data.extra_data is not None: - item.extra_data = data.extra_data - - db.commit() - db.refresh(item) - logger.info(f"[Routes] Updated item: {item_id}") - return item.to_dict() - - -@router.delete("/items/{item_id}") -def delete_item(item_id: int, db: Session = Depends(get_db)) -> Dict[str, str]: - """Delete an item.""" - item = db.query(Item).filter(Item.id == item_id).first() - if not item: - raise HTTPException(status_code=404, detail="Item not found") - - db.delete(item) - db.commit() - logger.info(f"[Routes] Deleted item: {item_id}") - return {"status": "deleted", "id": str(item_id)} - - -# ============================================================================ -# UI Observation Routes (Agent API) -# ============================================================================ - - -@router.get("/ui-snapshot") -def get_ui_snapshot(db: Session = Depends(get_db)) -> Dict[str, Any]: - """ - Get the current UI snapshot. - - Returns the latest UI state captured by the frontend. - Agent uses this to observe the UI without WebSocket. - - Response includes: - - htmlStructure: Simplified DOM structure - - visibleText: Array of visible text on screen - - inputValues: Current form field values - - componentState: State of registered components - - currentView: Current route/view - - viewport: Window dimensions and scroll position - - timestamp: When the snapshot was captured - """ - snapshot = db.query(UISnapshot).first() - if not snapshot: - return { - "htmlStructure": None, - "visibleText": [], - "inputValues": {}, - "componentState": {}, - "currentView": None, - "viewport": {}, - "timestamp": None, - "status": "no_snapshot", - } - return snapshot.to_dict() - - -@router.post("/ui-snapshot") -def update_ui_snapshot( - data: UISnapshotUpdate, db: Session = Depends(get_db) -) -> Dict[str, Any]: - """ - Update the UI snapshot. - - Frontend calls this periodically to report UI state. - This replaces WebSocket-based state reporting. - """ - snapshot = db.query(UISnapshot).first() - if not snapshot: - snapshot = UISnapshot() - db.add(snapshot) - - if data.htmlStructure is not None: - snapshot.html_structure = data.htmlStructure - if data.visibleText is not None: - snapshot.visible_text = data.visibleText - if data.inputValues is not None: - snapshot.input_values = data.inputValues - if data.componentState is not None: - snapshot.component_state = data.componentState - if data.currentView is not None: - snapshot.current_view = data.currentView - if data.viewport is not None: - snapshot.viewport = data.viewport - - snapshot.timestamp = datetime.utcnow() - - db.commit() - db.refresh(snapshot) - logger.info("[Routes] UI snapshot updated") - return snapshot.to_dict() - - -@router.get("/ui-screenshot") -def get_ui_screenshot(db: Session = Depends(get_db)) -> Dict[str, Any]: - """ - Get the current UI screenshot. - - Returns the latest screenshot captured by the frontend as base64 PNG. - Agent uses this for visual observation of the UI. - - Response includes: - - imageData: Base64 encoded PNG image - - width: Image width in pixels - - height: Image height in pixels - - timestamp: When the screenshot was captured - - To use the image: - - Decode base64: base64.b64decode(imageData) - - Or display in HTML: - """ - screenshot = db.query(UIScreenshot).first() - if not screenshot or not screenshot.image_data: - return { - "imageData": None, - "width": None, - "height": None, - "timestamp": None, - "status": "no_screenshot", - } - return screenshot.to_dict() - - -@router.post("/ui-screenshot") -def update_ui_screenshot( - data: UIScreenshotUpdate, db: Session = Depends(get_db) -) -> Dict[str, Any]: - """ - Update the UI screenshot. - - Frontend calls this to post a screenshot of the current UI. - Screenshot should be a base64 encoded PNG. - """ - screenshot = db.query(UIScreenshot).first() - if not screenshot: - screenshot = UIScreenshot() - db.add(screenshot) - - screenshot.image_data = data.imageData - screenshot.width = data.width - screenshot.height = data.height - screenshot.timestamp = datetime.utcnow() - - db.commit() - db.refresh(screenshot) - logger.info(f"[Routes] UI screenshot updated ({data.width}x{data.height})") - return {"status": "updated", "timestamp": screenshot.timestamp.isoformat()} diff --git a/app/data/living_ui_template/backend/services/integration_client.py b/app/data/living_ui_template/backend/services/integration_client.py deleted file mode 100644 index dee26124..00000000 --- a/app/data/living_ui_template/backend/services/integration_client.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -CraftBot Integration Client — call external APIs through CraftBot. - -Living UIs are shareable, so they never store credentials. Instead, -requests go through CraftBot which injects auth headers server-side. - -Usage: - from services.integration_client import integration - - # Check what's available - integrations = await integration.get_integrations() - - # Make an authenticated API call - result = await integration.request( - integration="google_workspace", - method="GET", - url="https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true", - ) - if result["status"] == 200: - channels = result["data"] -""" - -import os -import httpx -from typing import Any, Dict, List, Optional - -BRIDGE_URL = os.environ.get("CRAFTBOT_BRIDGE_URL", "") -BRIDGE_TOKEN = os.environ.get("CRAFTBOT_BRIDGE_TOKEN", "") - - -class IntegrationClient: - """Proxy client for calling external APIs through CraftBot.""" - - def __init__(self): - self._client: Optional[httpx.AsyncClient] = None - - def _ensure_client(self) -> httpx.AsyncClient: - if self._client is None: - self._client = httpx.AsyncClient(timeout=30) - return self._client - - @property - def available(self) -> bool: - """Whether the CraftBot integration bridge is available.""" - return bool(BRIDGE_URL and BRIDGE_TOKEN) - - def _auth_headers(self) -> Dict[str, str]: - return {"Authorization": f"Bearer {BRIDGE_TOKEN}"} - - async def get_integrations(self) -> List[Dict[str, Any]]: - """ - List available integrations and their connection status. - - Returns a list like: - [ - {"id": "google_workspace", "connected": true, "granted": true}, - {"id": "slack", "connected": true, "granted": false}, - {"id": "discord", "connected": false, "granted": false}, - ] - """ - if not self.available: - return [] - try: - client = self._ensure_client() - r = await client.get( - f"{BRIDGE_URL}/api/integrations/available", - headers=self._auth_headers(), - ) - if r.status_code == 200: - return r.json().get("integrations", []) - return [] - except Exception: - return [] - - async def request( - self, - integration: str, - method: str, - url: str, - headers: Optional[Dict[str, str]] = None, - body: Any = None, - ) -> Dict[str, Any]: - """ - Make an authenticated request to an external API via CraftBot proxy. - - Args: - integration: Platform ID (e.g., "google_workspace", "slack", "discord") - method: HTTP method (GET, POST, PUT, DELETE) - url: Full URL to the external API endpoint - headers: Optional extra headers (e.g., custom Accept header) - body: Optional request body (dict for JSON) - - Returns: - {"status": 200, "data": {...}} on success - {"status": 4xx/5xx, "data": "error message"} on failure - {"error": "..."} if bridge itself fails - """ - if not self.available: - return {"error": "Integration bridge not available"} - - try: - client = self._ensure_client() - r = await client.post( - f"{BRIDGE_URL}/api/integrations/proxy", - headers=self._auth_headers(), - json={ - "integration": integration, - "method": method, - "url": url, - "headers": headers or {}, - "body": body, - }, - ) - return r.json() - except Exception as e: - return {"error": str(e)} - - async def close(self): - """Close the HTTP client.""" - if self._client: - await self._client.aclose() - self._client = None - - -# Singleton — import and use directly -integration = IntegrationClient() diff --git a/app/data/living_ui_template/backend/test_runner.py b/app/data/living_ui_template/backend/test_runner.py deleted file mode 100644 index c0eee614..00000000 --- a/app/data/living_ui_template/backend/test_runner.py +++ /dev/null @@ -1,1135 +0,0 @@ -""" -Living UI Backend Test Runner - -Auto-discovers and tests backend routes without agent involvement. -Four modes: - --internal : Pre-server validation (imports, models, route registration) - --unit : Auto-generated CRUD unit tests against temp DB - --compatibility : Frontend-backend route compatibility check - --external : Post-server HTTP smoke tests (requires running server) - -Usage: - python test_runner.py --internal - python test_runner.py --unit - python test_runner.py --compatibility - python test_runner.py --external --port 3101 -""" - -import argparse -import json -import logging -import re -import sys -import traceback -import urllib.request -import urllib.error -from datetime import datetime -from pathlib import Path -from typing import Any, Dict, List, Set, Tuple - -LOG_DIR = Path(__file__).parent / "logs" -LOG_DIR.mkdir(parents=True, exist_ok=True) - -logger = logging.getLogger("test_runner") - -# Routes to skip during smoke tests (framework/template-provided, not agent code) -SKIP_PATHS = {"/health", "/docs", "/redoc", "/openapi.json"} -# Template-provided UI observation routes — complex payloads (base64 images, DOM), skip in smoke tests -SKIP_API_PREFIXES = ( - "/api/ui-snapshot", - "/api/ui-screenshot", -) - - -# ============================================================================ -# Auto-payload generation from OpenAPI schemas -# ============================================================================ - - -def generate_payload_from_schema( - schema: Dict[str, Any], definitions: Dict[str, Any] -) -> Dict[str, Any]: - """ - Generate a minimal valid payload from an OpenAPI/JSON Schema definition. - - Handles $ref resolution and generates test values for common types. - Only includes required fields. - """ - if "$ref" in schema: - ref_name = schema["$ref"].split("/")[-1] - schema = definitions.get(ref_name, {}) - - if schema.get("type") != "object": - return {} - - properties = schema.get("properties", {}) - required = set(schema.get("required", [])) - - # If no required fields specified, include all properties - if not required: - required = set(properties.keys()) - - payload = {} - for field_name, field_schema in properties.items(): - if field_name not in required: - continue - if field_name.startswith("_"): - continue - payload[field_name] = _generate_value(field_schema, definitions) - - return payload - - -def _generate_value(schema: Dict[str, Any], definitions: Dict[str, Any]) -> Any: - """Generate a test value for a single field based on its schema.""" - if "$ref" in schema: - ref_name = schema["$ref"].split("/")[-1] - ref_schema = definitions.get(ref_name, {}) - return generate_payload_from_schema(ref_schema, definitions) - - field_type = schema.get("type", "string") - - if field_type == "string": - if "enum" in schema: - return schema["enum"][0] - # Use format hints for better test values - fmt = schema.get("format", "") - if fmt == "date-time": - return "2026-01-01T00:00:00" - elif fmt == "date": - return "2026-01-01" - elif fmt == "email": - return "test@test.com" - elif fmt == "uri" or fmt == "url": - return "http://test.com" - return "test" - elif field_type == "integer": - return schema.get("minimum", 1) - elif field_type == "number": - return schema.get("minimum", 1.0) - elif field_type == "boolean": - return True - elif field_type == "array": - # Generate an array with one item of the correct type - items_schema = schema.get("items", {}) - if items_schema: - return [_generate_value(items_schema, definitions)] - return [] - elif field_type == "object": - # Check if it has properties (structured) or is a free-form dict - if schema.get("properties"): - return generate_payload_from_schema(schema, definitions) - # Free-form object (e.g., Dict[str, Any]) - return {} - elif field_type == "null": - return None - - # anyOf / oneOf — pick the first non-null type - for key in ("anyOf", "oneOf"): - if key in schema: - for variant in schema[key]: - if variant.get("type") != "null": - return _generate_value(variant, definitions) - - return "test" - - -# ============================================================================ -# Internal Tests (pre-server) -# ============================================================================ - - -def run_internal_tests() -> Dict[str, Any]: - """ - Run pre-server validation tests. - - - Import validation for main, routes, models, database - - Route discovery from FastAPI app - - Model verification (SQLAlchemy tables) - - Returns dict with status, errors, and discovered routes. - """ - result = { - "status": "pass", - "errors": [], - "routes": [], - "timestamp": datetime.now().isoformat(), - "mode": "internal", - } - - # Test 1: Import validation - modules_to_test = ["database", "models", "routes", "main"] - for module_name in modules_to_test: - try: - __import__(module_name) - logger.info(f"[IMPORT] {module_name} — OK") - except Exception as e: - error_msg = f"Failed to import {module_name}: {e}" - logger.error(f"[IMPORT] {error_msg}") - result["errors"].append( - { - "test": "import", - "module": module_name, - "error": str(e), - "traceback": traceback.format_exc(), - } - ) - result["status"] = "fail" - - if result["status"] == "fail": - # No point continuing if imports fail - _write_result(result, "test_discovery.json") - return result - - # Test 2: Route discovery - try: - from main import app - - openapi_schema = app.openapi() - definitions = openapi_schema.get("components", {}).get("schemas", {}) - paths = openapi_schema.get("paths", {}) - - for path, methods in paths.items(): - for method, details in methods.items(): - if method.upper() in ("GET", "POST", "PUT", "DELETE", "PATCH"): - # Check for request body schema - body_schema = None - has_request_body = False - request_body = details.get("requestBody", {}) - if request_body: - has_request_body = True - content = request_body.get("content", {}) - json_content = content.get("application/json", {}) - body_schema = json_content.get("schema") - - # Check for path parameters - path_params = [] - for param in details.get("parameters", []): - if param.get("in") == "path": - path_params.append(param["name"]) - - route_info = { - "method": method.upper(), - "path": path, - "has_request_body": has_request_body, - "body_schema": body_schema, - "path_params": path_params, - "level": "light", - } - result["routes"].append(route_info) - logger.info(f"[ROUTE] {method.upper()} {path}") - - if not any(r["path"].startswith("/api") for r in result["routes"]): - result["errors"].append( - { - "test": "route_discovery", - "error": "No /api/* routes found — backend has no application routes registered", - } - ) - result["status"] = "fail" - else: - api_count = sum(1 for r in result["routes"] if r["path"].startswith("/api")) - logger.info(f"[ROUTES] Discovered {api_count} API route(s)") - - except Exception as e: - result["errors"].append( - { - "test": "route_discovery", - "error": str(e), - "traceback": traceback.format_exc(), - } - ) - result["status"] = "fail" - - # Test 3: Model/table verification - try: - from models import Base - - # Verify tables can be created (uses in-memory check, doesn't modify real DB) - table_names = list(Base.metadata.tables.keys()) - logger.info(f"[MODELS] Found {len(table_names)} table(s): {table_names}") - - if not table_names: - result["errors"].append( - {"test": "models", "error": "No SQLAlchemy models/tables defined"} - ) - result["status"] = "fail" - - except Exception as e: - result["errors"].append( - {"test": "models", "error": str(e), "traceback": traceback.format_exc()} - ) - result["status"] = "fail" - - # Test 4: System file integrity — verify critical system features weren't removed - system_checks = _check_system_files() - for check in system_checks: - if check["status"] == "fail": - result["errors"].append( - {"test": "system_integrity", "error": check["error"]} - ) - result["status"] = "fail" - logger.error(f"[SYSTEM] {check['error']}") - else: - logger.info(f"[SYSTEM] {check['name']} — OK") - - _write_result(result, "test_discovery.json") - return result - - -def _check_system_files() -> List[Dict[str, Any]]: - """Check that critical system features haven't been removed from template files.""" - checks = [] - backend_dir = ( - Path(__file__).parent.parent / "backend" - if (Path(__file__).parent.parent / "backend").exists() - else Path(__file__).parent - ) - project_root = Path(__file__).parent.parent - - # Check main.py has /health endpoint - main_py = backend_dir / "main.py" - if main_py.exists(): - content = main_py.read_text(encoding="utf-8") - if "/health" not in content: - checks.append( - { - "name": "health_endpoint", - "status": "fail", - "error": "main.py is missing /health endpoint. Add: @app.get('/health') async def health_check(): return {'status': 'healthy'}", - } - ) - else: - checks.append({"name": "health_endpoint", "status": "pass"}) - - if "/api/logs" not in content: - checks.append( - { - "name": "logs_endpoint", - "status": "fail", - "error": "main.py is missing POST /api/logs endpoint for frontend console capture. Restore it from the template or add: @app.post('/api/logs') that accepts {entries: [{level, message, timestamp}]} and writes to logs/frontend_console.log", - } - ) - else: - checks.append({"name": "logs_endpoint", "status": "pass"}) - - if "setup_logging" not in content: - checks.append( - { - "name": "logging_setup", - "status": "fail", - "error": "main.py is missing setup_logging() call. Add: from logger import setup_logging, cleanup_old_logs; setup_logging(); cleanup_old_logs(keep=20)", - } - ) - else: - checks.append({"name": "logging_setup", "status": "pass"}) - - # Health checker is handled by the manager watchdog — no longer required in main.py - checks.append({"name": "health_checker", "status": "pass"}) - else: - checks.append( - {"name": "main_py", "status": "fail", "error": "main.py not found"} - ) - - # Check index.html has console capture script - index_html = project_root / "index.html" - if index_html.exists(): - content = index_html.read_text(encoding="utf-8") - if "ConsoleCapture" not in content and "/api/logs" not in content: - checks.append( - { - "name": "console_capture", - "status": "fail", - "error": "index.html is missing the ConsoleCapture script. Restore it from the template — it should be an inline - - - - - - - - - - diff --git a/app/data/living_ui_template/package.json b/app/data/living_ui_template/package.json deleted file mode 100644 index 903a9ae1..00000000 --- a/app/data/living_ui_template/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "{{PROJECT_NAME}}", - "version": "1.0.0", - "description": "{{PROJECT_DESCRIPTION}}", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" - }, - "dependencies": { - "html2canvas": "^1.4.1", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "lucide-react": "^0.460.0", - "react-toastify": "^10.0.0" - }, - "devDependencies": { - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", - "@vitejs/plugin-react": "^4.0.0", - "typescript": "^5.0.0", - "vite": "^5.0.0" - } -} diff --git a/app/data/living_ui_template/pb_hooks/_craftbot_bridge.js b/app/data/living_ui_template/pb_hooks/_craftbot_bridge.js new file mode 100644 index 00000000..95feb50d --- /dev/null +++ b/app/data/living_ui_template/pb_hooks/_craftbot_bridge.js @@ -0,0 +1,58 @@ +/** CraftBot host bridge helpers. Require this module inside route handlers. */ + +function callLLM(prompt, systemMessage) { + try { + const bridge = $os.getenv("CRAFTBOT_BRIDGE_URL") + const token = $os.getenv("CRAFTBOT_BRIDGE_TOKEN") + if (!bridge || !token) return "" + const res = $http.send({ + url: bridge + "/api/bridge/llm", + method: "POST", + body: JSON.stringify({ prompt: prompt, system_message: systemMessage || "" }), + headers: { + "content-type": "application/json", + authorization: "Bearer " + token, + }, + timeout: 120, + }) + return (res.json && res.json.content) || "" + } catch (_) { + return "" + } +} + +function callIntegration(integration, method, url, body, headers) { + 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/proxy", + method: "POST", + body: JSON.stringify({ + integration: integration, + method: method, + url: url, + body: body || null, + headers: headers || {}, + }), + 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) } + } +} + +module.exports = { + callLLM: callLLM, + callIntegration: callIntegration, +} diff --git a/app/data/living_ui_template/requirements.txt b/app/data/living_ui_template/requirements.txt deleted file mode 100644 index fbbd4fe5..00000000 --- a/app/data/living_ui_template/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -# Python backend dependencies for Living UI -# Uncomment if backend functionality is needed - -# fastapi>=0.100.0 -# uvicorn>=0.23.0 -# sqlalchemy>=2.0.0 -# aiosqlite>=0.19.0 -# pydantic>=2.0.0 -# httpx>=0.24.0 diff --git a/app/data/living_ui_template/tsconfig.json b/app/data/living_ui_template/tsconfig.json deleted file mode 100644 index cda9bcf8..00000000 --- a/app/data/living_ui_template/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["frontend"], - "references": [{ "path": "./tsconfig.node.json" }] -} diff --git a/app/data/living_ui_template/tsconfig.node.json b/app/data/living_ui_template/tsconfig.node.json deleted file mode 100644 index 42872c59..00000000 --- a/app/data/living_ui_template/tsconfig.node.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true - }, - "include": ["vite.config.ts"] -} diff --git a/app/data/living_ui_template/vite.config.ts b/app/data/living_ui_template/vite.config.ts deleted file mode 100644 index a30ac34c..00000000 --- a/app/data/living_ui_template/vite.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react()], - server: { - port: {{PORT}}, - host: true, - proxy: { - '/api': 'http://localhost:{{BACKEND_PORT}}', - }, - }, - preview: { - port: {{PORT}}, - host: true, - proxy: { - '/api': 'http://localhost:{{BACKEND_PORT}}', - }, - }, - build: { - outDir: 'dist', - sourcemap: true, - }, -}) diff --git a/app/errors/__init__.py b/app/errors/__init__.py new file mode 100644 index 00000000..a4dc43be --- /dev/null +++ b/app/errors/__init__.py @@ -0,0 +1,5 @@ +"""App-layer error catalogue — see app/errors/codebook.py.""" + +from app.errors.codebook import CatalogError, make_error + +__all__ = ["CatalogError", "make_error"] diff --git a/app/errors/codebook.py b/app/errors/codebook.py new file mode 100644 index 00000000..800fce65 --- /dev/null +++ b/app/errors/codebook.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +""" +App-layer error codebook. + +Curated, representative entries for the highest-duplication non-LLM call +sites (see docs/error_handling_report.md and the error-catalogue plan). This +is deliberately a small proof-of-adoption set, not exhaustive coverage of +every hand-rolled error string in the app. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Dict, List + +from agent_core.core.errors import ( + ClassifiedError, + ErrorAction, + ErrorCategory, + ErrorInfo, + Severity, + redact, +) + + +@dataclass(frozen=True) +class _Spec: + category: ErrorCategory + severity: Severity + title: str + message_template: str + actions: Callable[..., List[ErrorAction]] = lambda **_: [] + + +def _settings_action(**_kwargs) -> List[ErrorAction]: + return [ErrorAction(label="Open settings", action="open_settings_model")] + + +_CODEBOOK: Dict[str, _Spec] = { + "CONFIG_NO_API_KEY": _Spec( + category=ErrorCategory.CONFIG, + severity=Severity.ERROR, + title="No API key configured", + message_template="No {provider} API key configured. Add one in Settings.", + actions=_settings_action, + ), + "CONFIG_INVALID_API_KEY": _Spec( + category=ErrorCategory.AUTH, + severity=Severity.ERROR, + title="Invalid API key", + message_template="The {provider} API key was rejected. Check your key in Settings.", + actions=_settings_action, + ), + "CONNECTION_FAILED": _Spec( + category=ErrorCategory.CONNECTION, + severity=Severity.ERROR, + title="Connection failed", + message_template="Could not reach {target}. {detail}", + ), + "CONNECTION_TIMEOUT": _Spec( + category=ErrorCategory.CONNECTION, + severity=Severity.ERROR, + title="Request timed out", + message_template="{target} did not respond in time. Try again.", + ), + "VLM_PROVIDER_UNAVAILABLE": _Spec( + category=ErrorCategory.CONFIG, + severity=Severity.ERROR, + title="Vision model unavailable", + message_template=( + "VLM is not available for provider '{provider}'. Switch VLM provider " + "in Settings to one that supports vision (e.g. anthropic, openai, " + "gemini, byteplus)." + ), + actions=_settings_action, + ), + "VLM_PROVIDER_NOT_INITIALIZED": _Spec( + category=ErrorCategory.CONFIG, + severity=Severity.ERROR, + title="Vision model not configured", + message_template=( + "VLM for provider '{provider}' is not initialized. Check that the " + "API key is configured in Settings." + ), + actions=_settings_action, + ), + "PROXY_ERROR": _Spec( + category=ErrorCategory.SERVER, + severity=Severity.ERROR, + title="Proxy request failed", + message_template="{detail}", + ), + "SUBAGENT_TIMEOUT": _Spec( + category=ErrorCategory.CONNECTION, + severity=Severity.ERROR, + title="Sub-agent call timed out", + message_template="The sub-agent LLM call did not respond within {timeout}s.", + ), +} + + +def make_error(code: str, **fmt_kwargs) -> ErrorInfo: + """Build a structured `ErrorInfo` from a codebook entry. + + `fmt_kwargs` fill the entry's message template (e.g. `provider=`, + `target=`). A missing key raises `KeyError` here, at the call site, + rather than shipping a broken `"{provider}"` literal to the UI. + + `detail` is redacted before formatting — by convention it's raw + exception text (`str(e)`), unlike `provider`/`target` which are + semantic, already-user-known values. + """ + spec = _CODEBOOK.get(code) + if spec is None: + raise KeyError(f"Unknown error code {code!r} — add it to app/errors/codebook.py") + if "detail" in fmt_kwargs: + fmt_kwargs["detail"] = redact(str(fmt_kwargs["detail"])) + message = spec.message_template.format(**fmt_kwargs) + return ErrorInfo( + category=spec.category, + code=code, + title=spec.title, + message=message, + severity=spec.severity, + actions=spec.actions(**fmt_kwargs), + ) + + +class CatalogError(ClassifiedError): + """Drop-in replacement for `raise RuntimeError(f"...")` at call sites + that have been migrated onto the codebook.""" diff --git a/app/errors/web.py b/app/errors/web.py new file mode 100644 index 00000000..fc89b7f7 --- /dev/null +++ b/app/errors/web.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +"""aiohttp helper for returning a classified error as a JSON response.""" + +from __future__ import annotations + +from aiohttp import web + +from agent_core.core.errors import ErrorInfoLike + + +def error_json_response(info: ErrorInfoLike, status: int) -> web.Response: + """Build a `web.json_response` from a classified error. + + Keeps the existing `"error"` string key (so current frontend `fetch` + consumers that only read `.error` keep working unchanged) and adds + `error_category`/`error_code` additively. + """ + code = getattr(info, "code", None) + return web.json_response( + { + "error": info.message, + "error_category": info.category.value, + **({"error_code": code} if code else {}), + }, + status=status, + ) 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/i18n/__init__.py b/app/i18n/__init__.py index 6638d932..fac368dd 100644 --- a/app/i18n/__init__.py +++ b/app/i18n/__init__.py @@ -14,6 +14,11 @@ classify_provider_error(exc, *, provider, model="") -> str Map a raw exception to a human-readable, locale-aware error string. +classify_provider_error_info(exc, *, provider, model="") -> ErrorInfo + Same classification, returned as a structured ErrorInfo (category, + severity, actions preserved) for callers that raise ClassifiedError + instead of just logging a string. + Adding a new provider --------------------- Add one entry to ``_PROVIDER_DISPLAY`` in agent_core/core/impl/llm/errors.py. @@ -30,6 +35,7 @@ import json from pathlib import Path +from agent_core.core.errors import ErrorInfo from agent_core.core.impl.llm.errors import ( ErrorCategory, classify_llm_error, @@ -93,28 +99,55 @@ def classify_provider_error( ) -> str: """Map *exc* to a human-readable, locale-aware error string. + Thin wrapper over ``classify_provider_error_info`` for callers that only + need the rendered string. + """ + return classify_provider_error_info(exc, provider=provider, model=model).message + + +def classify_provider_error_info( + exc: Exception, + *, + provider: str, + model: str = "", +) -> ErrorInfo: + """Map *exc* to a structured, locale-aware ``ErrorInfo``. + Classification (status codes, structured bodies, SDK exception types, - CJK error text) is done by ``classify_llm_error``; this function only - renders the resulting category through the locale catalog. + CJK error text) is done by ``classify_llm_error``; this function renders + the resulting category through the locale catalog for ``.message`` while + preserving category/severity/actions for callers that want to raise a + classified exception (see ``ClassifiedError``) instead of just logging a + string. """ info = classify_llm_error(exc, provider=provider, model=model or None) label = provider_display_name(provider) key = _CATEGORY_KEYS.get(info.category) if key: - return t(key, provider_label=label, model=model or "the requested model") - - if info.category is ErrorCategory.CONNECTION: + message = t(key, provider_label=label, model=model or "the requested model") + elif info.category is ErrorCategory.CONNECTION: low = (info.raw_message or str(exc)).lower() if "timeout" in low or "timed out" in low: - return t("provider_timeout", provider_label=label) - return t("provider_connection", provider_label=label) - - # BAD_REQUEST / SERVER / UNKNOWN — generic template, with the upstream - # detail appended so misclassified 400s and provider outages surface - # their cause. raw_message is already truncated by the classifier. - result = t("provider_generic", provider_label=label) - detail = (info.raw_message or "").strip() - if detail: - result = f"{result}: {detail}" - return result + message = t("provider_timeout", provider_label=label) + else: + message = t("provider_connection", provider_label=label) + else: + # BAD_REQUEST / SERVER / UNKNOWN — generic template, with the + # upstream detail appended so misclassified 400s and provider + # outages surface their cause. raw_message is already truncated by + # the classifier. + message = t("provider_generic", provider_label=label) + detail = (info.raw_message or "").strip() + if detail: + message = f"{message}: {detail}" + + return ErrorInfo( + category=info.category, + code=info.code or f"LLM_{info.category.value.upper()}", + title=info.title, + message=message, + severity=info.severity, + actions=info.actions, + raw_message=info.raw_message, + ) diff --git a/app/internal_action_interface.py b/app/internal_action_interface.py index 87724327..97a5feea 100644 --- a/app/internal_action_interface.py +++ b/app/internal_action_interface.py @@ -138,17 +138,15 @@ def _ensure_vlm_available(cls) -> None: if not cls.vlm_interface.is_initialized: from agent_core.core.models.model_registry import MODEL_REGISTRY from agent_core.core.models.types import InterfaceType + from app.errors import CatalogError, make_error provider = cls.vlm_interface.provider or "unknown" if MODEL_REGISTRY.get(provider, {}).get(InterfaceType.VLM) is None: - raise RuntimeError( - f"VLM is not available for provider '{provider}'. " - "Switch VLM provider in setting to the one " - "that supports vision (e.g. anthropic, openai, gemini, byteplus)." + raise CatalogError( + make_error("VLM_PROVIDER_UNAVAILABLE", provider=provider) ) - raise RuntimeError( - f"VLM for provider '{provider}' is not initialized. " - "Check that the API key is configured in app/config/settings.json." + raise CatalogError( + make_error("VLM_PROVIDER_NOT_INITIALIZED", provider=provider) ) @classmethod diff --git a/app/living_ui/__init__.py b/app/living_ui/__init__.py index 12e3f527..388bf36f 100644 --- a/app/living_ui/__init__.py +++ b/app/living_ui/__init__.py @@ -21,7 +21,6 @@ broadcast_living_ui_ready, broadcast_living_ui_created, broadcast_living_ui_progress, - broadcast_living_ui_question, dispatch_living_ui_data_changed, make_todo_broadcast_hook, ) @@ -36,7 +35,6 @@ "broadcast_living_ui_ready", "broadcast_living_ui_created", "broadcast_living_ui_progress", - "broadcast_living_ui_question", "dispatch_living_ui_data_changed", "make_todo_broadcast_hook", "restart_living_ui", diff --git a/app/living_ui/agent_view.py b/app/living_ui/agent_view.py new file mode 100644 index 00000000..b8462bcc --- /dev/null +++ b/app/living_ui/agent_view.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- +""" +What the agent and the user each SEE of a Living UI. + +Two jobs, both about presentation rather than mechanism: + +1. `schema_block()` — the app's data model, inlined into the agent's prompt. + Advisory pointers do not work on weak models: across two recorded incidents + the agent ignored "Read LIVING_UI.md", never ran `lui ops`, and guessed + collection names instead (`items`, `tasks`). It cannot ignore what is + already in its context. + +2. `humanise_write()` — one plain sentence describing what a write actually + did, built from the stored record. The user should never read + `cards.create [kapp872i5etufxb] due_date='2026-07-31 00:00:00.000Z'`. + +Both read the app's own A2APP `describe` surface, so neither can drift from +what the app actually is. +""" + +from __future__ import annotations + +import json +import time +import urllib.request +from datetime import datetime +from typing import Any, Dict, Optional + +try: + from app.logger import logger +except Exception: # pragma: no cover + import logging + + logger = logging.getLogger(__name__) + +# describe is cheap but not free, and it is fetched on every user message. +# A few minutes of staleness is harmless: the app validates writes itself, so +# a stale block can only cost a retry, never a bad write. +_CACHE: Dict[str, tuple] = {} +_TTL_SECONDS = 300 +_TIMEOUT_SECONDS = 2.0 + +_SKIP_FIELDS = {"id", "collectionId", "collectionName", "created", "updated"} + + +def _describe(base_url: str) -> Optional[Dict[str, Any]]: + """Fetch (and cache) the app's data model. None when the app is down.""" + cached = _CACHE.get(base_url) + if cached is not None and time.time() - cached[0] < _TTL_SECONDS: + return cached[1] + try: + request = urllib.request.Request( + f"{base_url}/api/_a2app/describe", headers={"User-Agent": "CraftBot"} + ) + with urllib.request.urlopen(request, timeout=_TIMEOUT_SECONDS) as response: + data = json.loads(response.read().decode("utf-8")) + _CACHE[base_url] = (time.time(), data) + return data + except Exception as e: + logger.debug(f"[AGENT_VIEW] describe unavailable at {base_url}: {e}") + _CACHE[base_url] = (time.time(), None) + return None + + +def _type_label(spec: Dict[str, Any]) -> str: + """Render a field's type the way the agent needs to see it — including the + enum's actual values, whose absence caused a rejected write.""" + kind = str(spec.get("type", "string")) + if kind == "enum" and spec.get("values"): + return "one of " + "|".join(str(v) for v in spec["values"]) + if kind in ("ref", "list") and spec.get("entity"): + arrow = "->" if kind == "ref" else "->[]" + return f"{arrow}{spec['entity']}" + if spec.get("format"): + return str(spec["format"]) + return kind + + +def schema_block(base_url: str, max_chars: int = 2000) -> Optional[str]: + """The data model, compact enough to sit in every prompt. + + Read-only and server-managed fields are omitted: the agent cannot write + them, so naming them only invites it to try. + """ + described = _describe(base_url) + if not described: + return None + entities = described.get("entities") or {} + if not entities: + return None + + lines = [] + for name, entity in entities.items(): + fields = [] + for field_name, spec in (entity.get("fields") or {}).items(): + if spec.get("readOnly"): + continue + star = "*" if spec.get("required") else "" + 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 + block = "\n".join(f" {n}: {len((e.get('fields') or {}))} fields" for n, e in entities.items()) + 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) + if not described: + return None + target = (described.get("entities") or {}).get(entity) or {} + label_field = target.get("label") + if not label_field: + return None + try: + url = f"{base_url}/api/collections/{entity}/records/{record_id}" + with urllib.request.urlopen(url, timeout=_TIMEOUT_SECONDS) as response: + record = json.loads(response.read().decode("utf-8")) + value = record.get(label_field) + return str(value) if value else None + except Exception: + return None + + +def _humanise_date(value: str) -> str: + """'2026-07-31 00:00:00.000Z' -> 'Fri 31 Jul'. Times are kept when present.""" + text = str(value).strip() + try: + stamp = datetime.fromisoformat(text.replace("Z", "+00:00").replace(" ", "T", 1)) + except Exception: + return text[:10] or text + if stamp.hour == 0 and stamp.minute == 0: + return stamp.strftime("%a %-d %b") + return stamp.strftime("%a %-d %b %H:%M") + + +_VERBS = {"create": "Added", "update": "Updated", "delete": "Removed"} + + +def humanise_write(base_url: str, collection: str, op: str, record: Dict[str, Any]) -> str: + """One sentence a person can read, built from what was actually stored. + + Example: Added "Eat chicken" to To Do — due Fri 31 Jul, priority medium + """ + described = _describe(base_url) + entities = (described or {}).get("entities") or {} + entity = entities.get(collection) or {} + specs = entity.get("fields") or {} + label_field = entity.get("label") + + name = record.get(label_field) if label_field else None + verb = _VERBS.get(op, "Changed") + subject = f'"{name}"' if name else f"a {collection.rstrip('s')}" + + into = "" + details = [] + for key, value in record.items(): + if key in _SKIP_FIELDS or key == label_field: + continue + if value in ("", None, [], {}, False, 0): + continue + spec = specs.get(key) or {} + kind = str(spec.get("type", "")) + + if kind == "ref" and spec.get("entity"): + resolved = _resolve_ref(base_url, str(spec["entity"]), str(value)) + if resolved and not into: + into = f" to {resolved}" # the containing thing reads best inline + continue + details.append(f"{key.replace('_', ' ')} {resolved or value}") + elif kind == "datetime": + details.append(f"{key.replace('_', ' ').replace(' date', '')} {_humanise_date(value)}") + elif kind in ("json", "binary", "list"): + continue # nothing a person wants to read + else: + details.append(f"{key.replace('_', ' ')} {value}") + + sentence = f"{verb} {subject}{into}" + if details: + sentence += " — " + ", ".join(details[:4]) + return sentence diff --git a/app/living_ui/broadcast.py b/app/living_ui/broadcast.py index c32ea67f..04d6ebd6 100644 --- a/app/living_ui/broadcast.py +++ b/app/living_ui/broadcast.py @@ -31,9 +31,9 @@ Callable[[str, List[Dict[str, Any]]], Awaitable[None]] ] = None _broadcast_data_changed_callback: Optional[Callable[[str], Awaitable[None]]] = None -_broadcast_question_callback: Optional[Callable[[str, str, str], Awaitable[None]]] = ( - None -) +_broadcast_build_event_callback: Optional[ + Callable[[str, Dict[str, Any]], Awaitable[None]] +] = None # Captured at register time so cross-thread dispatchers (action handlers # running on a worker thread pool) can schedule coroutines onto the main loop. @@ -48,7 +48,9 @@ def register_broadcast_callbacks( ] = None, broadcast_data_changed: Optional[Callable[[str], Awaitable[None]]] = None, broadcast_created: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None, - broadcast_question: Optional[Callable[[str, str, str], Awaitable[None]]] = None, + broadcast_build_event: Optional[ + Callable[[str, Dict[str, Any]], Awaitable[None]] + ] = None, ) -> None: """Register broadcast callbacks for Living UI actions to use. @@ -59,13 +61,14 @@ def register_broadcast_callbacks( _broadcast_created_callback, \ _broadcast_progress_callback, \ _broadcast_todos_callback - global _broadcast_data_changed_callback, _broadcast_question_callback, _main_loop + global _broadcast_data_changed_callback, _main_loop + global _broadcast_build_event_callback _broadcast_ready_callback = broadcast_ready _broadcast_created_callback = broadcast_created _broadcast_progress_callback = broadcast_progress _broadcast_todos_callback = broadcast_todos _broadcast_data_changed_callback = broadcast_data_changed - _broadcast_question_callback = broadcast_question + _broadcast_build_event_callback = broadcast_build_event try: _main_loop = asyncio.get_running_loop() except RuntimeError: @@ -104,30 +107,6 @@ async def broadcast_living_ui_created(project: Dict[str, Any]) -> bool: return False -async def broadcast_living_ui_question(session_id: str, message: str) -> bool: - """Mirror an agent's final question onto the Living UI creation screen, - so the user can answer even with the chat closed. - - Resolves the *creating* project from the session id and no-ops if the - session isn't a Living UI project session. The on-screen answer is posted - back through the normal chat path into the same session, which wakes the - waiting run. Returns True if mirrored. - """ - if not session_id or not _broadcast_question_callback: - return False - manager = get_living_ui_manager() - if not manager: - return False - try: - project = manager.get_project_by_session_id(session_id) - except Exception: - project = None - if not project or getattr(project, "status", None) != "creating": - return False - await _broadcast_question_callback(project.id, session_id, message) - return True - - async def broadcast_living_ui_progress( project_id: str, phase: str, progress: int, message: str ) -> bool: @@ -176,6 +155,41 @@ def _dispatch_todos(project_id: str, todos: List[Dict[str, Any]]) -> bool: return False +async def _broadcast_build_event_async( + project_id: str, event: Dict[str, Any] +) -> bool: + """Internal async broadcaster used by the sync dispatcher below.""" + if _broadcast_build_event_callback: + await _broadcast_build_event_callback(project_id, event) + return True + return False + + +def dispatch_build_event(project_id: str, event: Dict[str, Any]) -> bool: + """Thread-safe build-event broadcast (called from the read-only + construction observer). Same dual-context handling as _dispatch_todos: + schedules onto the running loop, or onto the captured main loop from a + worker thread. Fire-and-forget — never blocks the action pipeline.""" + if not _broadcast_build_event_callback: + return False + + coro = _broadcast_build_event_async(project_id, event) + + try: + running = asyncio.get_running_loop() + running.create_task(coro) + return True + except RuntimeError: + pass + + if _main_loop is not None and _main_loop.is_running(): + asyncio.run_coroutine_threadsafe(coro, _main_loop) + return True + + coro.close() + return False + + async def _broadcast_data_changed_async(project_id: str) -> bool: """Internal async broadcaster used by the sync dispatcher below.""" if _broadcast_data_changed_callback: @@ -235,5 +249,12 @@ def hook(session: Any, todos: List[Dict[str, Any]]) -> None: f"[LIVING_UI] Broadcasting {len(todos)} todos to project {project.id}" ) _dispatch_todos(project.id, todos) + # Narrate plan milestones into the build feed (start / complete rows). + try: + from . import construction_events + + construction_events.record_todo_transitions(project.id, todos) + except Exception: + pass return hook diff --git a/app/living_ui/construction_events.py b/app/living_ui/construction_events.py new file mode 100644 index 00000000..d7f6ec85 --- /dev/null +++ b/app/living_ui/construction_events.py @@ -0,0 +1,560 @@ +"""Living UI build-event pipeline — the construction dock's data source. + +Derives structured "the app is being built" events from actions the agent +already performs (write_file / stream_edit / living_ui_scaffold / +living_ui_notify_ready). The agent is NEVER asked to narrate progress: events +are classified by matching the action's file path against projects currently +being built, and entity names (React components, PocketBase routes/collections) +are extracted from the written content by regex. + +READ-ONLY BY CONTRACT. Wired into ActionManager's on_action_start / +on_action_end hooks (see browser_adapter). Every path here is fail-silent and +mutates nothing about the build — a visualization bug must never break a build. +The executor already wraps these hooks in try/except; we wrap again here and do +only fast, synchronous work, handing the broadcast off to the event loop. +""" + +import re +import time +from collections import deque +from pathlib import Path +from typing import Any, Deque, Dict, List, Optional, Tuple + +try: + from loguru import logger +except ImportError: + import logging + + logger = logging.getLogger(__name__) + +from ._state import get_living_ui_manager + +# Actions we derive build events from. Everything else is ignored at the +# hook's first line, so the per-action overhead is one set lookup. The read/ +# search/run/verify actions don't change the app, but the agent performs them +# constantly — surfacing them keeps the feed lively during the long reasoning +# stretches between file writes. +_FILE_ACTIONS = frozenset({"write_file", "stream_edit"}) +_READ_ACTIONS = frozenset({"read_file", "list_folder"}) +_SEARCH_ACTIONS = frozenset({"find_files", "grep_files"}) +_WATCHED_ACTIONS = ( + _FILE_ACTIONS + | _READ_ACTIONS + | _SEARCH_ACTIONS + | frozenset( + { + "living_ui_scaffold", + "living_ui_notify_ready", + "run_shell", + "spawn_subagent", + "browser_probe", + } + ) +) + +# run_id -> recorded start info, popped on action end. Bounded as a +# belt-and-braces guard against end hooks that never fire. +_PENDING: Dict[str, Dict[str, Any]] = {} +_PENDING_MAX = 500 + +# Per-project ring buffers so a page refresh mid-build can replay the feed. +_BUFFER_MAX = 200 +_BUFFERS: Dict[str, Deque[Dict[str, Any]]] = {} + +# Last-seen todo status per project, for emitting start/complete transitions. +_PREV_TODOS: Dict[str, Dict[str, str]] = {} + +_SNIPPET_MAX_LINES = 18 +_SNIPPET_MAX_CHARS = 900 + +# ── entity extraction (V2: PocketBase + React kit) ────────────────────────── + +# React components: export function/const/class Foo +_COMPONENT_RE = re.compile( + r"^export\s+(?:default\s+)?(?:function|const|class)\s+([A-Z]\w*)", re.MULTILINE +) +# Custom API routes in pb_hooks: routerAdd("POST", "/api/ops/x", ...) +_PB_ROUTE_RE = re.compile( + r"routerAdd\(\s*[\"'](\w+)[\"']\s*,\s*[\"']([^\"']+)", re.IGNORECASE +) +# PocketBase collections in a migration: new Collection({ ... name: "posts" ... }) +_PB_COLLECTION_RE = re.compile( + r"new\s+Collection\([^)]*?[\"']?name[\"']?\s*:\s*[\"'](\w+)[\"']", + re.IGNORECASE | re.DOTALL, +) + + +def _area_for(rel_path: str) -> str: + p = rel_path.replace("\\", "/").lower() + if p.startswith("pb/pb_migrations/") or p.startswith("pb/pb_hooks/"): + return "backend" + if p.startswith("frontend/"): + return "frontend" + if p == "operations.json" or p.startswith("config"): + return "config" + if p.startswith("reference/") or p.endswith(".md"): + return "docs" + return "other" + + +def _extract_entities(rel_path: str, content: str) -> Dict[str, List[str]]: + """Pull human-recognizable names out of written content, by file kind.""" + if not content: + return {} + entities: Dict[str, List[str]] = {} + p = rel_path.replace("\\", "/").lower() + if p.startswith("pb/pb_hooks/") and p.endswith(".js"): + routes = [f"{m.upper()} {path}" for m, path in _PB_ROUTE_RE.findall(content)] + if routes: + entities["routes"] = routes + if p.startswith("pb/pb_migrations/") and p.endswith(".js"): + collections = list(dict.fromkeys(_PB_COLLECTION_RE.findall(content))) + if collections: + entities["models"] = collections + if p.startswith("frontend/") and p.endswith((".tsx", ".ts", ".jsx")): + names = _COMPONENT_RE.findall(content) + if names: + entities["components"] = names + return entities + + +# ── authoritative project snapshot (source of truth for the dock chips) ───── +# The chips count what actually EXISTS in the project on disk, not what a +# single write payload happened to contain — so scaffold-created collections +# and incremental edits are all reflected, and the numbers can't drift. +# Read-only, fail-silent, cheap (a handful of small files). + +# Declared components: function/class Foo (any capitalized top-level decl). +_COMPONENT_DECL_RE = re.compile( + r"(?:export\s+)?(?:default\s+)?(?:function|class)\s+([A-Z]\w*)", re.MULTILINE +) +# Rendered JSX tags: / + +
+ + ) : ( + <> + +

Writing the requirements & starting the build…

+

+ Your answers are being turned into a complete specification. You'll be + taken to the live build view in a moment. +

+ + )} +
+ ) + } + + if (step === 'interview') { + return ( +
+ {interviewLoading ? ( +
+ +

Preparing your interview…

+

+ The agent is reading your configuration and deciding what it still + needs to know. +

+
+ ) : interviewError ? ( +
+

{interviewError}

+
+ + + +
+
+ ) : question ? ( +
+
+ + + Question {qIndex + 1} of {questions.length} + + +
+

{question.question}

+ {question.why &&

{question.why}

} +
+ {question.options.map(opt => { + const selected = (answers[question.id] || []).includes(opt) + return ( + + ) + })} +
+
+ setFreeText(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && canContinue) handleContinue() }} + /> + +
+
+ ) : null} +
+ ) + } + + // step === 'configure' + return ( +
+
+
+
+ +
+
+ + {iconOpen && ( +
+ +
+ {Object.entries(LIVING_UI_ICONS).map(([iconName, Cmp]) => { + const value = `lucide:${iconName}` + return ( + + ) + })} +
+
+ )} +
+ setName(e.target.value)} + maxLength={50} + /> +
+ {errors.name && {errors.name}} +
+ +
+ +