From 174ab37a95e4a2d628dad00c400070abb0b20b60 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 09:12:39 +0000 Subject: [PATCH 1/4] Add self-correction: moi debug logs + moi call-server-fn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent that builds applets could not tell when they broke: a widget compiles, then fails to load in the browser, crashes on render, or its server function throws — and nothing reaches the agent until the user complains. Close the loop with two legs (spec: docs/self-correction.md): - moi debug logs — an applet error journal under the new experimental `moi debug` command group. A per-workspace, in-memory ring buffer fed server-side (RPC failures, build failures) and by the browser (load failures, render crashes via the error boundary, window errors attributed by bundle-URL stack match, POSTed to /applet-log with a hostile-input guard). Identical errors dedup into a count; a successful rebuild clears the applet's standing entries; `moi bundle` ends with a nudge when errors remain on record. - moi call-server-fn — invoke one .server.ts function directly. Each invocation runs in an EPHEMERAL one-shot worker: spawn, single call, kill — fully isolated from the warm pool the widgets use, with the same env injection, module loading, timeout, and devalue wire format. Args as one JSON array; results render via Bun.inspect with duration. The moi-workspace skill grows a "Verify your work" section teaching the loop (bump to 0.6.0), with `moi debug` marked experimental. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01M8rSCv39HrkfaH23tceb2q --- .../features/applets/WidgetErrorBoundary.tsx | 22 ++- client/features/applets/WidgetShell.tsx | 9 +- client/features/applets/applet-log.ts | 64 +++++++ client/features/applets/useApplet.ts | 28 ++- client/features/workspace/WorkspaceScreen.tsx | 9 +- client/main.tsx | 6 + docs/self-correction.md | 149 ++++++++++++++++ lib/types.ts | 34 ++++ server/api.ts | 48 ++++++ server/applet-log.ts | 149 ++++++++++++++++ server/cli.ts | 163 +++++++++++++++++- server/control.ts | 82 ++++++++- server/functions.ts | 58 +++++-- server/test/applet-log.test.ts | 161 +++++++++++++++++ server/test/functions.test.ts | 40 ++++- server/views.ts | 5 + server/widgets.ts | 5 + .../.claude/skills/moi-workspace/SKILL.md | 35 +++- 18 files changed, 1031 insertions(+), 36 deletions(-) create mode 100644 client/features/applets/applet-log.ts create mode 100644 docs/self-correction.md create mode 100644 server/applet-log.ts create mode 100644 server/test/applet-log.test.ts diff --git a/client/features/applets/WidgetErrorBoundary.tsx b/client/features/applets/WidgetErrorBoundary.tsx index 1f55924d..d823d906 100644 --- a/client/features/applets/WidgetErrorBoundary.tsx +++ b/client/features/applets/WidgetErrorBoundary.tsx @@ -2,15 +2,22 @@ import { Component, type ErrorInfo, type ReactNode } from 'react' import { IconAlertTriangle } from '@tabler/icons-react' -type Props = { +import { reportAppletError } from '@/client/features/applets/applet-log' +import type { AppletKind } from '@/lib/types' + +type WidgetErrorBoundaryProps = { name: string + // Applet attribution for the error journal (docs/self-correction.md). The + // boundary wraps both widgets (WidgetShell) and views (WorkspaceScreen). + kind: AppletKind + workspaceId: string resetKey: number children: ReactNode } type State = { error: Error | null } -export class WidgetErrorBoundary extends Component { +export class WidgetErrorBoundary extends Component { state: State = { error: null } static getDerivedStateFromError(error: Error): State { @@ -19,9 +26,18 @@ export class WidgetErrorBoundary extends Component { componentDidCatch(error: Error, info: ErrorInfo) { console.error(`[widget:${this.props.name}]`, error, info.componentStack) + // Journal the crash so `moi debug logs` can surface it to the agent — the + // console line above dies in a console nobody reads. + reportAppletError(this.props.workspaceId, { + source: 'render', + kind: this.props.kind, + name: this.props.name, + message: error.message || String(error), + stack: [error.stack, info.componentStack].filter(Boolean).join('\n') + }) } - componentDidUpdate(prev: Props) { + componentDidUpdate(prev: WidgetErrorBoundaryProps) { if (prev.resetKey !== this.props.resetKey && this.state.error) { this.setState({ error: null }) } diff --git a/client/features/applets/WidgetShell.tsx b/client/features/applets/WidgetShell.tsx index 6db30cbe..582217a2 100644 --- a/client/features/applets/WidgetShell.tsx +++ b/client/features/applets/WidgetShell.tsx @@ -2,6 +2,7 @@ import { AnimatePresence, motion } from 'motion/react' import { AppletMount } from '@/client/features/applets/AppletMount' import { useWidget } from '@/client/features/applets/useApplet' +import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' import { WidgetErrorBoundary } from './WidgetErrorBoundary' @@ -10,6 +11,7 @@ type WidgetShellProps = { } export function WidgetShell({ name }: WidgetShellProps) { + const workspaceId = useWorkspaceId() const widget = useWidget(name) return ( @@ -23,7 +25,12 @@ export function WidgetShell({ name }: WidgetShellProps) { exit={{ opacity: 0, filter: 'blur(4px)' }} transition={{ duration: 0.45, ease: 'easeInOut' }} > - + diff --git a/client/features/applets/applet-log.ts b/client/features/applets/applet-log.ts new file mode 100644 index 00000000..e0730389 --- /dev/null +++ b/client/features/applets/applet-log.ts @@ -0,0 +1,64 @@ +import type { AppletClientError } from '@/lib/types' + +// Fire-and-forget reporter for browser-side applet errors — the client half of +// the applet error journal (docs/self-correction.md). Failures only the user's +// tab sees (module load failures, render crashes, window errors attributed to +// a bundle) are POSTed into the workspace journal so `moi debug logs` can show +// them to the agent. Always on: when the user says "it's broken", the crash +// their tab saw five minutes ago is already on record. +// +// Reporting must never hurt the app: fetch errors are swallowed, and a +// per-error cooldown keeps a crash loop from turning into a POST storm (the +// server dedups identical errors too — this just spares the network). +const COOLDOWN_MS = 5_000 +const lastSent = new Map() + +export function reportAppletError(workspaceId: string, event: AppletClientError): void { + const key = [workspaceId, event.source, event.kind, event.name, event.message].join('\0') + const now = Date.now() + const last = lastSent.get(key) + if (last !== undefined && now - last < COOLDOWN_MS) return + lastSent.set(key, now) + // Bound the cooldown map: drop the oldest half if it ever balloons. + if (lastSent.size > 256) { + for (const k of [...lastSent.keys()].slice(0, 128)) lastSent.delete(k) + } + void fetch(`/api/workspaces/${workspaceId}/applet-log`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ events: [event] }) + }).catch(() => {}) +} + +// Attribute a window-level error to an applet by the bundle URL in its stack: +// applet bundles load from `/api/workspaces//(widgets|views)//…`, so +// any frame from one pins the error to that applet — and carries the workspace +// id along. Host-app errors never match; they are not the journal's business. +const BUNDLE_URL_RE = /\/api\/workspaces\/([^/\s)]+)\/(widgets|views)\/([^/\s)]+)\// + +let installed = false + +// Catch errors that escape React entirely — event handlers, async effects, +// unawaited promises — which no error boundary sees. Installed once at startup. +export function installAppletErrorHook(): void { + if (installed) return + installed = true + + const report = (raw: unknown) => { + const err = raw instanceof Error ? raw : null + const stack = err?.stack ?? '' + const m = BUNDLE_URL_RE.exec(stack) + if (!m) return + const [, workspaceId, segment, name] = m + reportAppletError(workspaceId, { + source: 'window', + kind: segment === 'widgets' ? 'widget' : 'view', + name, + message: err?.message || String(raw), + stack + }) + } + + window.addEventListener('error', e => report(e.error)) + window.addEventListener('unhandledrejection', e => report(e.reason)) +} diff --git a/client/features/applets/useApplet.ts b/client/features/applets/useApplet.ts index b352201a..8d9f6d74 100644 --- a/client/features/applets/useApplet.ts +++ b/client/features/applets/useApplet.ts @@ -9,8 +9,10 @@ import { invalidateApplet, setCachedApplet } from '@/client/features/applets/applet-cache' +import { reportAppletError } from '@/client/features/applets/applet-log' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' import { type WorkspaceEvent, useWorkspaceEvent } from '@/client/runtime/useWorkspaceEvents' +import type { AppletKind } from '@/lib/types' type AppletState = | { status: 'loading'; version: number } @@ -21,19 +23,22 @@ type AppletState = // MEI events tell it to reload. Everything else — dynamic import, caching, // cache-busting — is shared. An applet is an agent-authored UI unit loaded as an // ESM module and mounted into the workspace. -type AppletKind = { +type AppletKindSpec = { + kind: AppletKind segment: AppletSegment // True when this MEI event means the named applet should cache-bust + reload. shouldReload: (event: WorkspaceEvent, name: string) => boolean } -const WIDGET_KIND: AppletKind = { +const WIDGET_KIND: AppletKindSpec = { + kind: 'widget', segment: 'widgets', shouldReload: (e, name) => (e.type === 'widget:updated' && e.name === name) || e.type === 'widgets:refresh' } -const VIEW_KIND: AppletKind = { +const VIEW_KIND: AppletKindSpec = { + kind: 'view', segment: 'views', shouldReload: (e, name) => e.type === 'view:updated' && e.name === name } @@ -59,7 +64,7 @@ function loadApplet( return promise } -function useApplet(kind: AppletKind, name: string): AppletState { +function useApplet(kind: AppletKindSpec, name: string): AppletState { const workspaceId = useWorkspaceId() const [state, setState] = useState({ status: 'loading', version: 0 }) @@ -69,10 +74,19 @@ function useApplet(kind: AppletKind, name: string): AppletState { .then(Component => setState(prev => ({ status: 'ready', Component, version: prev.version + 1 })) ) - .catch(err => + .catch(err => { + // Journal the load failure so `moi debug logs` can surface it to the + // agent — otherwise only this tab ever sees it (docs/self-correction.md). + reportAppletError(workspaceId, { + source: 'load', + kind: kind.kind, + name, + message: String(err), + ...(err instanceof Error && err.stack ? { stack: err.stack } : {}) + }) setState(prev => ({ status: 'error', error: String(err), version: prev.version + 1 })) - ) - }, [kind.segment, workspaceId, name]) + }) + }, [kind, workspaceId, name]) useEffect(() => { load() diff --git a/client/features/workspace/WorkspaceScreen.tsx b/client/features/workspace/WorkspaceScreen.tsx index 48d0ecc2..51991a7b 100644 --- a/client/features/workspace/WorkspaceScreen.tsx +++ b/client/features/workspace/WorkspaceScreen.tsx @@ -29,6 +29,7 @@ import { ViewBuilderTab } from '@/client/features/views/ViewBuilderTab' import { useViewBuilderActions } from '@/client/features/views/useViewBuilderActions' import { useFitsSplitLayout } from '@/client/features/workspace/useFitsSplitLayout' import { useWorkspaceTheme } from '@/client/features/workspace/useWorkspaceTheme' +import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' import { useWorkspaceLayoutCtx } from '@/client/features/workspace/WorkspaceLayoutContext' import { resolveAppIcon } from '@/client/lib/app-icon-registry' import { cn } from '@/client/lib/cn' @@ -100,6 +101,7 @@ type ViewAppProps = { // layout + scroll, so we give it a plain filled box and fade it in (mirroring // WidgetShell, but full-area instead of a grid cell). function ViewApp({ view }: ViewAppProps) { + const workspaceId = useWorkspaceId() const bundle = useView(view.id) return ( @@ -120,7 +122,12 @@ function ViewApp({ view }: ViewAppProps) { exit={{ opacity: 0, filter: 'blur(4px)' }} transition={{ duration: 0.3, ease: 'easeInOut' }} > - + diff --git a/client/main.tsx b/client/main.tsx index 8ac9ceb6..ae3b9969 100644 --- a/client/main.tsx +++ b/client/main.tsx @@ -3,6 +3,7 @@ import { Agentation } from 'agentation' import { createRoot } from 'react-dom/client' import { Router } from 'wouter' +import { installAppletErrorHook } from '@/client/features/applets/applet-log' import { initConnection } from '@/client/features/chat/chat-connection' import { AppRouter } from './app/AppRouter' @@ -12,6 +13,11 @@ const queryClient = new QueryClient() // live frames fold into the RQ transcript cache. Lives for the page's lifetime. initConnection(queryClient) +// Catch applet errors that escape React (handlers, async effects) and journal +// them for `moi debug logs` — attribution is by bundle URL in the stack, so +// host-app errors never match (see features/applets/applet-log.ts). +installAppletErrorHook() + export function mount(el: HTMLElement) { function Root() { return ( diff --git a/docs/self-correction.md b/docs/self-correction.md new file mode 100644 index 00000000..66f0591e --- /dev/null +++ b/docs/self-correction.md @@ -0,0 +1,149 @@ +# Self-correction + +**Key idea:** the agent that builds applets should also be able to _tell when they're broken_ +— without waiting for the user to complain. Today the feedback loop ends at `moi bundle`: a +widget can compile fine, then fail to load in the browser, crash on render, or call a server +function that throws — and the agent learns none of it. + +Self-correction closes the loop with two legs: + +| leg | command | what it answers | +| -------- | -------------------- | --------------------------------------------------- | +| **feel** | `moi debug logs` | "did anything break at runtime since I last built?" | +| **poke** | `moi call-server-fn` | "does this server function actually work?" | + +Both ride the existing plumbing: the control port for CLI round-trips and the functions +worker for direct invocation. Nothing new is invented — the loop is wired out of parts that +exist. + +## Failure taxonomy + +Where an applet can go wrong after `moi bundle` succeeds, and which leg catches it: + +1. **Module load failure** — the browser's dynamic `import()` of the bundle rejects (bad + top-level code, missing default export). Caught by `useApplet`, shown to the user, + previously invisible to the agent. → **logs** (`load`). +2. **Render crash** — the component throws during render; `WidgetErrorBoundary` catches it + and `console.error`s into a console nobody reads. → **logs** (`render`). +3. **Async runtime error** — an event handler or effect throws outside React's render path; + surfaces as a window `error`/`unhandledrejection`. Attributable to an applet by matching + stack frames against its bundle URL. → **logs** (`window`). +4. **Server-function failure** — `.server.ts` throws (or times out) behind the RPC route; the + server returns 500 and only the browser sees the message. → **logs** (`rpc`), and + preventable up front with **call-server-fn**. +5. **Build failure** — already reported by `moi bundle`, but an old failure is easy to lose + track of turns later. → **logs** (`build`) keeps it on record until a good build. + +## `moi debug` — the workspace debugging toolbox (experimental) + +`moi debug` is an **experimental** command group for inspecting a running workspace. It ships +with one subcommand — `logs` — and is expected to grow (worker state, RPC traces, …). Being +experimental means its output format and flags may change between releases; the agent should +treat it as a diagnostic aid, not a stable API. + +### `moi debug logs` — the applet error journal + +A per-workspace, in-memory ring buffer of applet runtime errors, queryable from the CLI. + +``` +moi debug logs # print errors on record (oldest → newest) +moi debug logs --json # machine-readable, includes stacks + epoch timestamps +moi debug logs --clear # wipe the buffer +``` + +- **Entry shape:** `{ ts, source, kind?, name?, module?, fn?, message, stack?, count }` — + `source` is one of `build | load | render | window | rpc`; `kind`/`name` attribute the + applet when known; `module`/`fn` pin down the server function for `rpc` entries. +- **Producers.** Server-side: the RPC route records every failed function call; the bundle + pipeline records build failures. Browser-side: the client POSTs `load`, `render`, and + `window` events to `POST /api/workspaces/:id/applet-log` — fire-and-forget, throttled, + size-capped. Reporting is **always on**: when the user says "it's broken", the crash their + tab saw five minutes ago is already on record. +- **Dedup.** A repeat of an identical error (same source + attribution + message) bumps a + `count` and its timestamp instead of appending — a render crash loop is one line, not a + hundred. +- **Lifecycle.** The buffer holds the _standing_ problems since each applet's last good + build: a successful rebuild of an applet clears its entries (including `rpc` entries for + the server modules that were rebuilt with it). The buffer is bounded (100 entries/workspace) + and in-memory — a server restart starts clean, which is correct: the journal describes the + current runtime, not history. +- **The nudge.** `moi bundle` output ends with `ℹ N runtime error(s) on record — moi debug +logs` whenever the buffer is non-empty after the rebuild, so the agent is pointed at + standing breakage exactly when it's paying attention. + +`moi call-server-fn` invocations deliberately do **not** record — a failing smoke test is +feedback the agent already has in hand. + +## `moi call-server-fn` — poke a server function + +Invoke one exported `.server.ts` function directly. Server functions only — this is not a +general script runner (that's `moi env exec`). + +``` +moi call-server-fn widgets/hello/getGreeting # no arguments +moi call-server-fn views/crm/searchUsers '["ann", 10]' # args as one JSON array +``` + +- **Ephemeral, isolated execution.** Each invocation spawns a **fresh one-shot worker + process**, runs the single call, and kills the process. A debug invocation therefore never + touches the warm worker pool the widgets use: no shared module-level state in either + direction, and a call that wedges its process takes the throwaway worker down with it, not + the pool. Everything else — env resolution (`.env` + custom secrets, widgets sink), module + loading, the 30s timeout, the devalue wire format — is identical to the browser RPC path, + so a pass here means the production machinery works. (The one deliberate difference from a + warm-pool call: module-level state starts clean, e.g. a fresh DB connection.) +- The module key is the same path-relative key the RPC uses (`widgets/hello`, `views/crm`, + `lib/db`), plus the function name: `/` — split on the last slash. +- Arguments are a **plain JSON array** (friendlier to write than devalue's wire format); the + server converts to the devalue encoding the worker expects. JSON-expressible values only — + enough for smoke tests. +- Prints the returned value (inspected, so `Map`/`Set`/`Date` render readably) and the call + duration; a thrown error prints the message and exits 1. +- Duration matters: the RPC timeout is 30s, so a smoke test that takes 8s is a warning sign + the agent can act on. (Expect a few hundred ms of process-spawn overhead on top of the + function's own time — the isolation costs a fork.) + +## The loop, as the skill teaches it + +1. **Build** — `moi bundle`; a `failed` row is fixed before anything else. The footer nudges + when older runtime errors are still on record. +2. **Poke** — new or changed `.server.ts` functions get a `moi call-server-fn` smoke test + before the agent reports them working. +3. **Feel** — `moi debug logs` when the user reports breakage, and proactively after shipping + something new (once the user's tab has had a moment to load it). + +## How it works + +- **Control port.** `debug:logs` and `call-server-fn` are control-socket message types next + to `bundle`/`theme`/`scratch`, workspace-resolved the same way (subdir-safe, loud errors + outside a registered workspace). +- **Journal.** `server/applet-log.ts` owns the ring buffer; producers call `record` from the + RPC route, the bundle pipeline, and the `POST /applet-log` route. The client reporter is a + tiny fire-and-forget module wired into `useApplet`, `WidgetErrorBoundary`, and a global + `error`/`unhandledrejection` hook that attributes by bundle-URL stack match — unattributed + page errors are never recorded (the host app's bugs are not the applet journal's business). +- **Ephemeral worker.** `callFunctionEphemeral` (server/functions.ts) shares the spawn and + per-call IPC mechanics with the warm pool but skips the LRU cache: spawn → ready → one call + → kill, with the same env injection and cwd contract. +- **Validation.** The POST route accepts only the browser-side sources + (`load`/`render`/`window`), whitelists `kind`, pattern-checks `name`, caps message/stack + lengths and events per request — it's an unauthenticated localhost route and is treated + with the same suspicion as `/fs/`. + +## Constraints & non-goals + +- **The journal is not observability.** No persistence, no levels, no tracing — it answers + exactly one question: "what's broken right now that I'd otherwise not know about?" +- **`moi call-server-fn` args are JSON.** Values that need devalue's richer encoding (`Map` + args, etc.) can't be expressed — acceptable for smoke tests, revisit if it ever bites. +- **`moi debug` is experimental.** Output format and flags may change; scripts should not + parse the human output (use `--json`). + +## Future ideas + +- `moi shot widget|view ` — screenshot an applet through a live workspace tab + (offscreen mount + DOM rasterization) with box-vs-content overflow facts, so the agent can + _see_ what it built. Prototyped and removed from scope for now. +- Slow-call warnings: record `rpc` entries for calls that succeed but take >5s. +- Console capture: attribute applet `console.error` output the way window errors are. +- More `moi debug` subcommands: worker-pool state, recent RPC traces, env diagnostics. diff --git a/lib/types.ts b/lib/types.ts index 38a82b61..5c55d086 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -60,6 +60,40 @@ export type ViewBuilder = { updatedAt: number } +// ---- Applet error journal (see docs/self-correction.md) ------------------ + +// Where an applet error was observed. `build` and `rpc` are recorded +// server-side (bundle pipeline, RPC route); `load`/`render`/`window` are +// browser-side and reach the journal via POST /api/workspaces/:id/applet-log. +export type AppletLogSource = 'build' | 'load' | 'render' | 'window' | 'rpc' + +// One journal entry, as returned to `moi debug logs`. `kind`/`name` attribute +// the applet when known; `module`/`fn` pin down the server function for `rpc` +// entries. `count` dedups repeats of the identical error (crash loops are one +// line, not a hundred); `ts` is the LAST occurrence. +export type AppletLogEntry = { + ts: number + source: AppletLogSource + kind?: AppletKind + name?: string + module?: string + fn?: string + message: string + stack?: string + count: number +} + +// The browser-reported subset (the server-side sources can't be spoofed by a +// tab — the POST route rejects them). +export type AppletClientErrorSource = 'load' | 'render' | 'window' +export type AppletClientError = { + source: AppletClientErrorSource + kind: AppletKind + name: string + message: string + stack?: string +} + // A Scratchpad draw/view operation issued by `moi scratch`. Mutations run // server-side against a headless tldraw store; `view` relays to a live tab. The // server assigns each add op a `name` (the `--id`, or a generated one) so the diff --git a/server/api.ts b/server/api.ts index 0f752210..f9b4c1ed 100644 --- a/server/api.ts +++ b/server/api.ts @@ -7,6 +7,7 @@ import { basename, join, resolve } from 'node:path' import type { UploadInfo, ViewBuilderInput, WorkspaceEntry, WorkspaceModels } from '@/lib/types' import { appendViewBuilderMeta } from '@/lib/view-builder-meta' +import { appletForModule, recordAppletError } from './applet-log' import { apiBaseFor, parseAppletTail, serveWorkspaceFile } from './applets' import { applyEnvChanged } from './env-apply' import { publishEvent } from './events' @@ -82,6 +83,15 @@ async function handleFunctionCall( }) } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error' + // Journal the failure so the agent can find it via `moi debug logs` — the + // 500 body below only reaches the browser (see docs/self-correction.md). + recordAppletError(workspacePath, { + source: 'rpc', + ...(appletForModule(module) ?? {}), + module, + fn: name, + message + }) return new Response(message, { status: 500 }) } } @@ -276,6 +286,44 @@ one.post('/rpc/*', c => { return handleFunctionCall(c.req.raw, tail, c.get('ws').path) }) +// Browser-side applet errors (module load failures, render crashes, window +// errors attributed to a bundle) reported into the workspace's error journal — +// `moi debug logs` reads it back (docs/self-correction.md). Unauthenticated +// localhost route, so treat the payload as hostile: whitelist the browser-only +// sources (`build`/`rpc` are server-recorded and can't be spoofed by a tab), +// pattern-check the applet name, cap the batch size; applet-log.ts caps string +// lengths. +one.post('/applet-log', async c => { + let body: { events?: unknown } + try { + body = await c.req.json() + } catch { + return c.text('Invalid JSON', 400) + } + const events = Array.isArray(body.events) ? body.events.slice(0, 10) : [] + for (const raw of events) { + const e = raw as { + source?: unknown + kind?: unknown + name?: unknown + message?: unknown + stack?: unknown + } + if (e.source !== 'load' && e.source !== 'render' && e.source !== 'window') continue + if (e.kind !== 'widget' && e.kind !== 'view') continue + if (typeof e.name !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(e.name)) continue + if (typeof e.message !== 'string' || !e.message) continue + recordAppletError(c.get('ws').path, { + source: e.source, + kind: e.kind, + name: e.name, + message: e.message, + ...(typeof e.stack === 'string' ? { stack: e.stack } : {}) + }) + } + return c.body(null, 204) +}) + // Downscaled image preview of a workspace file. The chat's expanded tool rows // use this to show the picture an agent `Read` — same guards as /fs/ above, // images only, resized server-side (see server/preview.ts). diff --git a/server/applet-log.ts b/server/applet-log.ts new file mode 100644 index 00000000..d89e6629 --- /dev/null +++ b/server/applet-log.ts @@ -0,0 +1,149 @@ +// Per-workspace, in-memory journal of applet runtime errors — the "feel" leg of +// the self-correction loop (docs/self-correction.md). The agent builds applets +// but never sees them run; this journal collects what breaks afterwards (module +// load failures, render crashes, window errors attributed to a bundle, RPC +// failures, standing build failures) so `moi debug logs` can answer "what's +// broken right now that I'd otherwise not know about?". +// +// Deliberately NOT observability: no persistence (a server restart starts +// clean — the journal describes the current runtime), no levels, bounded size. +// Entries hold the *standing* problems since each applet's last good build: a +// successful rebuild clears that applet's entries, because they describe a +// bundle that no longer exists. +import type { AppletKind, AppletLogEntry, AppletLogSource } from '@/lib/types' + +const MAX_ENTRIES = 100 +const MAX_MESSAGE_CHARS = 1000 +const MAX_STACK_CHARS = 4000 + +// workspacePath -> entries, oldest → newest. +const journals = new Map() + +export type AppletErrorInput = { + source: AppletLogSource + kind?: AppletKind + name?: string + module?: string + fn?: string + message: string + stack?: string +} + +// Identity for dedup: a repeat of the same error (same source + attribution + +// message) bumps `count` instead of appending. Stack is deliberately excluded — +// minified column drift would defeat the dedup. +function dedupKey(e: { + source: string + kind?: string + name?: string + module?: string + fn?: string + message: string +}): string { + return [e.source, e.kind ?? '', e.name ?? '', e.module ?? '', e.fn ?? '', e.message].join('\0') +} + +export function recordAppletError(workspacePath: string, input: AppletErrorInput): void { + const entries = journals.get(workspacePath) ?? [] + const message = input.message.slice(0, MAX_MESSAGE_CHARS) + const key = dedupKey({ ...input, message }) + + const existingIdx = entries.findIndex(e => dedupKey(e) === key) + if (existingIdx !== -1) { + // Repeat: bump the counter, refresh the timestamp, move to the tail so the + // journal stays ordered by last occurrence. + const [existing] = entries.splice(existingIdx, 1) + existing.count++ + existing.ts = Date.now() + entries.push(existing) + } else { + entries.push({ + ts: Date.now(), + source: input.source, + ...(input.kind ? { kind: input.kind } : {}), + ...(input.name ? { name: input.name } : {}), + ...(input.module ? { module: input.module } : {}), + ...(input.fn ? { fn: input.fn } : {}), + message, + ...(input.stack ? { stack: input.stack.slice(0, MAX_STACK_CHARS) } : {}), + count: 1 + }) + if (entries.length > MAX_ENTRIES) entries.shift() + } + journals.set(workspacePath, entries) +} + +// Snapshot, oldest → newest. Copies so callers can't mutate the journal. +export function getAppletLog(workspacePath: string): AppletLogEntry[] { + return (journals.get(workspacePath) ?? []).map(e => ({ ...e })) +} + +export function getAppletLogCount(workspacePath: string): number { + return journals.get(workspacePath)?.length ?? 0 +} + +// Wipe the journal (`moi debug logs --clear`). Returns how many were dropped. +export function clearAppletLog(workspacePath: string): number { + const count = getAppletLogCount(workspacePath) + journals.delete(workspacePath) + return count +} + +// Attribute an RPC module key to its applet: `widgets/hello` → widget "hello", +// `views/crm` → view "crm". Shared modules (`lib/db`) attribute to nothing — +// the entry still carries `module`/`fn`. +export function appletForModule(module: string): { kind: AppletKind; name: string } | null { + const m = module.match(/^(widgets|views)\/([^/]+)$/) + if (!m) return null + return { kind: m[1] === 'widgets' ? 'widget' : 'view', name: m[2] } +} + +type BuildResultLike = { + name: string + status: 'built' | 'skipped' | 'failed' + error?: string + serverModules?: string[] +} + +// Reconcile the journal with one kind's bundle results: a failure lands in the +// journal (so it stays on record turns later), a successful build clears the +// applet's standing entries — its browser-side errors describe a bundle that no +// longer exists — plus `rpc` entries for the server modules rebuilt with it. +// Every current source yields a result row (built/skipped/failed), so the +// result set doubles as the source list and entries for deleted applets are +// swept too. +export function syncAppletLogAfterBuild( + workspacePath: string, + kind: AppletKind, + results: BuildResultLike[] +): void { + const rebuiltModules = new Set() + const clearedNames = new Set() + const present = new Set(results.map(r => r.name)) + + for (const r of results) { + if (r.status === 'failed') { + recordAppletError(workspacePath, { + source: 'build', + kind, + name: r.name, + message: r.error ?? 'Build failed' + }) + } + if (r.status === 'built') { + clearedNames.add(r.name) + for (const m of r.serverModules ?? []) rebuiltModules.add(m) + } + } + + const entries = journals.get(workspacePath) + if (!entries) return + const kept = entries.filter(e => { + if (e.kind === kind && e.name && (clearedNames.has(e.name) || !present.has(e.name))) { + return false + } + if (e.source === 'rpc' && e.module && rebuiltModules.has(e.module)) return false + return true + }) + journals.set(workspacePath, kept) +} diff --git a/server/cli.ts b/server/cli.ts index ffab474e..11b27c28 100755 --- a/server/cli.ts +++ b/server/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import './cli-colors' // must precede citty: sets NO_COLOR before its color flag is computed import { defineCommand, runMain, showUsage } from 'citty' +import { parse as devalueParse } from 'devalue' import { existsSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'path' @@ -9,6 +10,7 @@ import pc from './cli-pc' import { COLOR_THEMES, FONT_THEMES } from '@/lib/themes' import type { ColorTheme, FontTheme } from '@/lib/themes' import type { + AppletLogEntry, ScratchArrowEnd, ScratchColor, ScratchFill, @@ -481,6 +483,16 @@ const bundle = defineCommand({ console.log(' ' + f.error + '\n') } + // Runtime errors still standing after the rebuild's clear-on-success + // sweep — point the agent at the journal while it's paying attention. + const logCount = typeof res.logCount === 'number' ? res.logCount : 0 + if (logCount > 0) { + console.log( + pc.yellow(`ℹ ${logCount} applet runtime error(s) on record — run \`moi debug logs\`.`) + + '\n' + ) + } + if (notice) console.log(pc.yellow(notice) + '\n') ws.close() process.exit(failed.length > 0 ? 1 : 0) @@ -1319,15 +1331,16 @@ function styleArgs(args: { type ScratchCliOp = ScratchOp | { kind: 'read' } | { kind: 'read-image'; name: string } -// Round-trip one op through the control port and hand the reply to `onResult`. -// Mirrors the `bundle`/`theme` commands: one socket per invocation, print, exit. -function sendScratch( +// Round-trip one request through the control port and hand the reply to +// `onResult`. Mirrors the `bundle`/`theme` commands: one socket per invocation, +// print, exit. Shared by `scratch`, `call-server-fn`, and `debug logs`. +function sendControl( path: string, - op: ScratchCliOp, + payload: Record, onResult: (res: Record) => void | Promise ) { const ws = new WebSocket(`ws://localhost:${CONTROL_PORT}`) - ws.onopen = () => ws.send(JSON.stringify({ type: 'scratch', path, op })) + ws.onopen = () => ws.send(JSON.stringify(payload)) ws.onmessage = async event => { const res = JSON.parse(String(event.data)) if (res.error) { @@ -1349,6 +1362,14 @@ function sendScratch( } } +function sendScratch( + path: string, + op: ScratchCliOp, + onResult: (res: Record) => void | Promise +) { + sendControl(path, { type: 'scratch', path, op }, onResult) +} + // Print the name a draw op landed on, so the agent can address it later. function printAdded(res: Record) { const result = res.result as { name?: string } | undefined @@ -1677,6 +1698,136 @@ const scratch = defineCommand({ } }) +// ---- self-correction commands (docs/self-correction.md) --------------------- + +const callServerFn = defineCommand({ + meta: { + name: 'call-server-fn', + description: 'Invoke an applet .server.ts function in an isolated one-shot worker (smoke test)' + }, + args: { + fn: { + type: 'positional', + required: true, + description: 'Function path: /, e.g. widgets/hello/getGreeting' + }, + args: { + type: 'positional', + required: false, + description: 'Arguments as one JSON array, e.g. \'["ann", 10]\' (default [])' + }, + dir: dirArg + }, + run({ args }) { + const path = resolve(args.dir) + sendControl( + path, + { type: 'call-server-fn', path, fn: args.fn, args: args.args ?? '[]' }, + res => { + // The worker replies devalue-encoded (same wire format the browser RPC + // parses), so Map/Set/Date render readably through Bun.inspect. + const value = devalueParse(String(res.result)) + console.log(Bun.inspect(value, { depth: 8, colors: process.stdout.isTTY })) + // Duration on stderr: stdout stays clean data, and a slow call is a + // warning sign worth surfacing (browser RPC times out at 30s). + console.error(pc.dim(`↩ ${res.ms}ms`)) + } + ) + } +}) + +// Compact relative age for a journal row: "just now", "4m ago", "2h ago", … +function agoLabel(ts: number): string { + const s = Math.max(0, Math.round((Date.now() - ts) / 1000)) + if (s < 60) return 'just now' + if (s < 3600) return `${Math.round(s / 60)}m ago` + if (s < 86400) return `${Math.round(s / 3600)}h ago` + return `${Math.round(s / 86400)}d ago` +} + +// Where a journal entry points: the server function for rpc rows, else the applet. +function logSubject(e: { + source: string + kind?: string + name?: string + module?: string + fn?: string +}): string { + if (e.module) return `${e.module}/${e.fn ?? '?'}` + if (e.name) return `${e.kind ?? 'applet'} ${e.name}` + return 'unattributed' +} + +const MAX_LOG_MESSAGE_LINES = 12 + +const debugLogs = defineCommand({ + meta: { + name: 'logs', + description: 'Applet runtime errors on record (load / render / rpc / build failures)' + }, + args: { + dir: dirArg, + json: { + type: 'boolean', + default: false, + description: 'Machine-readable output, includes stacks and epoch timestamps' + }, + clear: { type: 'boolean', default: false, description: 'Wipe the journal' } + }, + run({ args }) { + const path = resolve(args.dir) + if (args.clear) { + sendControl(path, { type: 'debug:logs', path, clear: true }, res => { + console.log('\n' + pc.green('✓') + ` cleared ${res.cleared ?? 0} entries\n`) + }) + return + } + sendControl(path, { type: 'debug:logs', path }, res => { + const entries = (Array.isArray(res.entries) ? res.entries : []) as AppletLogEntry[] + if (args.json) { + console.log(JSON.stringify(entries, null, 2)) + return + } + if (entries.length === 0) { + console.log( + '\n' + pc.bold('moi debug logs') + pc.dim(' — no applet errors on record') + '\n' + ) + return + } + console.log( + '\n' + pc.bold('moi debug logs') + pc.dim(` — ${entries.length} error(s) on record`) + '\n' + ) + for (const e of entries) { + const count = e.count > 1 ? pc.dim(` ×${e.count}`) : '' + console.log( + ` ${pc.dim(agoLabel(e.ts).padEnd(10))} ${e.source.padEnd(7)} ${pc.bold(logSubject(e))}${count}` + ) + const lines = e.message.split('\n') + for (const line of lines.slice(0, MAX_LOG_MESSAGE_LINES)) { + console.log(' ' + line) + } + if (lines.length > MAX_LOG_MESSAGE_LINES) { + console.log(pc.dim(` … ${lines.length - MAX_LOG_MESSAGE_LINES} more lines (--json)`)) + } + console.log() + } + console.log( + pc.dim(' Entries clear when their applet next builds successfully, or via --clear.') + '\n' + ) + }) + } +}) + +// Experimental workspace-debugging toolbox. One subcommand for now (`logs`); +// more introspection (worker state, RPC traces, …) may hang off it later. +const debug = defineCommand({ + meta: { + name: 'debug', + description: 'Debug the workspace (experimental) — `moi debug logs` for applet errors' + }, + subCommands: { logs: debugLogs } +}) + // Re-copy bundled skills into a workspace, then report what changed. Pure // filesystem op — no running server needed. Resolves the workspace root the // same way `moi bundle` does, so it works from `.moi/` or any subdirectory. @@ -1808,6 +1959,8 @@ const main = defineCommand({ bundle, builder, refresh, + 'call-server-fn': callServerFn, + debug, theme, config, env, diff --git a/server/control.ts b/server/control.ts index 0da2f9b8..af742eac 100644 --- a/server/control.ts +++ b/server/control.ts @@ -1,10 +1,13 @@ +import { stringify as devalueStringify } from 'devalue' import { resolve } from 'path' import type { WorkspaceEntry } from '@/lib/types' +import { clearAppletLog, getAppletLog, getAppletLogCount } from './applet-log' import { serializeWorkspaceBundle } from './bundle-queue' import { CONTROL_PORT } from './constants' import { applyEnvChanged } from './env-apply' +import { callFunctionEphemeral, parseFunctionPath } from './functions' import { processIcon } from './icon' import { loadLayout, saveLayout } from './layout' import { publishEvent } from './events' @@ -119,7 +122,84 @@ export const control = Bun.serve({ } } }) - ws.send(JSON.stringify({ ok: true, workspacePath, results })) + // Entries still standing after the rebuild's clear-on-success sweep — + // the CLI nudges the agent toward `moi debug logs` when non-zero. + ws.send( + JSON.stringify({ + ok: true, + workspacePath, + results, + logCount: getAppletLogCount(workspacePath) + }) + ) + return + } + + // The applet error journal — `moi debug logs` (docs/self-correction.md). + if (data.type === 'debug:logs') { + const match = await resolveWorkspace(ws, data.path) + if (!match) return + if (data.clear === true) { + ws.send(JSON.stringify({ ok: true, cleared: clearAppletLog(match.path) })) + return + } + ws.send(JSON.stringify({ ok: true, entries: getAppletLog(match.path) })) + return + } + + // Direct server-function invocation — `moi call-server-fn /`. + // Runs in an EPHEMERAL worker: a fresh process spawned for this one call + // and killed after, so a debug invocation is fully isolated from the + // warm pool the widgets use (same env/timeout/wire format otherwise). + // Args arrive as plain JSON (easier to hand-write than devalue's wire + // format) and are re-encoded for the worker; the result goes back + // devalue-encoded for the CLI to render. + if (data.type === 'call-server-fn') { + const match = await resolveWorkspace(ws, data.path) + if (!match) return + const parsed = parseFunctionPath(String(data.fn ?? '')) + if (!parsed) { + ws.send( + JSON.stringify({ + error: `Invalid function path "${data.fn}". Use /, e.g. widgets/hello/getGreeting.` + }) + ) + return + } + let args: unknown + try { + args = JSON.parse(String(data.args ?? '[]')) + } catch (err) { + ws.send( + JSON.stringify({ + error: `Arguments must be valid JSON: ${err instanceof Error ? err.message : String(err)}` + }) + ) + return + } + if (!Array.isArray(args)) { + ws.send( + JSON.stringify({ error: 'Arguments must be a JSON array, e.g. \'["ann", 10]\'' }) + ) + return + } + const t0 = performance.now() + try { + const result = await callFunctionEphemeral( + parsed.module, + parsed.name, + devalueStringify(args), + match.path + ) + ws.send(JSON.stringify({ ok: true, result, ms: Math.round(performance.now() - t0) })) + } catch (err) { + ws.send( + JSON.stringify({ + error: err instanceof Error ? err.message : String(err), + ms: Math.round(performance.now() - t0) + }) + ) + } return } diff --git a/server/functions.ts b/server/functions.ts index 4bd394c8..16f20455 100644 --- a/server/functions.ts +++ b/server/functions.ts @@ -186,20 +186,10 @@ export function parseFunctionPath(tail: string): { module: string; name: string return { module, name } } -export async function callFunction( - module: string, - name: string, - args: string, - workspacePath: string -): Promise { - // Resolve the workspace env before (maybe) spawning, so a fresh worker picks - // up current .env + custom overrides. Env is fixed at spawn — an already-warm - // worker keeps its snapshot until restartWorker() reaps it. Widgets only see - // secrets scoped to the 'widgets' sink. - const workspaceEnv = await resolveWorkspaceEnv(workspacePath) - const slot = getOrSpawn(workspacePath, workspaceEnv) - await slot.readyPromise - +// Issue one call against a live slot: register a pending entry, send the IPC +// frame, settle from the worker's reply (or the timeout). Shared by the warm +// pool path (callFunction) and the ephemeral path (callFunctionEphemeral). +function callInSlot(slot: Slot, module: string, name: string, args: string): Promise { slot.calls++ slot.lastCallAt = Date.now() @@ -223,6 +213,46 @@ export async function callFunction( }) } +export async function callFunction( + module: string, + name: string, + args: string, + workspacePath: string +): Promise { + // Resolve the workspace env before (maybe) spawning, so a fresh worker picks + // up current .env + custom overrides. Env is fixed at spawn — an already-warm + // worker keeps its snapshot until restartWorker() reaps it. Widgets only see + // secrets scoped to the 'widgets' sink. + const workspaceEnv = await resolveWorkspaceEnv(workspacePath) + const slot = getOrSpawn(workspacePath, workspaceEnv) + await slot.readyPromise + return callInSlot(slot, module, name, args) +} + +// One-shot, isolated invocation — `moi call-server-fn`'s path. Spawns a fresh +// worker just for this call and kills it afterwards, so a debug invocation +// never touches the warm pool the widgets use: no shared module-level state in +// either direction, and a call that wedges the process takes the throwaway +// worker with it, not the pool. Same env resolution, module loading, timeout, +// and devalue wire format as the pool path — only the process lifetime differs. +// The slot is never registered in the LRU cache; its onExit cache-cleanup guard +// no-ops for an uncached slot. +export async function callFunctionEphemeral( + module: string, + name: string, + args: string, + workspacePath: string +): Promise { + const workspaceEnv = await resolveWorkspaceEnv(workspacePath) + const slot = spawnSlot(workspacePath, workspaceEnv) + try { + await slot.readyPromise + return await callInSlot(slot, module, name, args) + } finally { + killSlot(slot, 'Ephemeral call finished') + } +} + // Kill every live worker. Called from the server's shutdown handler so a // dev-supervisor restart (or Ctrl-C) never orphans worker child processes. // killSlot is idempotent, so a dispose() racing in via clear() is harmless. diff --git a/server/test/applet-log.test.ts b/server/test/applet-log.test.ts new file mode 100644 index 00000000..deb34ccd --- /dev/null +++ b/server/test/applet-log.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from 'bun:test' + +import { + appletForModule, + clearAppletLog, + getAppletLog, + getAppletLogCount, + recordAppletError, + syncAppletLogAfterBuild +} from '../applet-log' + +// The journal is keyed by workspace path, so each test isolates itself with a +// unique fake path — no shared state between tests. +let n = 0 +const wsPath = () => `/fake/workspace-${++n}` + +describe('recordAppletError / getAppletLog', () => { + test('records an entry with count 1', () => { + const ws = wsPath() + recordAppletError(ws, { source: 'render', kind: 'widget', name: 'hello', message: 'boom' }) + const entries = getAppletLog(ws) + expect(entries).toHaveLength(1) + expect(entries[0]).toMatchObject({ + source: 'render', + kind: 'widget', + name: 'hello', + message: 'boom', + count: 1 + }) + expect(entries[0].ts).toBeGreaterThan(0) + }) + + test('dedups identical errors into a count and moves them to the tail', () => { + const ws = wsPath() + recordAppletError(ws, { source: 'render', kind: 'widget', name: 'a', message: 'boom' }) + recordAppletError(ws, { source: 'render', kind: 'widget', name: 'b', message: 'other' }) + recordAppletError(ws, { source: 'render', kind: 'widget', name: 'a', message: 'boom' }) + const entries = getAppletLog(ws) + expect(entries).toHaveLength(2) + // The repeated entry moved to the tail with a bumped count. + expect(entries[1]).toMatchObject({ name: 'a', count: 2 }) + expect(entries[0]).toMatchObject({ name: 'b', count: 1 }) + }) + + test('a different message is a separate entry', () => { + const ws = wsPath() + recordAppletError(ws, { source: 'rpc', module: 'widgets/a', fn: 'f', message: 'x' }) + recordAppletError(ws, { source: 'rpc', module: 'widgets/a', fn: 'f', message: 'y' }) + expect(getAppletLog(ws)).toHaveLength(2) + }) + + test('caps the journal at 100 entries, dropping the oldest', () => { + const ws = wsPath() + for (let i = 0; i < 120; i++) { + recordAppletError(ws, { source: 'window', kind: 'widget', name: 'w', message: `err ${i}` }) + } + const entries = getAppletLog(ws) + expect(entries).toHaveLength(100) + expect(entries[0].message).toBe('err 20') + expect(entries[99].message).toBe('err 119') + }) + + test('caps message and stack lengths', () => { + const ws = wsPath() + recordAppletError(ws, { + source: 'load', + kind: 'view', + name: 'v', + message: 'm'.repeat(5000), + stack: 's'.repeat(10_000) + }) + const [e] = getAppletLog(ws) + expect(e.message.length).toBe(1000) + expect(e.stack?.length).toBe(4000) + }) + + test('returned entries are copies — mutating them does not touch the journal', () => { + const ws = wsPath() + recordAppletError(ws, { source: 'build', kind: 'widget', name: 'w', message: 'fail' }) + getAppletLog(ws)[0].message = 'mutated' + expect(getAppletLog(ws)[0].message).toBe('fail') + }) +}) + +describe('clearAppletLog', () => { + test('empties the journal and reports how many were dropped', () => { + const ws = wsPath() + recordAppletError(ws, { source: 'rpc', module: 'lib/db', fn: 'q', message: 'x' }) + recordAppletError(ws, { source: 'rpc', module: 'lib/db', fn: 'q', message: 'y' }) + expect(clearAppletLog(ws)).toBe(2) + expect(getAppletLogCount(ws)).toBe(0) + }) +}) + +describe('appletForModule', () => { + test('attributes widget and view modules', () => { + expect(appletForModule('widgets/hello')).toEqual({ kind: 'widget', name: 'hello' }) + expect(appletForModule('views/crm')).toEqual({ kind: 'view', name: 'crm' }) + }) + + test('shared and nested modules attribute to nothing', () => { + expect(appletForModule('lib/db')).toBeNull() + expect(appletForModule('widgets/a/b')).toBeNull() + expect(appletForModule('widgets')).toBeNull() + }) +}) + +describe('syncAppletLogAfterBuild', () => { + test('records build failures', () => { + const ws = wsPath() + syncAppletLogAfterBuild(ws, 'widget', [{ name: 'bad', status: 'failed', error: 'no parse' }]) + expect(getAppletLog(ws)[0]).toMatchObject({ + source: 'build', + kind: 'widget', + name: 'bad', + message: 'no parse' + }) + }) + + test('a successful rebuild clears the applet entries and its rpc modules', () => { + const ws = wsPath() + recordAppletError(ws, { source: 'render', kind: 'widget', name: 'a', message: 'boom' }) + recordAppletError(ws, { source: 'rpc', module: 'widgets/a', fn: 'f', message: 'rpc boom' }) + recordAppletError(ws, { source: 'rpc', module: 'lib/db', fn: 'q', message: 'db boom' }) + syncAppletLogAfterBuild(ws, 'widget', [ + { name: 'a', status: 'built', serverModules: ['widgets/a'] }, + { name: 'db-widget', status: 'built', serverModules: ['lib/db'] } + ]) + expect(getAppletLog(ws)).toHaveLength(0) + }) + + test('a skipped applet keeps its standing entries', () => { + const ws = wsPath() + recordAppletError(ws, { source: 'render', kind: 'widget', name: 'a', message: 'boom' }) + syncAppletLogAfterBuild(ws, 'widget', [{ name: 'a', status: 'skipped' }]) + expect(getAppletLog(ws)).toHaveLength(1) + }) + + test('entries for deleted applets are swept', () => { + const ws = wsPath() + recordAppletError(ws, { source: 'load', kind: 'widget', name: 'gone', message: 'boom' }) + syncAppletLogAfterBuild(ws, 'widget', [{ name: 'other', status: 'skipped' }]) + expect(getAppletLog(ws)).toHaveLength(0) + }) + + test('only touches its own kind', () => { + const ws = wsPath() + recordAppletError(ws, { source: 'render', kind: 'view', name: 'a', message: 'boom' }) + syncAppletLogAfterBuild(ws, 'widget', [{ name: 'a', status: 'built' }]) + expect(getAppletLog(ws)).toHaveLength(1) + }) + + test('a failed rebuild keeps older runtime entries and adds the build error', () => { + const ws = wsPath() + recordAppletError(ws, { source: 'render', kind: 'widget', name: 'a', message: 'boom' }) + syncAppletLogAfterBuild(ws, 'widget', [{ name: 'a', status: 'failed', error: 'oops' }]) + const entries = getAppletLog(ws) + expect(entries).toHaveLength(2) + expect(entries.map(e => e.source).sort()).toEqual(['build', 'render']) + }) +}) diff --git a/server/test/functions.test.ts b/server/test/functions.test.ts index f8ca10d9..5a85c286 100644 --- a/server/test/functions.test.ts +++ b/server/test/functions.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { basename, join } from 'node:path' import { tmpdir } from 'node:os' -import { callFunction, parseFunctionPath, restartWorker } from '../functions' +import { callFunction, callFunctionEphemeral, parseFunctionPath, restartWorker } from '../functions' import { resetWorkspaceEnvForTest, setSecretStoreBackend, @@ -206,3 +206,41 @@ describe('worker env isolation', () => { expect(v).toBe('leaked') }) }) + +describe('callFunctionEphemeral (one-shot isolated worker)', () => { + afterAll(() => { + restartWorker(FIXTURES) + }) + + test('returns a result through a throwaway worker', async () => { + const result = parse( + await callFunctionEphemeral('with-server', 'getWeather', stringify(['NYC']), FIXTURES) + ) + expect(result).toEqual({ city: 'NYC', temp: 72 }) + }) + + test('propagates function errors', async () => { + await expect( + callFunctionEphemeral('error', 'failHard', stringify([]), FIXTURES) + ).rejects.toThrow('intentional test error') + }) + + test('each invocation starts from clean module state', async () => { + // The stateful fixture's counter lives in module scope: a warm worker + // would return 1 then 2. A fresh process per call returns 1 both times. + const a = parse(await callFunctionEphemeral('stateful', 'increment', stringify([]), FIXTURES)) + const b = parse(await callFunctionEphemeral('stateful', 'increment', stringify([]), FIXTURES)) + expect(a).toBe(1) + expect(b).toBe(1) + }) + + test('never touches the warm pool worker', async () => { + restartWorker(FIXTURES) + const w1 = parse(await callFunction('stateful', 'increment', stringify([]), FIXTURES)) + await callFunctionEphemeral('stateful', 'increment', stringify([]), FIXTURES) + const w2 = parse(await callFunction('stateful', 'increment', stringify([]), FIXTURES)) + // The pool worker's counter advanced by exactly one — the ephemeral call + // ran elsewhere and its kill didn't reap the pool slot. + expect(w2).toBe((w1 as number) + 1) + }) +}) diff --git a/server/views.ts b/server/views.ts index b92a663e..13376ef7 100644 --- a/server/views.ts +++ b/server/views.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs' import type { ViewConfig, ViewInfo } from '@/lib/types' +import { syncAppletLogAfterBuild } from './applet-log' import { buildApplets, getAppletPaths, listBuilt, scanSources, serveApplet } from './applets' import { reloadModules } from './functions' import { markViewBuilderReady } from './view-builders' @@ -160,6 +161,10 @@ export async function handleBundleViews( const after = await readManifest(workspacePath) const afterBuilt = new Set(await listBuiltViews(workspacePath)) + // Keep the applet error journal honest: record build failures, clear entries + // superseded by a successful rebuild (see docs/self-correction.md). + syncAppletLogAfterBuild(workspacePath, 'view', results) + const identityChanged = results.some( r => r.status === 'built' && diff --git a/server/widgets.ts b/server/widgets.ts index 0b5f3d23..ed6451d8 100644 --- a/server/widgets.ts +++ b/server/widgets.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs' import type { WidgetConfig, WidgetInfo } from '@/lib/types' +import { syncAppletLogAfterBuild } from './applet-log' import { buildApplets, getAppletPaths, listBuilt, serveApplet } from './applets' import { reloadModules } from './functions' @@ -122,6 +123,10 @@ export async function handleBundle( const results = await buildAllWidgets(workspacePath, force) const after = new Set(await listBuiltWidgets(workspacePath)) + // Keep the applet error journal honest: record build failures, clear entries + // superseded by a successful rebuild (see docs/self-correction.md). + syncAppletLogAfterBuild(workspacePath, 'widget', results) + const configChanged = results.some(r => { if (r.status !== 'built' || !r.config) return false const old = manifestBefore[r.name] diff --git a/workspace/.claude/skills/moi-workspace/SKILL.md b/workspace/.claude/skills/moi-workspace/SKILL.md index d92520e4..157e0dca 100644 --- a/workspace/.claude/skills/moi-workspace/SKILL.md +++ b/workspace/.claude/skills/moi-workspace/SKILL.md @@ -112,6 +112,9 @@ never from inside `.moi/` itself. You don't pass paths; moi resolves the workspa - `moi bundle --force` — rebuild all applets (use after changing `config`) - `moi refresh` — re-fetch widget/view data without rebuilding (use after you mutated data the widgets read — DB rows, files, external API records — so the displayed values catch up) +- `moi call-server-fn / '[args]'` — invoke a `.server.ts` function directly + (smoke test; see Verify your work) +- `moi debug logs` — applet runtime errors on record (experimental; see Verify your work) - `moi theme --font=` — change font theme (omit `--font` to list options) - `moi theme --color=` — change color preset (omit `--color` to list options) - `moi config` — set the workspace name & icon (`moi config --help` for usage) @@ -256,8 +259,34 @@ Render **content only**: a plain `h-full w-full` region with no card chrome (`ro elevation. Changing `colSpan`/`rowSpan` needs `moi bundle --force`. See `DESIGN.md`. Typical loop: check/`bun install` deps → write `.moi/widgets/.tsx` → `moi bundle` → it appears -on the dashboard → change it and re-`bundle`, or `moi refresh` after mutating data, or any other `moi` -command as needed. Views work the same way (`.moi/views/.tsx`, appears as a nav tab). +on the dashboard → verify it actually works (see **Verify your work**) → change it and re-`bundle`, +or `moi refresh` after mutating data, or any other `moi` command as needed. Views work the same way +(`.moi/views/.tsx`, appears as a nav tab). + +# Verify your work + +`moi bundle` succeeding does **not** mean the applet works — it can still fail to load in the +browser, crash on render, or throw in its server functions. Don't wait for the user to tell you; +close the loop yourself: + +1. **Build** — read the `moi bundle` output. Fix any `failed` row before anything else. The + footer also warns when older runtime errors are still on record. +2. **Poke** — after writing or changing a `.server.ts`, smoke-test it before reporting done: + `moi call-server-fn widgets/hello/getGreeting` or + `moi call-server-fn views/crm/searchUsers '["ann", 10]'` (args are one JSON array). Each + invocation runs in a fresh, isolated one-shot process with the same env, module loading, and + timeout as the browser's calls — a pass means the real path works. Server functions only; for + arbitrary scripts use `moi env exec`. A multi-second duration is a warning sign — browser calls + time out at 30s. +3. **Feel** — `moi debug logs` prints the applet errors the workspace has seen since each applet's + last good build: browser-side load failures and render crashes, plus server-function (rpc) + errors. Check it whenever the user reports breakage ("it doesn't work", "the widget is blank"), + and proactively after shipping something new — the user's tab reports errors automatically, so + what broke for them is already on record. Entries clear when their applet next builds + successfully. (`moi debug` is an experimental command group — expect its output and flags to + evolve; use `--json` when you need to parse it.) + +When the user reports a problem, start from `moi debug logs`. # Views @@ -304,4 +333,4 @@ This skill is installed with moi (via the CLI or the UI) and can fall behind whe - **Then** — if you updated, mention it. - + From 6e67f2fa93347a13f491d0b6e814b48ce40b6b2e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 17:29:08 +0000 Subject: [PATCH 2/4] Soften the skill: present debug commands as available, not required Address PR #30 review: drop the "see Verify your work" cross-references from the CLI list, and rewrite the section (now "Debugging applets") so call-server-fn and debug logs are offered as feedback channels the agent can reach for, rather than a mandated verify-every-time checklist. The ambient signals (bundle footer nudge, always-on browser reporting) carry the discovery instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01M8rSCv39HrkfaH23tceb2q --- docs/self-correction.md | 16 ++--- .../.claude/skills/moi-workspace/SKILL.md | 58 +++++++++---------- 2 files changed, 35 insertions(+), 39 deletions(-) diff --git a/docs/self-correction.md b/docs/self-correction.md index 66f0591e..4908564c 100644 --- a/docs/self-correction.md +++ b/docs/self-correction.md @@ -103,14 +103,14 @@ moi call-server-fn views/crm/searchUsers '["ann", 10]' # args as one JSON arr the agent can act on. (Expect a few hundred ms of process-spawn overhead on top of the function's own time — the isolation costs a fork.) -## The loop, as the skill teaches it - -1. **Build** — `moi bundle`; a `failed` row is fixed before anything else. The footer nudges - when older runtime errors are still on record. -2. **Poke** — new or changed `.server.ts` functions get a `moi call-server-fn` smoke test - before the agent reports them working. -3. **Feel** — `moi debug logs` when the user reports breakage, and proactively after shipping - something new (once the user's tab has had a moment to load it). +## How the skill presents it + +The moi-workspace skill describes both commands as **available feedback channels, not a +mandatory checklist** — the agent decides when a smoke test or a journal check is worth the +trip (typically: after building something non-trivial, or when the user reports breakage). +The ambient signals do the nagging instead: the `moi bundle` footer calls out standing +errors, and the journal is already populated by the time the user complains — reporting is +always on, opting in is only about _reading_ it. ## How it works diff --git a/workspace/.claude/skills/moi-workspace/SKILL.md b/workspace/.claude/skills/moi-workspace/SKILL.md index 157e0dca..7fe07c6c 100644 --- a/workspace/.claude/skills/moi-workspace/SKILL.md +++ b/workspace/.claude/skills/moi-workspace/SKILL.md @@ -112,9 +112,8 @@ never from inside `.moi/` itself. You don't pass paths; moi resolves the workspa - `moi bundle --force` — rebuild all applets (use after changing `config`) - `moi refresh` — re-fetch widget/view data without rebuilding (use after you mutated data the widgets read — DB rows, files, external API records — so the displayed values catch up) -- `moi call-server-fn / '[args]'` — invoke a `.server.ts` function directly - (smoke test; see Verify your work) -- `moi debug logs` — applet runtime errors on record (experimental; see Verify your work) +- `moi call-server-fn / '[args]'` — invoke a `.server.ts` function directly (smoke test) +- `moi debug logs` — applet runtime errors on record (experimental) - `moi theme --font=` — change font theme (omit `--font` to list options) - `moi theme --color=` — change color preset (omit `--color` to list options) - `moi config` — set the workspace name & icon (`moi config --help` for usage) @@ -259,34 +258,31 @@ Render **content only**: a plain `h-full w-full` region with no card chrome (`ro elevation. Changing `colSpan`/`rowSpan` needs `moi bundle --force`. See `DESIGN.md`. Typical loop: check/`bun install` deps → write `.moi/widgets/.tsx` → `moi bundle` → it appears -on the dashboard → verify it actually works (see **Verify your work**) → change it and re-`bundle`, -or `moi refresh` after mutating data, or any other `moi` command as needed. Views work the same way -(`.moi/views/.tsx`, appears as a nav tab). - -# Verify your work - -`moi bundle` succeeding does **not** mean the applet works — it can still fail to load in the -browser, crash on render, or throw in its server functions. Don't wait for the user to tell you; -close the loop yourself: - -1. **Build** — read the `moi bundle` output. Fix any `failed` row before anything else. The - footer also warns when older runtime errors are still on record. -2. **Poke** — after writing or changing a `.server.ts`, smoke-test it before reporting done: - `moi call-server-fn widgets/hello/getGreeting` or - `moi call-server-fn views/crm/searchUsers '["ann", 10]'` (args are one JSON array). Each - invocation runs in a fresh, isolated one-shot process with the same env, module loading, and - timeout as the browser's calls — a pass means the real path works. Server functions only; for - arbitrary scripts use `moi env exec`. A multi-second duration is a warning sign — browser calls - time out at 30s. -3. **Feel** — `moi debug logs` prints the applet errors the workspace has seen since each applet's - last good build: browser-side load failures and render crashes, plus server-function (rpc) - errors. Check it whenever the user reports breakage ("it doesn't work", "the widget is blank"), - and proactively after shipping something new — the user's tab reports errors automatically, so - what broke for them is already on record. Entries clear when their applet next builds - successfully. (`moi debug` is an experimental command group — expect its output and flags to - evolve; use `--json` when you need to parse it.) - -When the user reports a problem, start from `moi debug logs`. +on the dashboard → change it and re-`bundle`, or `moi refresh` after mutating data, or any other `moi` +command as needed. Views work the same way (`.moi/views/.tsx`, appears as a nav tab). + +# Debugging applets + +`moi bundle` only proves an applet compiles — it can still fail to load in the browser, crash on +render, or throw in its server functions. Two feedback channels exist for what happens after the +build; reach for them when they'd help (smoke-testing something new, or investigating a problem): + +- `moi call-server-fn widgets/hello/getGreeting` / + `moi call-server-fn views/crm/searchUsers '["ann", 10]'` — run one `.server.ts` function + directly (args are one JSON array). Each invocation runs in a fresh, isolated one-shot process + with the same env, module loading, and timeout as the browser's calls, so a pass means the real + path works — handy for trying a function without touching the UI. Server functions only; for + arbitrary scripts use `moi env exec`. Browser calls time out at 30s, so a multi-second duration + is worth noting. +- `moi debug logs` — the applet errors the workspace has seen since each applet's last good + build: browser-side load failures and render crashes, plus server-function (rpc) errors. The + user's tab reports these automatically, so when the user says something is broken, what + happened is usually already on record — a good first place to look. Entries clear when their + applet next builds successfully. (`moi debug` is an experimental command group — expect its + output and flags to evolve; use `--json` when you need to parse it.) + +`moi bundle`'s footer also mentions when runtime errors are on record, so standing breakage +surfaces on its own. # Views From 03d79d9df200df12d2d8888a7459d13d77c8d53c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 17:36:15 +0000 Subject: [PATCH 3/4] Drop the timeout-duration note from the skill's call-server-fn entry Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01M8rSCv39HrkfaH23tceb2q --- workspace/.claude/skills/moi-workspace/SKILL.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/workspace/.claude/skills/moi-workspace/SKILL.md b/workspace/.claude/skills/moi-workspace/SKILL.md index 7fe07c6c..9fcac832 100644 --- a/workspace/.claude/skills/moi-workspace/SKILL.md +++ b/workspace/.claude/skills/moi-workspace/SKILL.md @@ -272,8 +272,7 @@ build; reach for them when they'd help (smoke-testing something new, or investig directly (args are one JSON array). Each invocation runs in a fresh, isolated one-shot process with the same env, module loading, and timeout as the browser's calls, so a pass means the real path works — handy for trying a function without touching the UI. Server functions only; for - arbitrary scripts use `moi env exec`. Browser calls time out at 30s, so a multi-second duration - is worth noting. + arbitrary scripts use `moi env exec`. - `moi debug logs` — the applet errors the workspace has seen since each applet's last good build: browser-side load failures and render crashes, plus server-function (rpc) errors. The user's tab reports these automatically, so when the user says something is broken, what From ec501dc4831d770a9a91d07f71e914123e9760dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 18:58:43 +0000 Subject: [PATCH 4/4] Harden the browser reporter: filename fast path + global rate cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two upgrades to the window-error half of the applet journal: - Attribute 'error' events via ErrorEvent.filename first — a structured URL with no per-browser stack-format quirks, which also covers throws that carry no usable stack (`throw 'string'`). The stack-text scan stays as the fallback (handler threw inside vendor code) and as the only path for unhandled rejections, which have no filename. - Close the flood hole: the per-error cooldown keys on the message, so an error whose text varies each occurrence (timestamps, ids) bypassed it — and the server dedup — turning a tight throw loop into a POST storm. A global cap (30 reports/min, silent drops) bounds the total regardless of message shape. The URL matcher is extracted and unit-tested against bare filenames, Chrome/Firefox stack shapes, and near-miss URLs (vendor, rpc). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01M8rSCv39HrkfaH23tceb2q --- client/features/applets/applet-log.test.ts | 46 +++++++++++++ client/features/applets/applet-log.ts | 76 ++++++++++++++++------ docs/self-correction.md | 10 ++- 3 files changed, 109 insertions(+), 23 deletions(-) create mode 100644 client/features/applets/applet-log.test.ts diff --git a/client/features/applets/applet-log.test.ts b/client/features/applets/applet-log.test.ts new file mode 100644 index 00000000..ff7ccbb7 --- /dev/null +++ b/client/features/applets/applet-log.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'bun:test' + +import { matchBundleUrl } from './applet-log' + +const WS = '17c35e98-f9a0-4cc3-bb71-cd8c279edb9b' + +describe('matchBundleUrl', () => { + test('matches a bare bundle URL (ErrorEvent.filename)', () => { + expect( + matchBundleUrl(`http://localhost:13337/api/workspaces/${WS}/widgets/hello/index.js`) + ).toEqual({ workspaceId: WS, kind: 'widget', name: 'hello' }) + }) + + test('matches a cache-busted view chunk URL', () => { + expect( + matchBundleUrl(`http://localhost:13337/api/workspaces/${WS}/views/crm/chunk-ab12.js?v=3`) + ).toEqual({ workspaceId: WS, kind: 'view', name: 'crm' }) + }) + + test('matches inside a Chrome-style stack frame (parenthesized URL)', () => { + const stack = [ + 'TypeError: boom', + ` at onClick (http://localhost:13337/api/workspaces/${WS}/widgets/rps-chart/index.js:10:5)`, + ' at invokeGuardedCallback (http://localhost:13337/vendor/react/react-dom.js:4:2)' + ].join('\n') + expect(matchBundleUrl(stack)).toEqual({ workspaceId: WS, kind: 'widget', name: 'rps-chart' }) + }) + + test('matches inside a Firefox-style stack frame (fn@url)', () => { + const stack = `onClick@http://localhost:13337/api/workspaces/${WS}/views/crm/index.js:10:5` + expect(matchBundleUrl(stack)).toEqual({ workspaceId: WS, kind: 'view', name: 'crm' }) + }) + + test('does not match host-app or vendor URLs', () => { + expect(matchBundleUrl('at fn (http://localhost:13337/vendor/react/react.js:1:1)')).toBeNull() + expect( + matchBundleUrl(`at fn (http://localhost:13337/api/workspaces/${WS}/rpc/widgets/hello/f)`) + ).toBeNull() + }) + + test('does not match empty or stack-less input', () => { + expect(matchBundleUrl(undefined)).toBeNull() + expect(matchBundleUrl('')).toBeNull() + expect(matchBundleUrl('TypeError: boom')).toBeNull() + }) +}) diff --git a/client/features/applets/applet-log.ts b/client/features/applets/applet-log.ts index e0730389..a70d3499 100644 --- a/client/features/applets/applet-log.ts +++ b/client/features/applets/applet-log.ts @@ -1,4 +1,4 @@ -import type { AppletClientError } from '@/lib/types' +import type { AppletClientError, AppletKind } from '@/lib/types' // Fire-and-forget reporter for browser-side applet errors — the client half of // the applet error journal (docs/self-correction.md). Failures only the user's @@ -7,17 +7,35 @@ import type { AppletClientError } from '@/lib/types' // them to the agent. Always on: when the user says "it's broken", the crash // their tab saw five minutes ago is already on record. // -// Reporting must never hurt the app: fetch errors are swallowed, and a -// per-error cooldown keeps a crash loop from turning into a POST storm (the -// server dedups identical errors too — this just spares the network). +// Reporting must never hurt the app: fetch errors are swallowed, and two +// throttles bound the network chatter. The per-error cooldown collapses a +// crash loop of the SAME error into one POST per window; the global cap +// bounds the total regardless of message — an error whose text varies every +// occurrence (timestamps, ids) would defeat key-based cooldown and server +// dedup alike, and a tight throw loop must not become a POST-per-frame storm. +// Drops are silent: the journal is best-effort diagnostics, not telemetry. const COOLDOWN_MS = 5_000 const lastSent = new Map() +const RATE_WINDOW_MS = 60_000 +const RATE_MAX_PER_WINDOW = 30 +let rateWindowStart = 0 +let rateCount = 0 + export function reportAppletError(workspaceId: string, event: AppletClientError): void { - const key = [workspaceId, event.source, event.kind, event.name, event.message].join('\0') const now = Date.now() + + const key = [workspaceId, event.source, event.kind, event.name, event.message].join('\0') const last = lastSent.get(key) if (last !== undefined && now - last < COOLDOWN_MS) return + + if (now - rateWindowStart >= RATE_WINDOW_MS) { + rateWindowStart = now + rateCount = 0 + } + if (rateCount >= RATE_MAX_PER_WINDOW) return + rateCount++ + lastSent.set(key, now) // Bound the cooldown map: drop the oldest half if it ever balloons. if (lastSent.size > 256) { @@ -30,12 +48,25 @@ export function reportAppletError(workspaceId: string, event: AppletClientError) }).catch(() => {}) } -// Attribute a window-level error to an applet by the bundle URL in its stack: -// applet bundles load from `/api/workspaces//(widgets|views)//…`, so -// any frame from one pins the error to that applet — and carries the workspace -// id along. Host-app errors never match; they are not the journal's business. +// Attribute a window-level error to an applet by a bundle URL: applet bundles +// load from `/api/workspaces//(widgets|views)//…`, so any reference +// to one pins the error to that applet — and carries the workspace id along. +// Host-app URLs never match; their errors are not the journal's business. +// Matched against ErrorEvent.filename (a bare URL) and stack-trace text, where +// segments stop at `/`, whitespace, or `)` because stack frame formats differ +// per browser (Chrome parenthesizes URLs, Firefox uses `fn@url`). const BUNDLE_URL_RE = /\/api\/workspaces\/([^/\s)]+)\/(widgets|views)\/([^/\s)]+)\// +type BundleRef = { workspaceId: string; kind: AppletKind; name: string } + +// Exported for tests. +export function matchBundleUrl(text: string | undefined): BundleRef | null { + if (!text) return null + const m = BUNDLE_URL_RE.exec(text) + if (!m) return null + return { workspaceId: m[1], kind: m[2] === 'widgets' ? 'widget' : 'view', name: m[3] } +} + let installed = false // Catch errors that escape React entirely — event handlers, async effects, @@ -44,21 +75,26 @@ export function installAppletErrorHook(): void { if (installed) return installed = true - const report = (raw: unknown) => { + const report = (ref: BundleRef | null, raw: unknown, fallbackMessage?: string) => { const err = raw instanceof Error ? raw : null const stack = err?.stack ?? '' - const m = BUNDLE_URL_RE.exec(stack) - if (!m) return - const [, workspaceId, segment, name] = m - reportAppletError(workspaceId, { + const hit = ref ?? matchBundleUrl(stack) + if (!hit) return + reportAppletError(hit.workspaceId, { source: 'window', - kind: segment === 'widgets' ? 'widget' : 'view', - name, - message: err?.message || String(raw), - stack + kind: hit.kind, + name: hit.name, + message: err?.message || fallbackMessage || String(raw), + ...(stack ? { stack } : {}) }) } - window.addEventListener('error', e => report(e.error)) - window.addEventListener('unhandledrejection', e => report(e.reason)) + // Fast path for 'error' events: ErrorEvent.filename is the structured URL of + // the script whose top frame threw — no stack-text parsing, no per-browser + // format quirks, and it works even for throws with no usable stack + // (`throw 'string'`). The stack scan remains the fallback (e.g. an applet + // handler that threw inside vendor code, where filename names the vendor + // chunk) and the only path for promise rejections, which carry no filename. + window.addEventListener('error', e => report(matchBundleUrl(e.filename), e.error, e.message)) + window.addEventListener('unhandledrejection', e => report(null, e.reason)) } diff --git a/docs/self-correction.md b/docs/self-correction.md index 4908564c..a07f2014 100644 --- a/docs/self-correction.md +++ b/docs/self-correction.md @@ -56,9 +56,13 @@ moi debug logs --clear # wipe the buffer applet when known; `module`/`fn` pin down the server function for `rpc` entries. - **Producers.** Server-side: the RPC route records every failed function call; the bundle pipeline records build failures. Browser-side: the client POSTs `load`, `render`, and - `window` events to `POST /api/workspaces/:id/applet-log` — fire-and-forget, throttled, - size-capped. Reporting is **always on**: when the user says "it's broken", the crash their - tab saw five minutes ago is already on record. + `window` events to `POST /api/workspaces/:id/applet-log` — fire-and-forget and + flood-guarded: a per-error cooldown (5s) collapses repeats of the same error, and a global + cap (30 reports/min) bounds the total even when the message varies every occurrence, so a + tight throw loop never becomes a POST-per-frame storm. `window` errors attribute via + `ErrorEvent.filename` first (structured, no stack parsing), falling back to a bundle-URL + match in the stack text. Reporting is **always on**: when the user says "it's broken", the + crash their tab saw five minutes ago is already on record. - **Dedup.** A repeat of an identical error (same source + attribution + message) bumps a `count` and its timestamp instead of appending — a render crash loop is one line, not a hundred.