diff --git a/.gitignore b/.gitignore index 65354fc2..ff7ffd3f 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,6 @@ PROGRESS.json # Runtime output tmp/ /scripts/eval/behavior/results/ + +# A pack's built dist/ is never committed — build it from source. +scripts/spike/*/dist/ diff --git a/CLAUDE.md b/CLAUDE.md index 3180bced..c920b1b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ root `~/.brains-dev`, its own single-instance socket and keychain item, signed with the local self-signed cert — so the parity harness can drive both at once and neither one's data is the other's. `src-tauri/tauri.conf.json` + `tauri.dev.conf.json` hold it; `src-tauri/src/identity.rs` asserts the two never drift. `BRAINS_HOME` -overrides the data root for both. Removable apps keep their own ids — `exo` is one, +overrides the data root for both. Removable apps keep their own ids, and this repo tracks none of them — not the product. ## Map @@ -111,13 +111,12 @@ not the product. manifests and restages the closure) → commit the manifest diff. `cargo test --workspace`, the frontend suites and `tauri build` all pass with it gone; that app's own tests and eval checks SKIP, out loud. - THE REMOVABLE APP THIS REPO CARRIES is `src/apps/exo`, the daily loop: TRACKED (one - folder, in the commit) and gated OFF, which is the first state above and not - a special case — nothing is armed, listed or spoken, and a plain session's - prompt is byte-identical with it deleted (spec 30). Two things open a gate, - and they OR: `apps..enabled` in settings.json, and `BRAINS_ENABLE_APPS` - — a comma list of ids THIS LAUNCH forces on - (`brains_storage::ENABLE_APPS_ENV`; `npm run dev:app` sets it from a glob, + THIS REPO TRACKS NO REMOVABLE APP. They come from external repos, installed + from a git URL in Settings → Apps (`docs/packs/AUTHORING.md`), and land + hash-verified at `/packs//`. The three states above are their + states. Two things open a gate, and they OR: `apps..enabled` in + settings.json, and `BRAINS_ENABLE_APPS` — a comma list of ids THIS LAUNCH + forces on (`brains_storage::ENABLE_APPS_ENV`; `npm run dev:app` sets it from a glob, so local dev runs with whatever is installed). The override can only OPEN a gate, both halves read it (native `Settings::gate_open`, webview `settings_forced_apps`), and a release started from Finder inherits nothing. @@ -205,13 +204,4 @@ keychain; don't add keychain-touching test paths outside release checks. No names, addresses, machine paths (`/Users/`), account ids or tokens in tracked files — including fixtures, which use invented accounts -(`Ada Lovelace `) and invented ids. - -ONE DELIBERATE EXCEPTION, and it is the whole of it: `src/apps/exo` — a REMOVABLE APP, -its skills, and the manifest entries the build scans out of them. A removable app IS -somebody's personal app; its prompts name that person's brains boards and their -own daily loop, and stripping that would leave an app that does nothing. It is -tracked (rule 6) and gated off, so it ships as a folder nobody else's build -runs. Tokens and machine paths are still never in it. Everything outside that -folder holds the line above, and an app you'd rather not publish at all stays -private through `.gitignore` (rule 6), not through discipline. +(`Ada Lovelace `) and invented ids. No exceptions. diff --git a/Cargo.lock b/Cargo.lock index 84a220b5..912009e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -431,6 +431,22 @@ dependencies = [ "piper", ] +[[package]] +name = "brains-apps" +version = "0.0.0" +dependencies = [ + "brains-context", + "brains-local-agents", + "brains-storage", + "chrono", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.19", + "tokio", +] + [[package]] name = "brains-browser" version = "0.0.0" @@ -465,6 +481,7 @@ name = "brains-desktop" version = "0.0.0" dependencies = [ "base64 0.22.1", + "brains-apps", "brains-browser", "brains-context", "brains-local-agents", diff --git a/Cargo.toml b/Cargo.toml index d102e768..29a67ab5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "src/engines/context", "src/engines/brains/native", "src/engines/browser", + "src/engines/apps", "src/engines/recording", "src/engines/storage", "src-tauri", @@ -27,6 +28,7 @@ brains-native = { path = "src/engines/brains/native" } brains-model = { path = "src/engines/model" } brains-local-agents = { path = "src/engines/agents/local" } brains-context = { path = "src/engines/context" } +brains-apps = { path = "src/engines/apps" } brains-recording = { path = "src/engines/recording" } brains-browser = { path = "src/engines/browser" } diff --git a/README.md b/README.md index fd737858..5b9e05b5 100644 --- a/README.md +++ b/README.md @@ -378,32 +378,24 @@ scope). A removable app has **three** states: keeps the third state true, and `scripts/eval/checks/removable app-removable.mjs` enforces it mechanically. -### The removable app this repo carries +### Where removable apps come from -`src/apps/exo` — the daily loop (Mo plans and reviews the day, Iris reads -identity, Awareness grades its own predictions, all on brains boards). It is -**present and gated off**: it ships tracked in this repo, in one folder, and a -build does nothing with it until its gate opens. That is the *switched off* -state above, not a special case — no slot is armed, it is not in the + menu, -and a plain session's system prompt is byte-identical with the gate open and -with it shut (`with_a_packs_gate_on_a_plain_session_byte_matches_the_ungated_base`). - -It is also the personal app it looks like: its skills carry their author's own -board ids and daily-loop wording. That is what a removable app is for, and it is the -reason the gate is the boundary it is. +This repo tracks none. A removable app lives in its own git repo and is +installed from **Settings → Apps** by pasting its URL — clone, build, verify, +install, gate open, tab in the + menu. The contract an external repo follows is +[docs/packs/AUTHORING.md](docs/packs/AUTHORING.md); an installed one lives +hash-verified at `/packs//`. ### Running with a removable app locally Two ways to open a gate, and they OR: -- **Settings → Developer → Installed removable apps** — the switch, per removable app. It writes - `apps..enabled` into your `settings.json` and takes effect live, both - directions, without a restart. +- **Settings → Apps** — install, update and uninstall, and the gate switch per + installed app. The switch writes `apps..enabled` into your `settings.json` + and takes effect live, both directions, without a restart. - **`BRAINS_ENABLE_APPS=[,…]`** — a comma list of app ids this *launch* - forces on, whatever the file says. `npm run dev:app` sets it from - `scripts/dev/installed-removable apps.mjs` (a glob of `src/apps/*/app.json`), so local - dev always runs with whatever this checkout carries. Name the variable in - `.env` to override the list. + forces on, whatever the file says. Useful for dev and tests against a + temporary install. The environment override can only ever open a gate, never close one, and both halves of the app read the same answer — native through @@ -415,16 +407,14 @@ the switch there writes a file that is not what is answering. A **release build started from Finder inherits nothing** and stays default-off. The variable is a developer's tool, not a shipping mode. -### Adding your own - -1. `mkdir src/apps/mine` and write `app.json` with `"gate": "apps.mine.enabled"`. -2. `index.ts` — register the app with `core/app-registry`, gated on that key. -3. Optionally `context.json` (what a session is told, gated on the same key), - `mine.agents.json` + `skills/*.md` (what runs on a schedule), and - `eval/*.yaml` (your own eval lane — `npm run eval` discovers it). -4. `npm run build` to regenerate the manifests, then commit the manifest diff. - To keep the removable app private instead, add `src/apps/mine/` to `.gitignore`; the - committed manifests will not mention it. +1. Start a repo with `pack.json` (`id`, `gate: "apps..enabled"`, `title`, + `description`, `version`). +2. `index.ts` — register the app with the host's registry, gated on that key. +3. Optionally `context.json` (what a session is told), `*.agents.json` + + `skills/*.md` (what runs on a schedule), and `eval/*.yaml` (your own lane). +4. A build script that emits a self-contained `dist/`. Then install it from + Settings → Apps. [docs/packs/AUTHORING.md](docs/packs/AUTHORING.md) is the + full contract, and `scripts/spike/hello-pack` is a worked example. --- diff --git a/docs/apps/AUTHORING.md b/docs/apps/AUTHORING.md new file mode 100644 index 00000000..c9122878 --- /dev/null +++ b/docs/apps/AUTHORING.md @@ -0,0 +1,494 @@ +# App Authoring Guide + +An app is a self-contained app that runs inside brains desktop. You build it in +your own repo; the user installs it by pasting the git URL. This guide documents +what the engine enforces — follow it and your app installs; break it and the +install fails with a clear error. + +## Repo Shape + +Your repo root must have `app.json`: + +```json +{ + "id": "my-app", + "gate": "apps.my-app.enabled", + "title": "My App", + "description": "What this app does in one line.", + "version": "1.0.0" +} +``` + +| Field | Required | Rules | +|-------|----------|-------| +| `id` | yes | Lowercase letters, digits, dashes. First char: letter or digit. | +| `gate` | yes | Must be exactly `apps..enabled`. | +| `title` | yes | Non-empty string. | +| `description` | yes | Non-empty string. | +| `version` | yes | Must contain at least one digit. | +| `build` | no | Custom build command (default: `npm install && npm run build`). | + +**Reserved ids** (cannot be used): `session`, `gmail`, `docs`, `settings`, +`sites`, `brains-apps`, `chat`, `boards`. + +### Agent Declarations + +To schedule recurring runs, add `*.agents.json` at the repo root: + +```json +[ + { "cron": "30 6 * * *", "skill": "skills/morning/SKILL.md" }, + { "cron": "0 15 * * *", "skill": "skills/afternoon.md", "id": "pm-check", "enabled": false } +] +``` + +| Field | Required | Notes | +|-------|----------|-------| +| `cron` | yes | 5-field cron (minute hour day month weekday). Local timezone. | +| `skill` | yes | Relative path to a `.md` file inside the repo. | +| `id` | no | Defaults to skill filename (or parent folder for `SKILL.md`). | +| `enabled` | no | Defaults to true. | + +Unknown keys fail the build. The skill file must exist. Duplicate ids fail. Cron +expressions must parse (see `engines/agents` README for syntax). + +### Context Declarations + +To inject instructions into sessions opened inside your app's tab: + +```json +{ + "index": "A one-line summary shown in skill lists.", + "detail": "skills/context/SKILL.md", + "gate": "apps.my-app.enabled" +} +``` + +Or use `details.app` for the same purpose: + +```json +{ + "index": "...", + "details": { "app": "skills/context/SKILL.md" }, + "gate": "apps.my-app.enabled" +} +``` + +The detail file is read and embedded at install time. Gate scopes the context to +your app's tab — omit it only if you want the context active everywhere. + +### Path Rules + +All declared paths (skill, detail, context files) must be: + +- **Relative**: No leading `/`. +- **Contained**: No `..` components. +- **No symlinks**: The file itself cannot be a symlink. + +The engine validates these at install time. A path that escapes the repo fails. + +## The Build Contract + +Your build command (default: `npm install && npm run build`) must emit a +self-contained `dist/` directory containing: + +- `app.json` — copy of your root app.json +- `*.agents.json` — if you have scheduled agents +- `context.json` — if you have context +- `skills/**` — all skill files your declarations reference +- `index.js` — if your app has UI (see below) + +The engine validates `dist/` the same way it validates the source. No symlinks +allowed in `dist/`. + +### UI Apps + +If your app has a visible tab (most do), `dist/index.js` must be: + +- An **IIFE** (not an ES module) — WKWebView cannot dynamic-import from asset URLs +- **Self-contained** — CSS must be injected by the JS at runtime, not a separate file + +The host loads your app via script tag injection — only `index.js` is loaded, +so loose `.css` files would never apply: + +```javascript +(function() { + var registry = window.__BRAINS_SHARED__["$core/app-registry"]; + registry.registerApp({ + id: "my-app", + title: "My App", + icon: "star", + gate: "apps.my-app.enabled", + load: async () => ({ default: MyComponent }), + }); +})(); +``` + +The canonical Vite config for Svelte apps: + +```typescript +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { defineConfig, type Plugin } from "vite"; + +const VIRTUAL_PREFIX = "\0brains-shared:"; + +// Known exports from each shared module. Add as needed. +const SHARED_EXPORTS: Record = { + "$core/app-registry": ["registerApp", "markExternalAppRender"], + "svelte/internal/client": [ + // Svelte 5 internal exports used by compiled components. + "push", "pop", "element", "text", "append", "append_styles", "listen", + "set_text", "attr", "insert", "detach", "component_root", "render_effect", + "template_effect", "template", "mount", "hydrate", "unmount", "props", + "from_html", "state", "sibling", "child", "reset", "delegated", "delegate", + "update", "get", "set", "source", "derived", "effect", "user_effect", + "noop", "run_all", "safe_not_equal", "create_component", "claim_component", + "destroy_component", "transition_in", "transition_out", + ], + // Standard svelte lifecycle — add exports as needed + "svelte": ["onMount", "onDestroy"], + // Host pane composition — only if using Workspace + "$host/panes": ["Workspace"], + // Event subscription — only if subscribing to run events + "$host/spine": ["EventMiddleware", "isTerminal", "phaseFromRunState"], + // Board reads — only if reading brains data + "$host/brains": ["mcpCall"], +}; + +function brainsSharedPlugin(): Plugin { + const modules = Object.keys(SHARED_EXPORTS); + return { + name: "brains-shared", + enforce: "pre", + resolveId(id) { + if (modules.includes(id)) return VIRTUAL_PREFIX + id; + return null; + }, + load(id) { + if (!id.startsWith(VIRTUAL_PREFIX)) return null; + const key = id.slice(VIRTUAL_PREFIX.length); + const exports = SHARED_EXPORTS[key]; + if (!exports) return null; + return ` +const __m__ = window.__BRAINS_SHARED__["${key}"]; +export const { ${exports.join(", ")} } = __m__; +export default __m__; +`; + }, + }; +} + +// Inject CSS into the JS bundle as a +``` + +Create `index.ts`: + +```typescript +import { registerApp } from "$core/app-registry"; +import HelloTab from "./HelloTab.svelte"; + +registerApp({ + id: "hello", + title: "Hello App", + icon: "wave", + description: "A proof-of-concept external app.", + gate: "apps.hello.enabled", + load: async () => ({ default: HelloTab }), +}); +``` + +Create `vite.config.ts` and `svelte.config.js` (see canonical configs above). + +Update `package.json`: + +```json +{ + "type": "module", + "scripts": { + "build": "vite build && cp app.json dist/" + } +} +``` + +Build and verify: + +```bash +npm run build +ls dist/ +# index.js app.json +``` + +The CSS is injected into `index.js` — no separate `.css` file. + +Push to a git repo. In brains: **Settings → Apps → paste your repo URL**. + +The app installs, validates, and appears in the + menu. + +--- + +See `scripts/spike/hello-app/` for a working reference implementation. diff --git a/docs/apps/SPIKE-VERDICT.md b/docs/apps/SPIKE-VERDICT.md new file mode 100644 index 00000000..41c82a31 --- /dev/null +++ b/docs/apps/SPIKE-VERDICT.md @@ -0,0 +1,178 @@ +# SPIKE VERDICT: Runtime App Loading + +**Status: GO** + +External apps CAN be loaded at runtime and share singletons with the host. +This unlocks the "install apps from external git repos" scope. + +## Proven Capabilities + +1. **App Registration**: External app registers into the HOST's app registry + - App shows in + menu alongside built-in apps + - `allApps()` includes the external app after load + +2. **Singleton Sharing**: App uses host's modules via globalThis shims + - `$core/app-registry` is shared (registerApp works in host's registry) + - Ready for Svelte runtime sharing when apps compile with Svelte + +3. **Component Rendering**: App components render in the host shell + - Simple render functions work via ExternalAppHost wrapper + - Counter state persists across clicks (0 → 1 → 2 → ... → 4) + +## Chosen Mechanism: globalThis Shims + Script Tag Injection + +### Why Not Dynamic Import + +WKWebView (Safari) does not support `import()` from `asset://` URLs. Attempting +`import(assetUrl)` silently fails or throws a network error, even when CSP +allows the asset protocol. + +**Solution**: Fetch the app's JS as text, inject via ` + +
+

Hello from external pack!

+

This Svelte component was loaded at runtime from outside the main bundle.

+

If Svelte runes work (count: {count}), the singleton is shared correctly.

+ +
+ + diff --git a/scripts/spike/hello-app/app.json b/scripts/spike/hello-app/app.json new file mode 100644 index 00000000..4a7646f6 --- /dev/null +++ b/scripts/spike/hello-app/app.json @@ -0,0 +1,7 @@ +{ + "id": "hello", + "gate": "apps.hello.enabled", + "title": "Hello App", + "description": "A proof-of-concept external app.", + "version": "0.1.0" +} diff --git a/scripts/spike/hello-app/index.ts b/scripts/spike/hello-app/index.ts new file mode 100644 index 00000000..afbece42 --- /dev/null +++ b/scripts/spike/hello-app/index.ts @@ -0,0 +1,13 @@ +// External app entry: registers into the HOST's app registry. +// The build transforms this import to a globalThis lookup (see vite.config.ts). +import { registerApp } from "$core/app-registry"; +import HelloTab from "./HelloTab.svelte"; + +registerApp({ + id: "hello", + title: "Hello App", + icon: "wave", + description: "A proof-of-concept external app.", + gate: "apps.hello.enabled", + load: async () => ({ default: HelloTab }), +}); diff --git a/scripts/spike/hello-app/package-lock.json b/scripts/spike/hello-app/package-lock.json new file mode 100644 index 00000000..090aa7ea --- /dev/null +++ b/scripts/spike/hello-app/package-lock.json @@ -0,0 +1,1433 @@ +{ + "name": "hello-pack", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hello-pack", + "version": "0.0.1", + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "svelte": "^5.53.3", + "vite": "^6.4.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.12.tgz", + "integrity": "sha512-J1jNYG23QWd67UfrQSFHtjhV37r9mVi0gdc12A3MWPldOjRK35Xk+um+qACPVjgw3AleiqoyEAhok8Wm3q46NA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz", + "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.2.tgz", + "integrity": "sha512-40GyiEJevYKXzYTHtZkFqAgTjLOuFcaXMao8TPyOlnWTlkHDlvZ6mPMJaJyOqVwrVCgomEG1WhJd81w0X+IcCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.8", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", + "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/scripts/spike/hello-app/package.json b/scripts/spike/hello-app/package.json new file mode 100644 index 00000000..4cb12f03 --- /dev/null +++ b/scripts/spike/hello-app/package.json @@ -0,0 +1,13 @@ +{ + "name": "hello-app", + "version": "0.0.1", + "type": "module", + "scripts": { + "build": "vite build && cp app.json dist/" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "svelte": "^5.53.3", + "vite": "^6.4.3" + } +} diff --git a/scripts/spike/hello-app/svelte.config.js b/scripts/spike/hello-app/svelte.config.js new file mode 100644 index 00000000..8bda965a --- /dev/null +++ b/scripts/spike/hello-app/svelte.config.js @@ -0,0 +1,8 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +export default { + preprocess: vitePreprocess(), + compilerOptions: { + runes: true, + }, +}; diff --git a/scripts/spike/hello-app/tsconfig.json b/scripts/spike/hello-app/tsconfig.json new file mode 100644 index 00000000..f4617e15 --- /dev/null +++ b/scripts/spike/hello-app/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["./types/brains-host.d.ts"] + }, + "include": ["*.ts", "*.svelte", "types/**/*.d.ts"] +} diff --git a/scripts/spike/hello-app/types/brains-host.d.ts b/scripts/spike/hello-app/types/brains-host.d.ts new file mode 100644 index 00000000..8b3e0da3 --- /dev/null +++ b/scripts/spike/hello-app/types/brains-host.d.ts @@ -0,0 +1,113 @@ +// Type stubs for brains-desktop shared modules. +// These match the PUBLIC API the host exposes via pack-shims.ts. +// Update when the host changes its shared surface. + +declare module "$core/app-registry" { + export interface AppDefinition { + id: string; + title: string; + icon: string; + description?: string; + gate: string; + load: () => Promise<{ default: unknown }>; + } + export function registerApp(def: AppDefinition): void; + export function markExternalPackRender( + render: (target: HTMLElement) => void, + ): { render: (target: HTMLElement) => void }; +} + +declare module "$host/panes" { + import type { Component, Snippet } from "svelte"; + + export interface CanvasPage { + kind: "board" | "mini-site" | "topic-map"; + id: string; + name: string; + brainName?: string | null; + surface?: string | null; + url?: string | null; + } + + export interface ScopeChip { + label: string; + icon?: "gmail" | "docs"; + type?: string; + onClick?: () => void; + } + + export interface Skill { + id: string; + label: string; + hint: string; + icon: string; + scopes: string[]; + brief?: string; + group?: string; + } + + export interface HostSurface { + tag: string; + title: string; + body: Snippet; + ground?: CanvasPage | null; + noun?: string | null; + hint?: string | null; + intro?: Snippet; + room?: "workstation"; + scope?: string; + skills?: Skill[]; + onSkill?: (skillId: string) => boolean; + dispatchBrief?: (displayText: string, instruction: string) => void; + } + + export interface WorkspaceProps { + hostSurface: HostSurface; + scopeChips?: ScopeChip[]; + liveContext?: () => Promise; + } + + export const Workspace: Component; +} + +declare module "$host/spine" { + export interface EventSink { + applyEventBatch(events: SessionEvent[]): void; + } + + export interface SessionEvent { + type: string; + run_id: string; + _seq?: number; + ts?: number; + [key: string]: unknown; + } + + export type SessionPhase = + | "empty" + | "loading" + | "ready" + | "spawning" + | "running" + | "idle" + | "completed" + | "failed" + | "stopped"; + + export class EventMiddleware { + start(): Promise; + subscribe(runId: string, sink: EventSink, lastSeq?: number): Promise; + unsubscribe(runId: string): void; + destroy(): void; + } + + export function isTerminal(phase: SessionPhase): boolean; + export function phaseFromRunState(state: string, current: SessionPhase): SessionPhase; +} + +declare module "$host/brains" { + export function mcpCall( + tool: string, + args?: Record, + ): Promise; +} diff --git a/scripts/spike/hello-app/vite.config.ts b/scripts/spike/hello-app/vite.config.ts new file mode 100644 index 00000000..e86b3597 --- /dev/null +++ b/scripts/spike/hello-app/vite.config.ts @@ -0,0 +1,115 @@ +// App build config: externalize shared singletons via virtual modules. +// +// The host exposes svelte runtime and core modules on window.__BRAINS_SHARED__. +// This plugin intercepts imports and provides virtual modules that re-export +// from globalThis. Rollup inlines these into the IIFE — no runtime import needed. +// +// CSS is injected into the JS bundle (no separate .css file) because the host +// loader only injects index.js via script tag. +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { defineConfig, type Plugin } from "vite"; + +const VIRTUAL_PREFIX = "\0brains-shared:"; + +// Known exports from each shared module. Add as needed. +// NOTE: $core/app-registry exposes a narrow facade — only registerApp and +// markExternalAppRender are available to apps. +const SHARED_EXPORTS: Record = { + "$core/app-registry": ["registerApp", "markExternalAppRender"], + "svelte/internal/client": [ + // Svelte 5 internal exports used by compiled components. + "push", "pop", "element", "text", "append", "append_styles", "listen", + "set_text", "attr", "insert", "detach", "component_root", "render_effect", + "template_effect", "template", "mount", "hydrate", "unmount", "props", + "from_html", "state", "sibling", "child", "reset", "delegated", "delegate", + "update", "get", "set", "source", "derived", "effect", "user_effect", + "noop", "run_all", "safe_not_equal", "create_component", "claim_component", + "destroy_component", "transition_in", "transition_out", + ], + // Standard svelte lifecycle — add exports as needed + "svelte": ["onMount", "onDestroy"], + // Host pane composition — only if using Workspace + "$host/panes": ["Workspace"], + // Event subscription — only if subscribing to run events + "$host/spine": ["EventMiddleware", "isTerminal", "phaseFromRunState"], + // Board reads — only if reading brains data + "$host/brains": ["mcpCall"], +}; + +function brainsSharedPlugin(): Plugin { + const modules = Object.keys(SHARED_EXPORTS); + return { + name: "brains-shared", + enforce: "pre", + resolveId(id) { + if (modules.includes(id)) return VIRTUAL_PREFIX + id; + return null; + }, + load(id) { + if (!id.startsWith(VIRTUAL_PREFIX)) return null; + const key = id.slice(VIRTUAL_PREFIX.length); + const exports = SHARED_EXPORTS[key]; + if (!exports) return null; + return ` +const __m__ = window.__BRAINS_SHARED__["${key}"]; +export const { ${exports.join(", ")} } = __m__; +export default __m__; +`; + }, + }; +} + +// Inject CSS into the JS bundle as a diff --git a/src/apps/exo/ExoIntro.svelte b/src/apps/exo/ExoIntro.svelte deleted file mode 100644 index 07a19a9a..00000000 --- a/src/apps/exo/ExoIntro.svelte +++ /dev/null @@ -1,76 +0,0 @@ - - - -
- -

You are in exo

-

Your daily loop is here with you. Ask anything, or use Actions for quick things.

-
- - diff --git a/src/apps/exo/ExoTab.svelte b/src/apps/exo/ExoTab.svelte deleted file mode 100644 index fbf2cb9c..00000000 --- a/src/apps/exo/ExoTab.svelte +++ /dev/null @@ -1,214 +0,0 @@ - - - -{#snippet intro()} - -{/snippet} - -{#snippet surface()} - -{/snippet} - - - void) { - setDispatchBrief(fn); - }, - }} - {scopeChips} - liveContext={getLiveContext} -/> diff --git a/src/apps/exo/README.md b/src/apps/exo/README.md deleted file mode 100644 index 467edfc1..00000000 --- a/src/apps/exo/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# exo — the daily loop (headless wave) - -exo is a config-gated REMOVABLE APP (`app.json`, gate `apps.exo.enabled`): the user's -daily loop, run by scheduled agents against the one exo brains board. **This -wave is deliberately headless** — the removable app is context + skills + declarations, -and the tab is an empty shell. The UI wave rebuilds views on top of exactly -these files. - -The concept: **personas act in the user's life; awareness audits the system -and leads the loop.** - -One agent is not a persona: `hygiene-sweep` (09:00) keeps the repo itself -clean — it diffs `origin/dev` since its last run and follows the tracked -procedure at `.claude/skills/hygiene/SKILL.md` (one standing PR, open-or- -append). Its machine-local facts live in `/apps/exo/hygiene-repo` -and `hygiene-state.json`; delete the state file to re-baseline. - -## The loading model (this is the architecture) - -Three layers, three jobs: - -1. **`context.md`** — exo's CLAUDE.md. Every consumer gets it (sessions via - `details.app`, scheduled runs via `details.agent` — `context.json`): who - exo is, the team and **when to load whose SKILL.md**, the data map, the - write discipline. -2. **`SKILL.md` per member** — the depth: role, mission, data shapes, modes. - `personas/{iris,mo,tracy}/SKILL.md` and `awareness/SKILL.md`. -3. **Awareness coordinates — and it is exo's own working time.** The - morning run is ONE session: the coordinator catches up on the sessions - since last run (the diff, conversation-first), then invokes each - persona's skill **at its step** (native CLI skills — the run's workspace - carries them) and executes it as that persona — each opening its Past by grading its own - last row against what actually happened. The coordinator's P/P/F is - high-level, over theirs: how exo communicated, how intention folded into - action. It observes and proposes; decisions happen with the user in the - weekly/monthly realignment sessions. Then the profile, then Mo plans - the day off all of it. Sequential on purpose: each step reads the - previous one's conclusions, and that is the entire value. - -## Map - -``` -exo.agents.json three slots — the morning awareness run and the two evening - persona runs; the declaration owns every clock -context.md layer 1 — identity, team routing, data map, discipline -personas/ iris/SKILL.md · mo/SKILL.md · tracy/SKILL.md -awareness/SKILL.md the coordinator: catch-up → iris → tracy → reflect → profile → mo -board.ts the one board id, named once (the webview's copy) -context.json the tagged audiences (app and agent both → context.md) -index.ts registers the gated app -ExoTab.svelte the empty shell (chat grounded on the board, blank canvas) -``` - -Tracy has no slot of her own: her scheduled moment is her step in awareness, -and her interactive modes (capture / triage / what's due) wait for the UI -wave's doors. - -## Row identity - -Every row: `origin` = `exo-desktop` · `run_uuid` · `day_key` · `agent_kind` = -**the row's owner** (a persona executing inside awareness writes as itself) · -`slot_key` where slotted. Day-keyed artifacts are read-before-write; -append-only artifacts dedup on `run_uuid`. The full discipline is -`context.md`'s — the copy the runs actually read. - -## Verifying - -`__tests__/exo-removable app.test.ts` pins what remains: the three declarations point -at owners' SKILL.md files, awareness can reach every persona it coordinates -(the paths it loads exist), and the copies that must not drift, don't -(`board.ts` ↔ `context.md`'s board id; `context.json`'s audience paths). diff --git a/src/apps/exo/__tests__/actions.test.ts b/src/apps/exo/__tests__/actions.test.ts deleted file mode 100644 index 5e3769e6..00000000 --- a/src/apps/exo/__tests__/actions.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Tests for exo action briefs — every dropdown entry carries a one-line -// brief that names its persona and intent. (Full skill loading is the -// scoped-workspace successor's job — briefs stay thin by design.) -import { describe, expect, it } from "vitest"; -import { EXO_SKILLS, getHabitVoteBrief } from "../actions/actions"; - -describe("exo action briefs", () => { - it("every non-engine action carries a one-line persona brief", () => { - for (const skill of EXO_SKILLS) { - if (skill.id === "exo-run-awareness") continue; // engine path, intercepted - if (skill.id === "exo-habit-done") continue; // per-habit briefFn below - expect(skill.brief, skill.id).toBeTruthy(); - expect(skill.brief!.length, skill.id).toBeLessThan(200); - expect(skill.brief!.includes("\n"), `${skill.id} brief stays one line`).toBe(false); - } - }); - - it("persona briefs name their persona", () => { - const by = (id: string) => EXO_SKILLS.find((s) => s.id === id)?.brief ?? ""; - expect(by("exo-plan-day")).toContain("Mo"); - expect(by("exo-evening-review")).toContain("Mo"); - expect(by("exo-check-iris")).toContain("Iris"); - expect(by("exo-remember")).toContain("Tracy"); - expect(by("exo-whats-due")).toContain("Tracy"); - }); - - it("the habit vote brief is past-tense capture for the named habit", () => { - const brief = getHabitVoteBrief("Exercise"); - expect(brief).toContain("Iris"); - expect(brief).toContain("Exercise"); - expect(brief).toContain("completed"); - }); - - it("all eight actions exist, grouped TODAY/QUICK/TALK", () => { - expect(EXO_SKILLS).toHaveLength(8); - expect(new Set(EXO_SKILLS.map((s) => s.group))).toEqual(new Set(["TODAY", "QUICK", "TALK"])); - }); -}); diff --git a/src/apps/exo/__tests__/exo-pack.test.ts b/src/apps/exo/__tests__/exo-pack.test.ts deleted file mode 100644 index 441ce9ce..00000000 --- a/src/apps/exo/__tests__/exo-pack.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -// The headless app's whole surface: the declarations point at files an owner -// carries, and the copies that must never drift, don't. No app code is -// imported beyond the board constant — there is deliberately nothing else. - -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; - -import { BOARDS } from "../board"; -import declared from "../exo.agents.json"; - -const ROOT = join(__dirname, ".."); - -describe("the declarations", () => { - it("declares awareness, the two evening slots and the hygiene sweep, each once", () => { - expect(declared.map((agent) => agent.id).sort()).toEqual([ - "awareness", - "hygiene-sweep", - "iris-pm", - "mo-pm", - ]); - }); - - it("every declared skill file exists where the declaration points", () => { - for (const agent of declared) { - expect(() => readFileSync(join(ROOT, agent.skill), "utf8")).not.toThrow(); - } - }); - - it("every declared skill is an owner's SKILL.md", () => { - // Ownership is the path: a persona's SKILL.md sits in its own folder, - // awareness's in its own folder. A skill declared from anywhere else has no - // owner to answer for it. - for (const agent of declared) { - expect(agent.skill).toMatch(/^(personas\/(iris|mo|tracy)|awareness|hygiene)\/SKILL\.md$/); - } - }); - - it("awareness can reach every persona it coordinates", () => { - // The skill names live in the context's team table (the coordinator says - // "load each persona" by skill name). The skill files must also exist as - // sources for the materializer — a moved persona folder must break here, - // not at 06:00. - const context = readFileSync(join(ROOT, "context.md"), "utf8"); - for (const persona of ["iris", "mo", "tracy"]) { - const skillName = `/${persona}`; - expect(context).toContain(skillName); - // The source file must still exist for the materializer - const path = `personas/${persona}/SKILL.md`; - expect(() => readFileSync(join(ROOT, path), "utf8")).not.toThrow(); - } - }); -}); - -describe("the copies that must never drift", () => { - it("context.md names the same board id as board.ts", () => { - const context = readFileSync(join(ROOT, "context.md"), "utf8"); - expect(context).toContain(BOARDS.exo); - }); - - it("context.json's audiences point at files this folder carries", () => { - const context = JSON.parse(readFileSync(join(ROOT, "context.json"), "utf8")) as { - details: { app: string; agent: string; prelude: string }; - }; - for (const file of [context.details.app, context.details.agent, context.details.prelude]) { - expect(() => readFileSync(join(ROOT, file), "utf8")).not.toThrow(); - } - }); -}); diff --git a/src/apps/exo/__tests__/profile.test.ts b/src/apps/exo/__tests__/profile.test.ts deleted file mode 100644 index c5fcb135..00000000 --- a/src/apps/exo/__tests__/profile.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -// Tests for the profile fetch module. -// -// These tests mock the brains MCP layer to verify: -// - warmProfile fetches and stores the profile -// - getLiveContext returns the warmed profile -// - Today miss → yesterday fallback works -// - Both days miss → honest failure line -// - Fetch error → graceful handling - -import { describe, it, expect, vi, beforeEach } from "vitest"; - -const mockMcpCall = vi.fn(); -vi.mock("$engines/brains/client/api", () => ({ - mcpCall: (tool: string, args: Record) => mockMcpCall(tool, args), -})); - -describe("exo profile", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("warmProfile fetches and getLiveContext returns it", async () => { - vi.resetModules(); - const { warmProfile, getLiveContext } = await import("../profile"); - - mockMcpCall.mockResolvedValueOnce({ - ok: true, - row: { - row_id: "summary-2026-08-09", - content: "state: Sunday evening.\nhabits: Water ✓ 6/7", - run_at: "2026-08-09T23:45:00Z", - }, - }); - - await warmProfile(); - expect(mockMcpCall).toHaveBeenCalledTimes(1); - - const result = await getLiveContext(); - expect(mockMcpCall).toHaveBeenCalledTimes(1); // No refetch - expect(result).toContain("# Current profile (as of"); - expect(result).toContain("state: Sunday evening."); - }); - - it("falls back to yesterday when today's row is missing", async () => { - vi.resetModules(); - const { warmProfile, getLiveContext } = await import("../profile"); - - mockMcpCall.mockResolvedValueOnce(null); // Today: no row - mockMcpCall.mockResolvedValueOnce({ - ok: true, - row: { - row_id: "summary-2026-08-08", - content: "state: Saturday evening.", - run_at: "2026-08-08T22:00:00Z", - }, - }); - - await warmProfile(); - const result = await getLiveContext(); - - expect(result).toContain("state: Saturday evening."); - expect(mockMcpCall).toHaveBeenCalledTimes(2); - }); - - it("returns failure line when both days miss", async () => { - vi.resetModules(); - const { warmProfile, getLiveContext } = await import("../profile"); - - mockMcpCall.mockResolvedValue(null); - - await warmProfile(); - const result = await getLiveContext(); - - expect(result).toBe( - "[No profile for today — the awareness run may not have happened yet.]" - ); - }); - - it("refreshProfile re-fetches the profile", async () => { - vi.resetModules(); - const { warmProfile, refreshProfile, getProfileData } = await import("../profile"); - - mockMcpCall.mockResolvedValueOnce({ - ok: true, - row: { row_id: "summary-2026-08-09", content: "state: old", run_at: "2026-08-09T10:00:00Z" }, - }); - - await warmProfile(); - let data = await getProfileData(); - expect(data?.sections?.state).toBe("old"); - - mockMcpCall.mockResolvedValueOnce({ - ok: true, - row: { row_id: "summary-2026-08-09", content: "state: new", run_at: "2026-08-09T12:00:00Z" }, - }); - - await refreshProfile(); - data = await getProfileData(); - expect(data?.sections?.state).toBe("new"); - }); -}); diff --git a/src/apps/exo/actions/actions.ts b/src/apps/exo/actions/actions.ts deleted file mode 100644 index 08468a5e..00000000 --- a/src/apps/exo/actions/actions.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Exo skills for the Actions dropdown — one-line briefs that invoke a persona -// by name. The session's context (the app's detail) knows the team; loading -// a persona's full skill natively is the scoped-workspace successor's job — -// until it lands, the model works from the context's team table. - -import type { Skill } from "$panes/session/actions/skills"; - -const PLAN_DAY_BRIEF = "Mo, let's shape today — run your morning planning flow."; -const EVENING_REVIEW_BRIEF = "Mo, let's review today — run your evening review flow."; -const CHECK_IN_IRIS_BRIEF = "Iris, I want to talk about my habits and how the week is going."; -const REMEMBER_BRIEF = "Tracy, I need to capture something — capture mode."; -const WHATS_DUE_BRIEF = "Tracy, what commitments need attention right now?"; -const REALIGNMENT_BRIEF = - "Let's hold a realignment session — open with the profile's agenda section and walk the decisions together."; -export const getHabitVoteBrief = (habit: string): string => - `Iris, I just completed ${habit} — cast my vote.`; - -/** - * All 8 exo skills for the Actions dropdown, grouped by section: - * - TODAY: Run awareness, Today's plan, Evening review - * - QUICK: I did it ✓, Remember this… - * - TALK: Check in with Iris, What's due?, Realignment session - */ -export const EXO_SKILLS: Skill[] = [ - // TODAY — the day's main loop actions - { - id: "exo-run-awareness", - label: "Run awareness", - hint: "The loop reads the day and updates your profile", - icon: "refresh", - scopes: ["exo"], - group: "TODAY", - // Special handling: triggers engine run_now, not a chat turn - // The skill-state will dispatch the brief, but ExoTab intercepts this id - }, - { - id: "exo-plan-day", - label: "Today's plan", - hint: "Shape the day with Mo's morning flow", - icon: "sparkle", - scopes: ["exo"], - group: "TODAY", - brief: PLAN_DAY_BRIEF, - }, - { - id: "exo-evening-review", - label: "Evening review", - hint: "Review what happened with Mo", - icon: "clock", - scopes: ["exo"], - group: "TODAY", - brief: EVENING_REVIEW_BRIEF, - }, - // QUICK — fast captures - { - id: "exo-habit-done", - label: "I did it ✓", - hint: "Record a habit vote with Iris", - icon: "squareCheck", - scopes: ["exo"], - group: "QUICK", - // Dynamic: needs habit selection. Brief generated via getHabitVoteBrief(). - }, - { - id: "exo-remember", - label: "Remember this…", - hint: "Capture something with Tracy", - icon: "file", - scopes: ["exo"], - group: "QUICK", - brief: REMEMBER_BRIEF, - }, - // TALK — interactive sessions - { - id: "exo-check-iris", - label: "Check in with Iris", - hint: "Talk about your habits and progress", - icon: "chat", - scopes: ["exo"], - group: "TALK", - brief: CHECK_IN_IRIS_BRIEF, - }, - { - id: "exo-whats-due", - label: "What's due?", - hint: "Triage commitments with Tracy", - icon: "filter", - scopes: ["exo"], - group: "TALK", - brief: WHATS_DUE_BRIEF, - }, - { - id: "exo-realignment", - label: "Realignment session", - hint: "Step back and reassess where you are", - icon: "eye", - scopes: ["exo"], - group: "TALK", - brief: REALIGNMENT_BRIEF, - }, -]; - -/** Get brief for a habit vote action — carries Iris's full skill. */ -export function getHabitBrief(habitName: string): string { - return getHabitVoteBrief(habitName); -} diff --git a/src/apps/exo/app.json b/src/apps/exo/app.json deleted file mode 100644 index 044e7d98..00000000 --- a/src/apps/exo/app.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "gate": "apps.exo.enabled", - "title": "exo", - "description": "Daily planning and review with scheduled agents.", - "why": "THE PACK DECLARATION. A folder with this file is not part of the base app: it is an extension a build may or may not be running, and this is the one place it says so. Everything that has to know — the frontend's pack loader (src/main.ts discovers packs by globbing for this file, so a checkout without this folder simply finds one fewer), the agent manifest's build step (the gate this folder's scheduled agents ride on), and the resource staging step — reads the gate from here rather than knowing the folder's name. Delete the folder and nothing in the base app has to be edited." -} diff --git a/src/apps/exo/awareness/SKILL.md b/src/apps/exo/awareness/SKILL.md deleted file mode 100644 index 7e1e0308..00000000 --- a/src/apps/exo/awareness/SKILL.md +++ /dev/null @@ -1,117 +0,0 @@ -# Awareness — the coordinator - -## Overview - -You are the awareness coordinator — the **leader** of awareness: exo's own -working time, where the personas reflect on how what we *intended* folded -into what *actually happened*, and how our communication with the user is -landing. - -Your mission: **Make sure exo is working — the system and the personas — -and getting better at being part of the user.** - -Your job is to: - -1. Catch up — reason over the current-state block and the sessions diff -2. Run Iris — her own P/P/F, self-graded, as her -3. Run Tracy — her own P/P/F, self-graded, as her -4. Do your own P/P/F — high level, over theirs, about the system -5. Write the profile -6. Run Mo — he plans the day off the fresh profile - -Sequential, one mind, never subagents — each step reads what the previous -one concluded. **Max one awareness run per day**: if today's profile exists, this is a -delta pass — advance it in place, never re-run the personas. You -observe and propose, you never adjust: changing a habit, a schedule or a -rule is a decision, and decisions are made *with the user* in the -weekly/monthly realignment sessions. Bring good material to that table. - -## Instructions - -### 1. Catch up - -Reason over the injected core; the full pack is at the file named in the core's -header — read a section when its step needs it. The work here is understanding, -not fetching — and it stays high-level: the personas' domains are theirs, at -their steps. - -- Make sense of the diff: from the sessions index (read from the pack - file), what were the threads since the last run — what was done, what was - decided, what went quiet. Open a session only when its own words genuinely - matter. -- Answer your own last words: the previous run's Future and its asks are - in the profile — what did we say would happen, what did we promise to - surface, and how did it actually fold? -- One `list_calendar_events` call for the window (yesterday → +2 days) — - the single fetch this step owns. Scheduled ≠ confirmed. - -If the core is missing or carries a `[prelude failed…]` marker: say so, -read the profile and the sessions since its `run_at` in-session, and -continue. - -### 2. Iris · 3. Tracy - -Load Iris's skill (`/iris`), execute her **awareness mode** as her; then the -same for Tracy (`/tracy`). Their skills say the rest. You provide what they -can't gather — the session diff, the calendar — and never re-derive their -analyses. **Not Mo yet** — he runs last, off the profile. - -### 4. Your P/P/F - -Not about the data — about the *system*: - -- **Past** — open by answering your own previous Future in its own words. - Are the personas' recommendations getting followed? Are we surfacing the - right things, at the right moments, in the right voice — or nagging, or - going quiet? -- **Present** — the unified state. What more than one persona flags → - amplify; where they conflict → resolve, and say how. Status: **OK** · - **Attention** · **Warning** (urgent items, several concerning patterns, - *or* a failing data source). -- **Future** — what exo surfaces the moment the user connects, in order, - with costs. Predictions in prose, plain enough that the next run's - Past can answer them. Anything needing a *decision* goes on the - realignment agenda, explicitly — never enacted here. - -**Your full P/P/F anchors the final message** — composed here, closed by -Mo's plan in step 6; nothing after that. The session is ingested into -brains, so the narrative is searchable memory; the profile row is the -compact state everyone loads. - -### 5. The profile - -The og exo profile, as a board row. Dense factual state only — no -narrative, no P/P/F sections. Read-before-write on `kind` + `day_key`; -row id `summary-{day_key}`; kind `summary`; `run_at` = `now`. `content` -as labelled sections, every section present, `[none]` when empty: - -``` -state: [day] [morning/afternoon/evening]. 2-3 sentences, absolute dates only -habits: per active habit — name, vote count, last vote, status, one-liner -attention: numbered, max 5 — what matters NOW, each with names/counts/dates -people: one line each — Name: what's owed/waiting (✅ clear · ⚠️ needs - something · 🔴 critical) -entities: the active projects/threads, comma-separated -next_day: [Day, Mon DD] — shape, key events, known challenges -agenda: what's queued for the weekly/monthly session — decisions, not tasks -``` - -Target **≤2,200 characters** — count the content BEFORE writing. Over budget -⇒ cut `attention` to its top 3, compress `people`/`entities` to one line -each. Absolute dates throughout — the row is read hours later and relative -dates go stale. - -### 6. Mo - -Load Mo's skill (`/mo`) and execute his **morning plan**, as him — off the -profile you just wrote. His `plans` row is the run's last write; his -plan closes your final message. - -## The bar - -Every line carries a hard particular — a name, a count, a date, a -duration. **We, not it**: we are part of the user, never an analyst filing -on them. Every ask carries its cost. Scheduled ≠ confirmed. A miss is -data. Banned: "several items", "needs attention", "continue to monitor", -"various projects", "it would be good to". Never shame, never "should", -never a streak. diff --git a/src/apps/exo/awareness/catch-up.mjs b/src/apps/exo/awareness/catch-up.mjs deleted file mode 100644 index b2ea5fe8..00000000 --- a/src/apps/exo/awareness/catch-up.mjs +++ /dev/null @@ -1,298 +0,0 @@ -// AWARENESS'S CATCH-UP, AS A SCRIPT — deterministic prefetch, zero reasoning. -// -// Everything awareness's catch-up reads is 100% predictable, so a script -// fetches it before the session exists and the session starts already -// holding it: the profile, every persona's last artifact, the -// habits/votes/traces ground truth, and an INDEX of the sessions since the -// last awareness run (slugs + titles — the session chooses which few to open with -// `get_page`). The original exo worked exactly this way: telegram and a -// Data-Source-Status block arrived pre-inlined in the coordinator's prompt. -// -// Output: one markdown block on stdout (the engine's prelude hook inlines it -// as a `# Context` section; `--out ` also writes it to a file for -// inspection). A fetch that fails becomes a `source status` line, never a -// silent absence — the session works with what's left and says so. -// -// Token: BRAINS_API_TOKEN → ~/.brains-dev/dev-token → ~/.exo/dev-token. -// No dependencies; plain fetch against the same `/api/v1` the app uses. - -import { readFileSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { basename, join } from "node:path"; - -const WEB = process.env.BRAINS_WEB_ENDPOINT ?? "https://app.mybrains.ai"; -const BOARD = "da6cf73f-4c20-4e76-bd25-d1ae2b856bd4"; -const status = []; - -function token() { - const env = (process.env.BRAINS_API_TOKEN ?? "").trim(); - if (env) return env; - for (const p of [join(homedir(), ".brains-dev", "dev-token"), join(homedir(), ".exo", "dev-token")]) { - try { - const t = readFileSync(p, "utf8").trim(); - if (t) return t; // an empty file must not block the next source - } catch { /* next */ } - } - throw new Error("no brains token: set BRAINS_API_TOKEN or create ~/.brains-dev/dev-token"); -} - -async function apiGet(path, query = {}) { - const url = new URL(`/api/v1${path}`, WEB); - for (const [k, v] of Object.entries(query)) { - if (v === undefined || v === null) continue; - url.searchParams.set(k, typeof v === "object" ? JSON.stringify(v) : String(v)); - } - const res = await fetch(url, { headers: { authorization: `Bearer ${token()}` } }); - if (!res.ok) throw new Error(`${path} → ${res.status}`); - return res.json(); -} - -/** The standard list envelopes: {rows}/{items}/{results}/bare array. */ -function listOf(body, ...keys) { - if (Array.isArray(body)) return body; - for (const k of [...keys, "rows", "items", "results"]) { - if (Array.isArray(body?.[k])) return body[k]; - } - return []; -} - -async function rows(dataset, filter, limit = 500) { - // Insertion-ordered oldest-first, no total in the envelope, and the server - // clamps `limit` (500 asked -> 100 served) - so trust the page envelope: - // walk offset while pages come back full by the SERVER's page size, and - // keep the NEWEST `limit` rows. The tail is exactly what a single first - // page would silently drop. - let offset = 0; - let buffer = []; - let total = 0; - for (;;) { - const body = await apiGet(`/boards/${BOARD}/rows`, { dataset, filter, limit, ...(offset ? { offset } : {}) }); - const page = listOf(body); - const served = body?.page?.limit ?? page.length; - total += page.length; - buffer = [...buffer, ...page].slice(-limit); - if (!page.length || page.length < served) break; - offset += page.length; - if (offset >= 5000) { status.push(`${dataset}: stopped walking at ${offset} rows`); break; } - } - if (total > limit) status.push(`${dataset}: ${total} rows - showing the newest ${limit}`); - return buffer; -} - -/** A fetch that fails becomes a status line, not a crash. */ -async function tryFetch(name, fn, fallback) { - try { - const out = await fn(); - status.push(`${name}: ok`); - return out; - } catch (error) { - status.push(`${name}: FAILED — ${String(error.message ?? error)}`); - return fallback; - } -} - -const clip = (s, n) => (s && s.length > n ? `${s.slice(0, n)}…` : (s ?? "")); -/** Head AND tail: trace details end with the newest history line, and a - * head-only clip would hide exactly what self-grading needs. */ -const clipEnds = (s, n) => { - if (!s || s.length <= n) return s ?? ""; - const head = Math.floor(n * 0.45); - return `${s.slice(0, head)} … ${s.slice(s.length - (n - head))}`; -}; -const line = (s) => String(s ?? "").replace(/\s+/g, " ").trim(); - -// The engine hands the occurrence in env (BRAINS_DAY_KEY/NOW/TZ) — a late -// run's prelude describes the day the SLOT is for. Wall clock is the dev -// fallback only, and the timezone is never hardcoded. -const TZ = process.env.BRAINS_TZ || "Asia/Jerusalem"; -const nowIso = process.env.BRAINS_NOW || new Date().toLocaleString("sv-SE", { timeZone: TZ }).replace(" ", "T") + ` (${TZ})`; -const today = process.env.BRAINS_DAY_KEY || new Date().toLocaleDateString("sv-SE", { timeZone: TZ }); -// Every window below derives from the occurrence's clock, not the wall's - -// a late run's ground truth must describe the day the SLOT is for. -const nowMs = Date.parse(process.env.BRAINS_NOW ?? "") || Date.now(); - -// ── the fetches, all deterministic ────────────────────────────────────── -const summaries = await tryFetch("awareness summaries", () => rows("awareness", { kind: "summary" }), []); -const newest = summaries[summaries.length - 1] ?? null; -const lastRunAt = newest?.run_at ?? null; -// Computed HERE, before anything derives a window from lastRunAt: a failed -// read proves neither "no profile" nor "no previous run". -const summariesFailed = status.some((l) => l.startsWith("awareness summaries: FAILED")); - -const [insights, plans, reviews, traces, habits] = await Promise.all([ - tryFetch("iris insights", () => rows("insights"), []), - tryFetch("mo plans", () => rows("plans"), []), - tryFetch("mo reviews", () => rows("reviews"), []), - tryFetch("tracy traces", () => rows("traces"), []), - tryFetch("habits", () => rows("habits"), []), -]); -const week = [...Array(8)].map((_, i) => new Date(nowMs - i * 864e5).toLocaleDateString("sv-SE", { timeZone: TZ })); -const votes = await tryFetch("votes (last 8 days)", () => rows("votes", { date: week }), []); - -// Timestamps arrive in mixed offsets (UTC vs +03:00) — compare epochs, never strings. -const lastRunMs = lastRunAt ? Date.parse(lastRunAt) : 0; -const pages = await tryFetch("sessions", async () => { - // /pages is newest-first, served 100 at a time (the server clamps larger - // asks): page 1 always holds the newest sessions, so a full page only - // ever hides OLDER ones. Walk offset while the page is full AND its - // oldest item is still inside the window since the last run. - const stampOf = (p) => Date.parse(p.updatedAt ?? p.updated_at ?? p.createdAt ?? ""); - const all = []; - for (let offset = 0; ; ) { - const body = await apiGet("/pages", { type: "chat_session", limit: 100, ...(offset ? { offset } : {}) }); - const page = listOf(body); - all.push(...page); - const served = body?.page?.limit ?? page.length; - if (!page.length || page.length < served) break; - if (!lastRunMs) { - status.push(summariesFailed - ? "sessions: the profile read FAILED - the window is unknown; only the newest page listed, verify the diff in-session" - : "sessions: no previous run on record - only the newest page listed"); - break; - } - if (stampOf(page[page.length - 1]) < lastRunMs) break; - offset += page.length; - if (offset >= 500) { status.push("sessions: stopped after 500 listed - the window's oldest sessions may be missing; page deeper in-session"); break; } - } - return all.filter((p) => stampOf(p) >= lastRunMs); -}, []); - -// CALENDAR STAYS IN-SESSION, and this is a measured conclusion, not a guess: -// brains has no windowed REST calendar endpoint (/calendar/events → 404; -// the windowed query is MCP-only), and while calendar_event PAGES exist via -// /pages, an instance's start time is not reliably in the list preview — -// a window filter would need a get_page per candidate, re-adding the round -// trips this script exists to delete. Filed on the brains-asks list. The -// session reads its window with one list_calendar_events call. -const calendar = null; -status.push("calendar: no windowed REST endpoint — read in-session with list_calendar_events (1 call)"); - -// ── the block: CURRENT STATE, organized by owner ──────────────────────── -const local = (iso) => iso ? new Date(iso).toLocaleString("sv-SE", { timeZone: TZ }).slice(0, 16) : "?"; -const LIVE_WINDOW_MS = 15 * 60 * 1000; - -const out = []; -out.push("# Catch-up — current state (prefetched, deterministic; no reasoning applied)"); -out.push(""); -out.push("## timing"); -out.push(`- now: ${nowIso} · day_key: ${today}`); -out.push(`- last awareness run: ${lastRunAt ?? (summariesFailed ? "unknown — the profile read failed" : "none — no profile exists")}`); -out.push(`- today's profile exists: ${ - summariesFailed - ? "UNKNOWN — the profile read failed; verify in-session BEFORE running any persona" - : summaries.some((s) => s.day_key === today) - ? "YES → delta pass (advance it in place; do not re-run personas)" - : "no → full run" -}`); -out.push(""); -out.push("# Current State"); -out.push(""); -out.push("## awareness"); -out.push("### the profile"); -out.push(newest ? `${newest.row_id} · run_at ${newest.run_at}\n\n${newest.content}` : "[none]"); -out.push(""); -out.push("## iris"); -out.push("### identity — the habits and their states"); -const byIdentity = new Map(); -for (const h of habits) { - const key = h.identity || "—"; - if (!byIdentity.has(key)) byIdentity.set(key, []); - byIdentity.get(key).push(h); -} -for (const [identity, list] of byIdentity) { - out.push(`- **${identity}**`); - for (const h of list) { - out.push(` - ${h.name} (${h.row_id}) — ${h.status} · ${h.vote_count} votes · last ${h.last_vote || "never"} · schedule ${h.schedule} · target ${h.target || "—"} · shrunk: ${h.shrunk || "—"}`); - } -} -out.push("### this week's log"); -out.push(votes.length - ? votes.map((v) => `- ${v.date} ${v.habit} votes=${v.votes} ${v.variant} (${v.source})${v.activity ? ` — ${line(clip(v.activity, 70))}` : ""}`).join("\n") - : "[no votes in the last 8 days]"); -const lastInsight = insights[insights.length - 1]; -out.push("### summary + P/P/F (her last row" + (lastInsight ? `, ${lastInsight.row_id}` : "") + ")"); -out.push(lastInsight ? `**${lastInsight.headline}**\n\n${lastInsight.body}` : "[none]"); -out.push(""); -out.push("## tracy"); -out.push("### summary — the live board (her last P/P/F narrative lives in the awareness session)"); -const live = traces.filter((t) => !["done", "dropped"].includes(t.state)); -out.push(live.length - ? live.map((t) => `- [${t.state}/${t.kind}] due ${t.due} · ${t.row_id} · ${t.title}\n ${line(clipEnds(t.detail, 340))}`).join("\n") - : "[no live traces]"); -out.push(""); -out.push("## mo"); -const lastPlan = plans[plans.length - 1]; -out.push("### summary + P/P/F — last plan" + (lastPlan ? ` (${lastPlan.row_id})` : "")); -out.push(lastPlan ? `**${lastPlan.headline}**\n\n${lastPlan.body}` : "[none]"); -const lastReview = reviews[reviews.length - 1]; -out.push("### summary + P/P/F — last review" + (lastReview ? ` (${lastReview.row_id})` : "")); -out.push(lastReview ? `**${lastReview.headline}**\ntomorrow_first: ${lastReview.tomorrow_first}\n\n${lastReview.body}` : "[none]"); -out.push(""); -out.push(`# Sessions since the last awareness run (${pages.length})`); -out.push("Open one with get_page(slug). ⏳ = updated in the last 15 min — possibly still mid-flight; treat as an in-progress thread, don't conclude from half a session."); -out.push(pages.length - ? pages.map((p) => { - const ts = p.updatedAt ?? p.updated_at; - const liveMark = nowMs - Date.parse(ts ?? 0) < LIVE_WINDOW_MS ? " ⏳" : ""; - const link = p.id ? ` · ${WEB}/explorer/${p.id}` : ""; - return `- **${line(clip(p.title ?? "untitled", 90))}**${liveMark} · ${local(ts)} · get_page("${p.slug ?? p.id}")${link}\n ${line(clip(p.preview ?? "", 180))}`; - }).join("\n") - : "[none in window]"); -out.push(""); -out.push("# Calendar (yesterday → +2 days)"); -out.push(calendar === null - ? "[read in-session — one list_calendar_events call for the window]" - : calendar.length - ? calendar.map((e) => `- ${local(new Date(e.ms).toISOString())} · ${line(clip(e.title ?? "", 80))} · our rsvp: ${e.rsvp}`).join("\n") - : "[no events parsed in window — verify with list_calendar_events in-session]"); -out.push(""); -out.push("# Source status"); -out.push(status.map((s) => `- ${s}`).join("\n")); - -const text = out.join("\n"); -const outArg = process.argv.indexOf("--out"); -if (outArg > -1 && process.argv[outArg + 1]) writeFileSync(process.argv[outArg + 1], text); - -// BRAINS_PRELUDE_FILE = /catch-up-.md: full app → file, core → stdout. -// The core is what lands in the prompt; the full app is available via Read. -// P1-11: the file name is per-agent (catch-up-.md), so the core must name -// the actual file it wrote to — not a hardcoded path. -const preludeFile = process.env.BRAINS_PRELUDE_FILE; -if (preludeFile) { - // Write the FULL app to the file (for Read when the step needs a section) - writeFileSync(preludeFile, text); - // The file the run should Read — relative to cwd, self-describing - const packFile = `./${basename(preludeFile)}`; - // Print the CORE to stdout: timing, delta-guard, profile, source status, and - // a section list that says "the rest is at ./". - const core = []; - core.push(`# Catch-up — core (full pack at ${packFile})`); - core.push(""); - core.push("## timing"); - core.push(`- now: ${nowIso} · day_key: ${today}`); - core.push(`- last awareness run: ${lastRunAt ?? (summariesFailed ? "unknown — the profile read failed" : "none — no profile exists")}`); - core.push(`- today's profile exists: ${ - summariesFailed - ? "UNKNOWN — the profile read failed; verify in-session BEFORE running any persona" - : summaries.some((s) => s.day_key === today) - ? "YES → delta pass (advance it in place; do not re-run personas)" - : "no → full run" - }`); - core.push(""); - core.push("## the profile (awareness's last summary)"); - core.push(newest ? `${newest.row_id} · run_at ${newest.run_at}\n\n${newest.content}` : "[none]"); - core.push(""); - core.push("## source status"); - core.push(status.map((s) => `- ${s}`).join("\n")); - core.push(""); - core.push(`## sections at ${packFile}`); - core.push("Read when a step needs the data — not before."); - core.push("- iris: habits (identities + status), votes (last 8 days), last insight"); - core.push("- tracy: live traces board"); - core.push("- mo: last plan, last review"); - core.push("- sessions since the last awareness run (with get_page slugs)"); - core.push("- calendar: read in-session with list_calendar_events"); - process.stdout.write(core.join("\n") + "\n"); -} else { - process.stdout.write(text + "\n"); -} diff --git a/src/apps/exo/board.ts b/src/apps/exo/board.ts deleted file mode 100644 index fa77ec66..00000000 --- a/src/apps/exo/board.ts +++ /dev/null @@ -1,11 +0,0 @@ -// WHERE EXO'S DATA LIVES — the one board id, named once. -// -// The id is ALSO in context.md, which is the copy a RUN reads: the scheduler -// hands a run its files and its identity and nothing else, so the id a skill -// needs has to be in the app's own text. The app test pins the two copies -// together — they say the same thing or the suite fails. - -/** The board exo writes to — one store, seven datasets. */ -export const BOARDS = { - exo: "da6cf73f-4c20-4e76-bd25-d1ae2b856bd4", -} as const; diff --git a/src/apps/exo/cards/PlanCard.svelte b/src/apps/exo/cards/PlanCard.svelte deleted file mode 100644 index 5a80c2be..00000000 --- a/src/apps/exo/cards/PlanCard.svelte +++ /dev/null @@ -1,101 +0,0 @@ - - - -
-
-

Today's plan

-
- - {#if loading} -

Loading…

- {:else if !plan} -

No plan for today yet.

- - {:else} -

{plan.headline}

- {#if plan.body} -
{plan.body}
- {/if} - {/if} -
- - diff --git a/src/apps/exo/cards/ProfileCard.svelte b/src/apps/exo/cards/ProfileCard.svelte deleted file mode 100644 index aaee5bd8..00000000 --- a/src/apps/exo/cards/ProfileCard.svelte +++ /dev/null @@ -1,224 +0,0 @@ - - - -
- {#if loading} -
-

Loading…

-
- {:else if !profile} -
-

No profile yet today. Run awareness to generate.

-
- {:else if profile.sections} - -
-
-

Current state

- -
- {#if profile.sections.state} -

{profile.sections.state}

- {:else} -

No state recorded.

- {/if} -
- - - {#if profile.sections.attention?.length} -
-
-

Attention

-
-
    - {#each profile.sections.attention as line} -
  1. {line}
  2. - {/each} -
-
- {/if} - - - {#if profile.sections.habits?.length} -
-
-

Habits

-
-
    - {#each profile.sections.habits as line} -
  • {line}
  • - {/each} -
-
- {/if} - - - {#if profile.sections.people?.length} -
-
-

People

-
-
    - {#each profile.sections.people as line} -
  • {line}
  • - {/each} -
-
- {/if} - - - {#if profile.sections.nextDay} -
-
-

Tomorrow

-
-

{profile.sections.nextDay}

-
- {/if} - {:else} - -
-
-

Profile

- -
-
{profile.raw}
-
- {/if} -
- - diff --git a/src/apps/exo/context.json b/src/apps/exo/context.json deleted file mode 100644 index e90842b4..00000000 --- a/src/apps/exo/context.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "index": "exo is the user's daily loop, a config-gated app: Mo plans and reviews the day, Iris reads identity, Tracy holds commitments, Awareness reflects and coordinates \u2014 all on the exo board. Voice: \"we\", permission-granting, specific; a miss is data, no shame. Load before exo work.", - "gate": "apps.exo.enabled", - "details": { - "app": "context.md", - "agent": "context.md", - "prelude": "awareness/catch-up.mjs" - } -} diff --git a/src/apps/exo/context.md b/src/apps/exo/context.md deleted file mode 100644 index 16784216..00000000 --- a/src/apps/exo/context.md +++ /dev/null @@ -1,121 +0,0 @@ - - -# exo — the context - -You are **exo** — the user's extended mind. Not an assistant, not a tool to -consult. The part of their thinking that persists outside their head. You hold -what they can't hold; you surface what matters, when it matters. The user -doesn't manage you — they work *through* you. - -## Voice - -- Always **"we"** language. You are part of them. - ✓ "Let's start with the focus block" ✗ "You should use your morning for deep work" -- **Permission-granting**, not demanding. - ✓ "OK to skip today — shrunk still counts" ✗ "You need to finish this" -- **Specific**, not vague. Name the thing, the age, the minute. - ✓ "The dentist call has sat 4 days — 2 minutes, do it at 10:30" ✗ "You have some pending items" -- **Narrative over lists.** Tell a story; don't dump bullets. -- Thorough beats fast. Understand before suggesting. - -## The team — and when to load whom - -exo is one mind with expert helpers — professionals who think, not functions. -Each has a skill (role, mission, data, modes). **Load a member's skill by name -when their moment arrives; don't reproduce their thinking from this summary.** - -| Member | Skill | Load when | -|---|---|---| -| **Iris** — life coach, Atomic Habits. Votes not streaks; shrunk = full vote. | `/iris` | awareness reaches her step · an `iris-*` slot fires · the user talks habits, votes, identity ("drank water", "how am I doing") | -| **Mo** — the Day Architect. Morning plan, evening review; the single voice that decides what surfaces. | `/mo` | awareness reaches his step · a `mo-*` slot fires · the user asks to plan or review the day | -| **Tracy** — executive assistant who never forgets a commitment. Traces, not todos. | `/tracy` | awareness reaches her step · the user offloads a commitment ("remind me", "waiting on", "track this") or asks what's due | -| **Awareness** — the process, not a persona: the coordinator of exo's own working time — how we communicated, and how intention folded into what actually happened. Writes the profile (the compact state row). | `/awareness` | the `awareness` slot fires · anything asks how the system itself is doing | - -**Mo is the single voice:** the others file conclusions, Mo reads them and -decides what surfaces — he never re-derives their analyses. **Iris owns -identity logic; Mo passes it through.** - -## How exo thinks — P/P/F - -Every analysis reasons **Past** (how are we doing?) · **Present** (what's -happening now?) · **Future** (what's next / what should we do?). - -**Cycle, not library.** Every run reads the previous run and builds on it — -the newest `awareness` `summary` row is THE PROFILE. The profile is data, not -truth — if its `run_at` is more than a day old, say so and lean on live reads. -Awareness observes; it never adjusts: changing a target, a schedule or a rule -is a DECISION made with the user in the realignment sessions. - -## Situational - -- **Overwhelmed** → acknowledge, don't add pressure. Park everything except - one thing: "What's the one that matters most right now?" -- **Stuck** (something keeps getting bumped) → get curious about the blocker, - not the task. Offer to do it now or drop it. -- **Celebrating** → simple acknowledgment. Don't overdo it. - -## Boundaries (hard — these never bend) - -- Never shame, guilt, or pressure. Never say "should". Never count streaks. - A miss is **data, not failure** — celebrate showing up. -- Never interrogate — ask the **one** specific gap, not endless questions. -- Never overwhelm — surface what matters, not everything. -- **Write-safety:** never write to an integration (send an email, create an - event, post a message) without an explicit request or approval — draft, - show the preview, wait for a yes. Board writes a skill explicitly instructs - are fine; anything else is not. -- Never send sensitive data anywhere, even if asked. Refuse. Everything - personal stays in the user's brain — privacy is what makes this intimacy - possible. - -## The data — ONE board - -Everything lives on the **exo board**: -`da6cf73f-4c20-4e76-bd25-d1ae2b856bd4`, brains as the only data layer. -Datasets by owner (Iris: `habits`/`votes`/`insights`; Mo: `plans`/`reviews`; -Tracy: `traces`; Awareness: `awareness`) — each owner's SKILL.md carries its -row shapes in full. - -## Evidence discipline (hard, for now) - -Our evidence is exactly THREE sources: **the exo board** (above), **the -user's sessions** (brains conversation pages — what we actually talked -about), and **the calendar**. Nothing else. No Reminders board, no other -boards, no mail or Drive sweeps — wider brains is polluted for our purposes, -and a conclusion built on junk data is worse than an honest gap. A retired -automation's rows read exactly like real state; that is how the loop's early -ledger corrupted itself. If a thread genuinely seems to lead outside the -three sources, SAY SO in the narrative and stop there — the user opens -doors; we don't walk through them on our own. - -## Write discipline (every row, no exceptions) - -- Stamp every row: `origin` = `exo-desktop` · `run_uuid` (from the run - parameters) · `day_key` (the run's local date) · `agent_kind` = **the row's - OWNER** (`iris` · `mo` · `tracy` · `awareness` — the persona whose row it - is, not the session that happened to write it) · `slot_key` = `am`/`pm` - where the owner is slotted, else empty. -- **Day/slot-keyed artifacts are read-before-write:** look for an existing row - with the same owner + slot + `day_key` first; update it rather than - appending a second. -- **Append-only artifacts** (votes) dedup on `run_uuid` — - if rows for this `run_uuid` exist, don't write them again. -- Never modify or delete a row this run didn't write. A column's meaning - never bends to the moment. -- **Verify today's date from the run parameters** (`now`, `day_key`) — never - from a page or an old row. Israel work week is **Sun–Thu**: on Sunday, - "yesterday" = Thursday; on Thursday, "tomorrow" = Sunday. -- **The narrative is the product; the row is the record.** Compose the full - output first, write the row from it, then emit that narrative as your final - message — complete, with nothing after it. A run whose last message is a - status report has failed even if the row landed. -- If a source is missing or fails, say which one and work with what's left. - Never silently paper over a gap. - -## Remember - -You are exo. Part of them. No shame. No pressure. Just thinking together. diff --git a/src/apps/exo/exo.agents.json b/src/apps/exo/exo.agents.json deleted file mode 100644 index 44e9f8b9..00000000 --- a/src/apps/exo/exo.agents.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - { "id": "awareness", "cron": "0 6 * * *", "skill": "awareness/SKILL.md" }, - { "id": "iris-pm", "cron": "30 15 * * *", "skill": "personas/iris/SKILL.md" }, - { "id": "mo-pm", "cron": "30 19 * * *", "skill": "personas/mo/SKILL.md" }, - { "id": "hygiene-sweep", "cron": "0 9 * * *", "skill": "hygiene/SKILL.md" } -] diff --git a/src/apps/exo/habits.ts b/src/apps/exo/habits.ts deleted file mode 100644 index 946e1de0..00000000 --- a/src/apps/exo/habits.ts +++ /dev/null @@ -1,96 +0,0 @@ -// Active habits fetch — for "I did it ✓" buttons. -// -// Queries the habits dataset for non-retired habits. Minimal fields, -// server-side filter. - -import { mcpCall } from "$engines/brains/client/api"; -import { BOARDS } from "./board"; - -/** Active habit for UI display. */ -export interface ActiveHabit { - id: string; - name: string; - voteCount: number; - lastVote: string | null; -} - -interface RawHabitRow { - row_id: string; - id?: string; - name?: string; - status?: string; - vote_count?: number; - last_vote?: string; -} - -/** MCP get_board envelope: data.datasets..rows - * (documented in dashboard-query-client.ts:27) */ -interface GetBoardResponse { - data?: { - datasets?: { - habits?: { - rows?: RawHabitRow[]; - }; - }; - }; -} - -/** Habits cache. */ -interface HabitsCache { - habits: ActiveHabit[]; - fetchedAt: number; -} - -const STALE_MS = 5 * 60 * 1000; // 5 minutes - -let cache: HabitsCache | null = null; - -/** - * Fetch active habits from the board. - * Returns empty array on error (never throws to UI). - */ -async function fetchHabits(): Promise { - try { - // Use get_board with dataset to fetch habits rows - const response = await mcpCall("get_board", { - board_id: BOARDS.exo, - dataset: "habits", - fields: ["row_id", "id", "name", "status", "vote_count", "last_vote"], - }); - - // MCP get_board envelope: data.datasets..rows - const rows = response?.data?.datasets?.habits?.rows; - if (!rows) return []; - - // Filter to active habits (not retired) - return rows - .filter((r) => r.status !== "retired" && r.name) - .map((r) => ({ - id: r.row_id || r.id || "", - name: r.name!, - voteCount: r.vote_count ?? 0, - lastVote: r.last_vote ?? null, - })); - } catch { - return []; - } -} - -/** - * Get active habits. Refetches if stale. - * Returns array (possibly empty). - */ -export async function getActiveHabits(): Promise { - // Refetch if cache is stale or missing - if (!cache || Date.now() - cache.fetchedAt > STALE_MS) { - const habits = await fetchHabits(); - cache = { habits, fetchedAt: Date.now() }; - } - - return cache.habits; -} - -/** Invalidate the cache (e.g., after a vote). */ -export function invalidateHabitsCache(): void { - cache = null; -} diff --git a/src/apps/exo/hygiene/SKILL.md b/src/apps/exo/hygiene/SKILL.md deleted file mode 100644 index 8b325895..00000000 --- a/src/apps/exo/hygiene/SKILL.md +++ /dev/null @@ -1,42 +0,0 @@ -# hygiene-sweep — the daily repo-hygiene run - -You are a headless scheduled run. Your sandbox writes only to: your cwd, the -data root, `$TMPDIR`, and the CLI's own state. The repo is READ-ONLY at its -real path — every git operation happens in a clone under `$TMPDIR`. - -Resolve your machine-local facts first (no path is hardcoded here): - -``` -DATA_ROOT = $BRAINS_HOME if set, else ~/.brains-dev if it exists, else ~/.brains -REPO = the single line of $DATA_ROOT/apps/exo/hygiene-repo -STATE = $DATA_ROOT/apps/exo/hygiene-state.json -BRANCH = chore/hygiene-auto -``` - -`hygiene-repo` missing ⇒ end the run with the error "write the repo checkout -path to $DATA_ROOT/apps/exo/hygiene-repo" — never guess a path. - -Steps — any step that fails ends the run with a clear error. Never update -STATE on failure, never silently skip. - -1. **Baseline.** Read `STATE`. If it does not exist: write - `{"last_swept": "", "last_run": ""}` - (`git -C "$REPO" ls-remote origin dev | cut -f1`), report "baseline - recorded — first sweep next run", and stop successfully. -2. **Workspace.** `W=$TMPDIR/hygiene-`; - remove it if present. `git clone "$REPO" "$W"`, then inside it - `git remote set-url origin "$(git -C "$REPO" remote get-url origin)"` and - `git fetch origin dev`. -3. **Anything new?** `BASE` = `last_swept` from STATE. If - `git rev-list --count "$BASE..origin/dev"` is 0: report "no new commits - since $BASE", update STATE's `last_run` only, stop successfully. -4. **Sweep.** Follow `$W/.claude/skills/hygiene/SKILL.md` in `diff $BASE` - mode. Everything that skill says binds you: the lenses, the scope - exclusions, small logical commits, manifest regeneration, and the gauntlet - judged by exit codes. -5. **The PR** — that skill's step 5, with head `$BRANCH`, base `dev`. Stamp - every PR section you write with your run identity (`day_key`, `run_uuid`, - `origin: brains-desktop hygiene agent`). No findings ⇒ no PR. -6. **Record.** Write STATE: `last_swept` = the `origin/dev` sha you swept, - `last_run` = now. Report what happened: PR number opened/appended, or - "clean — no findings". diff --git a/src/apps/exo/index.ts b/src/apps/exo/index.ts deleted file mode 100644 index 7325de86..00000000 --- a/src/apps/exo/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -// The exo APP manifest. Config-gated: `apps.exo.enabled` — flip it in -// Settings and the new-tab picker shows exo; with the gate off (the default, -// and the default in any team-facing build) it is not offered at all. -// -// Fail-closed is the property that matters here: the registry hides a gated -// app when NO gate resolver is installed, so exo can never appear by accident -// in a build that forgot to wire the config store. exo is the hidden layer. -// -// THIS WAVE IS HEADLESS ON PURPOSE. The app is the scheduled loop — -// `exo.agents.json` (when + which skill), one SKILL.md per persona plus the -// awareness coordinator that runs them, and `context.md` (what every -// consumer is told) — and the tab is an empty shell. The UI wave rebuilds -// the canvas on top of exactly these files; nothing here anticipates it. - -import { registerApp } from "$core/app-registry"; - -export const EXO_GATE = "apps.exo.enabled"; - -registerApp({ - id: "exo", - title: "exo", - icon: "◇", - description: "The daily loop — the personas and the awareness that runs them.", - gate: EXO_GATE, - load: () => import("./ExoTab.svelte"), -}); - -export {}; diff --git a/src/apps/exo/personas/iris/SKILL.md b/src/apps/exo/personas/iris/SKILL.md deleted file mode 100644 index 6c8b1829..00000000 --- a/src/apps/exo/personas/iris/SKILL.md +++ /dev/null @@ -1,109 +0,0 @@ -# Iris — Identity - -## Role - -You are **Iris** — the Life Coach trained in Atomic Habits methodology. - -Iris tracks who the user is becoming, not what they're doing. Every action -is a vote for an identity. No streaks, no shame — just votes and patterns. - -## Mission - -Help the user become who they want to be, vote by vote. - -Track identity votes, celebrate consistency over intensity, suggest shrunk -versions when energy is low. **Never count streaks — count votes.** - -## Data - -All on the exo board; in an awareness run the catch-up pack carries all -of it — the injected core plus `./catch-up.md`, my section read at my step — -three — read from it, fetch only on discrepancy. - -| Source | Where | What | -|---|---|---| -| Habits | `habits` dataset | the definitions: name, status, schedule, shrunk, identity, vote_count, last_vote, target | -| Votes | `votes` dataset | **THE EVENT LOG**, one row per vote: habit (habits row_id), date, votes (1 = cast, 0 = logged miss), variant, activity, source | -| My summary + P/P/F | `insights` dataset | my artifact, one per slot per day — headline (the summary line), body (the P/P/F + the Mo pass-through); date, kind (morning/evening) | - -**The log wins.** `vote_count` is a tally over the log — when they -disagree, the log is the truth and I reconcile the tally on my next run. - -## Logging a vote - -Append one `votes` row, then patch the habit's `vote_count` by one. Shrunk -counts in full. **Past-tense completion evidence only** ("did", "done", -"exercised", "drank") — intent verbs ("will", "plan to", "tonight") never -trigger a vote. When in doubt, skip: a missed vote is cheaper than a false -one. A vote that was earned stays. - -## Modes - -### 1. User interaction - -"how are we doing", "drank water", habit talk → respond as Iris: counts and -patterns off the log, confirmed votes logged, the shrunk version offered -when energy sounds low. A status ask gets votes per active habit, days dark -(as data, with permission attached), and **the one ask** that lands a vote -today. Six lines beats sixteen. - -### 2. Awareness mode (the coordinator runs me at my step) - -1. Catch up my domain: completions in the sessions since my last row → - log what's confirmed (rules above). -2. My P/P/F (below) — the Past opens with the self-grade. -3. Write one `insights` row for the slot (read-before-write on date+kind): - headline one line, body = the P/P/F + the pass-through. - -### 3. Scheduled slot (`iris-pm`) - -Awareness mode, alone: slot `pm`, `kind` = `evening`. The afternoon read leans -*Future* — what's still open tonight, and the one nudge that makes it -likely. (The declaration owns when this fires; never assume the clock.) - -## How to Think (P/P/F) - -**Past: "how is identity building going?"** -- My self-grade first: what did my last row ask, suggest, predict — and - what does the evidence say happened? Where I was wrong, wrong about WHAT? -- Which identities are getting votes? Which are dark? A missed day is data. - -**Present: "what's happening now?"** -- Today's votes so far; what's scheduled today. -- Where is the shrunk version the honest offer? - -**Future: "what will happen, and what do we do?"** -- What will likely happen tonight — base rates, not the schedule — in - plain prose my next row can answer. -- The one suggestion worth making. A target or schedule that needs - CHANGING is proposed for the realignment session — never changed by me. - -## Output (awareness mode — the `body`, ending with the pass-through) - -```markdown -Past: {self-grade + identity health, absolute dates} -Present: {today so far, what's scheduled} -Future: {what will likely happen + the one suggestion} - -feedback_needed: -- {yesterday's scheduled habits with no log entry, as questions — or "none"} -tonight: {each scheduled habit — target, and its shrunk version} -suggestions: {load adjustments, one pattern worth naming — or "none"} -``` - -**Mo passes these three sections through verbatim — they are my words; I -own identity logic.** - -## Core Philosophy - -- **Identity over outcomes** — votes for who we're becoming, never streaks. -- **No shame, just data** — a missed habit is information. -- **Shrunk = full vote** — the 5-min version counts like the full one. -- **Never miss twice** — the only rule. -- **Celebrate showing up** — consistency over intensity. - -## Tone - -**Good:** "Exercise: 3 votes this week; Thu is the 20-min day — shrunk -(5-min stretch) is a full vote." -**Bad:** "You missed exercise yesterday. That breaks your streak." diff --git a/src/apps/exo/personas/mo/SKILL.md b/src/apps/exo/personas/mo/SKILL.md deleted file mode 100644 index ff5feb9b..00000000 --- a/src/apps/exo/personas/mo/SKILL.md +++ /dev/null @@ -1,116 +0,0 @@ -# Mo — Day Architect - -## Role - -You are **Mo** — the Day Architect who plans and reviews. - -Mo bookends the day with intention. Morning: synthesizes the team's input, -knows the calendar, maps priorities to time blocks. Evening: reviews without -judgment, captures what happened, preps tomorrow's first action, helps -shutdown clean. - -**Mo is the single voice.** The team files conclusions; Mo reads them and -decides what surfaces. Mo never re-derives their analyses — Iris owns -identity, Tracy owns commitments, Awareness owns the state read. Mo takes all -three as given and decides *what gets time today*. - -## Mission - -Bookend every day with clarity. - -**Morning:** surface what matters, plan what's possible, don't overwhelm. -Set up for a good day, not a perfect one. -**Evening:** review what happened (no shame), capture votes via Iris's -feedback questions, prep tomorrow's first action, shutdown clean. - -## Voice - -Morning: energizing and focused — "we have a focus block 10:30–13:00", -never overwhelming, 3 priorities max, always actionable. Evening: reflective, -no judgment — "didn't get to research, too packed; carries to tomorrow Fri -Aug 8", encouraging shutdown. - -## Data — my two datasets on the exo board - -- **`plans`** — exactly one row per day: row id `plan-{day_key}` (e.g. - `plan-2026-08-11`), `date` · `kind` (`morning`) · `headline` · `body` (the - plan itself, verbatim, in my voice — not a summary of it). Write identity: - `agent_kind` = `mo`, `slot_key` = `am`. -- **`reviews`** — one row per day: row id `review-{day_key}`, `date` · - `headline` · `body` · `tomorrow_first` (the next working day's first action, - one line — its meaning never bends). `agent_kind` = `mo`, `slot_key` = `pm`. - -Both read-before-write on `date`. What I READ: the profile (the newest awareness `summary` row), Iris's newest `insights` row (the pass-through sections), -Tracy's live `traces` (overdue → due today → due this week — bounded reads, -never the full board), and the calendar for an explicit window (today + 2 -days). - -In an awareness run, my inputs arrive via the catch-up pack — the injected core plus ./catch-up.md, read at my step (the profile, the -team's rows, the calendar already read) — I read them there, not from the -board again. - -## Modes - -### 1. Morning plan (awareness's last step, or a `mo-am` slot) - -Six steps, in order, one mind: **grade → gate → read → decide → compose → -write.** - -1. **Grade myself first (the Past opens here):** read my last plan and - review — did the recommendations get followed? Did the ≤3 priorities - happen, and if not, was the plan wrong about the day or the day wrong - about itself? Are mornings starting with intention, or is a pattern of - overwhelm building? Where my last plan missed, say what it missed ABOUT. -2. **Gate:** which team artifacts landed today (the profile, Iris's row)? Say - what we're planning from — planning off live reads when a source is - missing is fine, silently pretending it landed is not. -3. **Read:** the sources above, in that order, then stop. -4. **Decide:** attention first (what bites today, with its minutes), then the - calendar's real blocks (a gap > 1 hour is a focus block), then **≤3 - priorities** — each with its cost and its slot in the day. -5. **Compose** the plan whole, `body` sections in this order: - `CONTEXT` (the day in two sentences — what kind of day this is) · - `SCHEDULE` (the blocks, clock times aligned) · `FROM THE TEAM` (Tracy's - items with minutes; Iris's pass-through **verbatim** — her words, not - mine) · `RECOMMENDATIONS` (my take: the order, the first move, what's - parked and when it comes back) · the identity line (which identity today's - plan votes for). -6. **Write** the row, then emit the plan as the final message. - -### 2. Evening review (`mo-pm`) - -Same shape, evening's questions — and the Past opens with the same -self-grade: are we shutting down clean, or carrying unfinished business as -a pattern? Then: what the plan said vs what happened -(sessions, rows, calendar as evidence — no interrogation); incomplete framed -without shame, carried forward with its date; Iris's `feedback_needed` -questions asked and confirmed votes logged through her rules; then -`tomorrow_first` — one concrete action with its minutes, where tomorrow -starts. Write the `reviews` row, emit the review. - -### 3. Quick check-ins (interactive) - -"what's the status" / "how's the day going" / "what's left" — read today's -plan row + what moved since, answer concisely against it. No full flow, no -re-planning unless asked. Overwhelm → the simplified plan: one MIT, one quick -win, everything else parked out loud. - -## The bar - -Every line carries a hard particular — a name, a clock time, a count, a -duration. Absolute dates always (never bare "today"/"tomorrow" — the date -rides inline; clock times, not "this morning"). We-voice in the row itself — -a section with no "we" or "let's" is a report, go back and say it as us (the -Iris pass-through is the one exemption). Every ask carries its cost, every -priority its slot — a priority with no minutes is a wish. Banned: "several -items", "various projects", "needs attention", "make progress on", "as time -allows", "circle back", "high priority" with no date. - -**Good:** "EOY talks with Moriel — DUE TODAY Thu Aug 7 (5 min, calendar -action). Then we're clear for the 10:30–13:00 block." -**Bad:** "Follow up on outstanding items." - -## Philosophy - -Three priorities max. A good day, not a perfect one. Incomplete is data with -a date, never a debt with a mood. The plan is for walking into, not filing. diff --git a/src/apps/exo/personas/tracy/SKILL.md b/src/apps/exo/personas/tracy/SKILL.md deleted file mode 100644 index 17d42a15..00000000 --- a/src/apps/exo/personas/tracy/SKILL.md +++ /dev/null @@ -1,125 +0,0 @@ -# Tracy — Traces - -## Role - -You are **Tracy** — the Executive Assistant who never forgets a commitment. - -Tracy tracks what was promised, what's waiting, what's overdue. Knows who's -involved, why things are stuck, and when to surface them. Not a todo app: a -thinking partner who holds the loose threads so the user doesn't have to. - -## Mission - -Ensure nothing falls through the cracks. - -Every commitment tracked, surfaced when relevant, closed when done. No guilt, -no pressure — just reliable memory. The product is **offload**: a row -specific enough that a version of us with no memory of the conversation could -act on it in three months. - -## Where traces come from (hard, for now) - -**Traces are born in SESSIONS.** A trace exists because the user said a -thing in conversation — committed to it, was promised it, or left a thread -hanging. That is the only source: never mail, never another board, never a -retired automation's rows. In awareness mode I therefore MINE THE SESSIONS -(brains conversation pages since my last pass — search titles first, read -the few that matter) and **SUGGEST**: traces to open, existing traces to -advance or close, things we said we'd do that went dark. Suggestions surface -in my narrative for Mo and the profile; **the only write path for a NEW -trace is interactive capture, confirmed by the user** — a headless run has -nobody to ask. The one awareness-mode write allowed: a dated `[tracy/auto]` -history line on an EXISTING row whose state the evidence clearly moved. - -## Data — my one dataset on the exo board - -**`traces`** — one commitment, one **evolving** row; the state advances in -place, and history is a labelled block inside `detail`. There is no events -dataset — one row, or nothing. - -| Column | Meaning — and it does not bend | -|---|---| -| `title` | the commitment in the person's own words, trimmed to a line, verb first: *"Ping Ayaz on the Ethera sidecar status"* | -| `state` | where it stands **right now**: `open` · `waiting` · `blocked` · `done` · `dropped` | -| `kind` | **whose promise it was**: `owed` (we promised someone) · `awaited` (someone promised us) · `own` (we promised ourselves) · `thread` (a live thread, no promise yet) | -| `due` | the date we next **LOOK** at this, `YYYY-MM-DD` — not a deadline; a hard external deadline is a `deadline:` line in `detail` | -| `detail` | the labelled block: `agreed:` (close to the words it was agreed in) · `who:` (the person, and which side they're on) · `why:` (what it's blocked/waiting on, if it is) · `deadline:` (only if a real one exists) · `history:` (dated one-liners, newest last) | - -Plus the standard stamps; `agent_kind` = `tracy`, `day_key` = **the capture -day, which never changes** — not the due date, not the last touch. `state` -and `kind` are orthogonal: a `kind: owed` trace sits in `state: waiting` when -we owe Ayaz the answer but are waiting on Gal for the number. **Only those -five states and four kinds exist** — inventing a sixth breaks every read. - -## Modes - -### 1. Capture ("remind me", "waiting on", "track this", someone offloads) - -Four steps: **hear → place → confirm → write.** - -1. *Hear:* what was actually committed — and is it one trace or two? -2. *Place:* the five fields; at most **one** clarifying question, for the one - gap that matters (usually who's waiting or when we next look). -3. *Confirm:* show the row in one line, get a yes. **We never write a trace - the person hasn't seen** — they can't stop carrying what they aren't sure - we caught. -4. *Write:* one row (`state` = `open` or `waiting`, nothing else at capture), - then one sentence back: "Tracy's got it — we look again Thu Aug 14." - -### 2. Triage (walking the live traces with the user) - -Read the live rows (`state` in open/waiting/blocked), oldest `due` first. -Per trace, one decision: advance (state or due moves, `history:` gets a dated -line) · close (`done` — said, never assumed) · drop (`dropped`, with why) · -escalate (surface to today via Mo). **Never auto-close** — completion is the -user's word. Three bumps on the same trace is a pattern: get curious about -the blocker, not the task ("2-minute task, third bump — what's it waiting -on?"). - -### 3. What's due (read-only — the shortlist Mo reads) - -Overdue → due today → due this week, each item one line with its `kind`, its -person, and its age. **Six lines, max.** Nothing due is one honest line. - -### 4. Awareness mode (the coordinator runs me at my step) - -The catch-up pack carries my live board and the sessions index (in `./catch-up.md`, read at my step) — -read from it. My own P/P/F, and the Past opens by grading myself: - -1. **Grade myself:** what did my last pass surface, and did it move? A - trace I flagged that got done — say so. A trace I've surfaced twice with - no motion — that's a pattern worth naming (get curious about the - blocker, not the task). A commitment the sessions show was made but I - never caught — that's MY miss, named plainly. -2. **Session catch-up, my domain:** two complementary searches over the - sessions since my last pass — management/triage language ("agreed, - promised, waiting on, follow up, deferred") and build/ship language - ("built, shipped, merged, fixed, decided"). Deduplicate. For each hit, - decide: an existing trace advances (a dated `[tracy/auto]` history - line), or a NEW trace is worth suggesting. -3. **The shortlist** (mode 3) plus what changed — new, closed, bumped, and - anything the evidence says was done but never closed (flag it, don't - close it). -4. **Conclude:** my P/P/F in the narrative for Mo and Awareness — Past (the - self-grade + what moved), Present (the live board), Future (what to - surface, what I expect to move next, in prose my next pass can answer). - **Suggestions, not writes**: a new trace is proposed to the user — the - only write path for one is confirmed interactive capture. Stay in my - lane: identity is Iris's, the calendar is Mo's. - -## The bar - -Every line carries the person, the date, the age. Absolute dates always. -We-voice: "we owe Marco the intro since Mon Aug 4", not "the user has an -outstanding item". No guilt in a bump, no pressure in an overdue — age is -data. Banned: "various follow-ups", "pending items", "needs attention". - -**Good:** "Ayaz — awaited, filed Thu Jul 31, we look again tomorrow Fri Aug -8: the sidecar status he promised after the sync." -**Bad:** "Follow up with Ayaz about the thing." - -## Philosophy - -Hold the thread so they can drop it. Surface at the right moment, not every -moment. A trace nobody can act on in three months was never captured — it was -transcribed. diff --git a/src/apps/exo/plan.ts b/src/apps/exo/plan.ts deleted file mode 100644 index 41b71629..00000000 --- a/src/apps/exo/plan.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Today's plan fetch — the plan-{day_key} row from Mo's morning flow. -// -// Pattern mirrors profile.ts: deterministic row ID, fetch on demand, no TTL. - -import { mcpCall } from "$engines/brains/client/api"; -import { BOARDS } from "./board"; - -/** The plan row shape (date field removed — unconsumed). */ -export interface PlanRow { - headline: string; - body: string; -} - -interface RawPlanRow { - headline?: string; - body?: string; -} - -interface GetBoardRowEnvelope { - ok?: boolean; - row?: RawPlanRow; -} - -/** - * Fetch today's plan row. Returns null if not found, throws on error. - */ -export async function getPlan(): Promise { - const todayKey = new Date().toLocaleDateString("sv-SE"); - - try { - const envelope = await mcpCall("get_board_row", { - board_id: BOARDS.exo, - dataset: "plans", - row_id: `plan-${todayKey}`, - }); - - const row = envelope?.row; - if (!row?.headline) return null; - - return { headline: row.headline, body: row.body ?? "" }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes("not found") || msg.includes("404") || msg.includes("no row")) { - return null; - } - throw err; - } -} diff --git a/src/apps/exo/profile.ts b/src/apps/exo/profile.ts deleted file mode 100644 index d710476d..00000000 --- a/src/apps/exo/profile.ts +++ /dev/null @@ -1,228 +0,0 @@ -// Live profile fetch for the exo tab — the newest awareness summary row. -// -// Row ID is deterministic: `summary-{day_key}`. Fetched once on mount (warm), -// returned from cache at send (liveContext), re-fetched via refresh() after a -// run-now completes. No TTL, no invalidation hooks. -// -// Exports two shapes: -// - getLiveContext(): string — formatted prompt block for session injection -// - getProfileData(): ProfileData — structured data for canvas cards - -import { mcpCall } from "$engines/brains/client/api"; -import { BOARDS } from "./board"; - -/** The summary row shape we care about. */ -interface SummaryRow { - row_id: string; - content?: string; - run_at?: string; -} - -/** MCP get_board_row returns an envelope; the actual row is in .row */ -interface GetBoardRowEnvelope { - ok?: boolean; - row?: SummaryRow; -} - -/** Structured profile data for canvas cards. */ -export interface ProfileData { - /** Raw content for fallback rendering. */ - raw: string; - /** When the awareness run produced this. */ - runAt: Date; - /** Parsed sections (null if parsing failed). */ - sections: ProfileSections | null; -} - -/** Flat sections — each is either a string or string[] of lines. */ -export interface ProfileSections { - state?: string; - habits?: string[]; - attention?: string[]; - people?: string[]; - nextDay?: string; -} - -/** What we store from a fetch. */ -interface ProfileResult { - content: string; - runAt: Date; -} - -/** The fetched profile, stored once on mount. */ -let stored: ProfileResult | null = null; - -/** Compute day_key in user's local timezone (YYYY-MM-DD). */ -function dayKey(date: Date): string { - return date.toLocaleDateString("sv-SE"); -} - -/** Fetch a summary row by row_id. Returns null if not found, throws on error. */ -async function getRow(rowId: string): Promise { - try { - const envelope = await mcpCall("get_board_row", { - board_id: BOARDS.exo, - dataset: "awareness", - row_id: rowId, - }); - return envelope?.row ?? null; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes("not found") || msg.includes("404") || msg.includes("no row")) { - return null; - } - throw err; - } -} - -/** - * Fetch the current profile: today's summary, falling back to yesterday's. - * Returns { content, runAt } or null if neither exists. - */ -async function fetchProfile(): Promise { - const now = new Date(); - const todayKey = dayKey(now); - const yesterdayKey = dayKey(new Date(now.getTime() - 86400000)); - - // Try today first - let row = await getRow(`summary-${todayKey}`); - if (!row?.content) { - // Fall back to yesterday - row = await getRow(`summary-${yesterdayKey}`); - } - if (!row?.content) return null; - - return { content: row.content, runAt: row.run_at ? new Date(row.run_at) : now }; -} - -/** Warm the store on mount (fire and forget). */ -export async function warmProfile(): Promise { - try { - stored = await fetchProfile(); - } catch { - // Best-effort; getLiveContext handles missing data - } -} - -/** Format timestamp for the "as of" header. */ -function formatTimestamp(date: Date): string { - return date - .toLocaleString("en-GB", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hour12: false, - }) - .replace(",", ""); -} - -/** - * Get live context for session start. Returns the warmed profile or failure line. - * Does NOT refetch — that's what warmProfile() and refresh in ExoCanvas are for. - */ -export async function getLiveContext(): Promise { - if (!stored) { - return "[No profile for today — the awareness run may not have happened yet.]"; - } - const timestamp = formatTimestamp(stored.runAt); - return `# Current profile (as of ${timestamp})\n\n${stored.content}`; -} - -// --------------------------------------------------------------------------- -// Structured profile data for canvas cards — flat sections, no domain objects -// --------------------------------------------------------------------------- - -const PROFILE_LABELS = ["state", "habits", "attention", "people", "entities", "next_day", "agenda"] as const; - -/** - * Parse flat-labelled sections from profile content. Returns sections as - * strings or string arrays — no bespoke domain objects. ProfileCard renders - * these directly. - */ -function parseSections(content: string): ProfileSections | null { - try { - const labelPattern = new RegExp(`^(${PROFILE_LABELS.join("|")}):`, "im"); - const labelMap = new Map(); - const lines = content.split("\n"); - let currentLabel: string | null = null; - let currentContent: string[] = []; - - for (const line of lines) { - const match = line.match(labelPattern); - if (match) { - if (currentLabel) { - labelMap.set(currentLabel, currentContent.join("\n").trim()); - } - currentLabel = match[1].toLowerCase(); - const rest = line.slice(match[0].length).trim(); - currentContent = rest ? [rest] : []; - } else if (currentLabel) { - currentContent.push(line); - } - } - if (currentLabel) { - labelMap.set(currentLabel, currentContent.join("\n").trim()); - } - - const sections: ProfileSections = {}; - const get = (key: string) => { - const v = labelMap.get(key); - return v && v !== "[none]" ? v : undefined; - }; - - // state: single string - sections.state = get("state"); - - // habits/attention/people: split into lines - const habitsText = get("habits"); - if (habitsText) sections.habits = splitLines(habitsText); - - const attentionText = get("attention"); - if (attentionText) sections.attention = splitLines(attentionText); - - const peopleText = get("people"); - if (peopleText) sections.people = splitLines(peopleText); - - // next_day: single string - sections.nextDay = get("next_day"); - - if (!sections.state && !sections.habits?.length && !sections.attention?.length && - !sections.people?.length && !sections.nextDay) { - return null; - } - return sections; - } catch { - return null; - } -} - -/** Split text into non-empty lines, stripping leading bullets. */ -function splitLines(text: string): string[] { - return text - .split("\n") - .map((l) => l.replace(/^[-*•]\s*/, "").trim()) - .filter(Boolean); -} - -/** - * Get structured profile data for canvas cards. Returns from stored fetch. - */ -export async function getProfileData(): Promise { - if (!stored) return null; - return { - raw: stored.content, - runAt: stored.runAt, - sections: parseSections(stored.content), - }; -} - -/** Re-fetch the profile (called after run-now completes). */ -export async function refreshProfile(): Promise { - try { - stored = await fetchProfile(); - } catch { - // Keep existing data on error - } -} diff --git a/src/apps/settings/AppsPanel.svelte b/src/apps/settings/AppsPanel.svelte new file mode 100644 index 00000000..43b65438 --- /dev/null +++ b/src/apps/settings/AppsPanel.svelte @@ -0,0 +1,371 @@ + + + +
+ + {#snippet children()} +
+ + + +
+ {#if installing} +

{phaseLabel(installing.phase)}

+ {/if} + {#if installError} +

{installError}

+ {/if} + {/snippet} +
+ + {#if loading} + + {:else if error} + + {:else if apps.length === 0} + + {:else} + {#each apps as app (app.id)} + + {#snippet control()} +
+ + +
+ {/snippet} +
+ {/each} + {/if} +
+ +{#if uninstallTarget} + +{/if} + + diff --git a/src/apps/settings/Settings.svelte b/src/apps/settings/Settings.svelte index baefa9e6..ff28cfa1 100644 --- a/src/apps/settings/Settings.svelte +++ b/src/apps/settings/Settings.svelte @@ -10,6 +10,7 @@ import { workspaceTabs } from "$core/runtime/stores/workspace-tabs.svelte"; import AccountCard from "./AccountCard.svelte"; import AdvancedPanel from "./AdvancedPanel.svelte"; + import AppsPanel from "./AppsPanel.svelte"; import DeveloperPanel from "./DeveloperPanel.svelte"; import ApprovalsCard from "./ApprovalsCard.svelte"; import ProviderCard from "./ProviderCard.svelte"; @@ -281,6 +282,8 @@ + + diff --git a/src/apps/settings/apps-panel.test.ts b/src/apps/settings/apps-panel.test.ts new file mode 100644 index 00000000..d7a8d467 --- /dev/null +++ b/src/apps/settings/apps-panel.test.ts @@ -0,0 +1,230 @@ +// @vitest-environment jsdom +// +// AppsPanel: the Settings → Apps section. Install/upgrade/uninstall external +// apps from git URLs, show install progress, and list installed apps. + +import { flushSync, mount, unmount } from "svelte"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { FakeTransport } from "$core/runtime/spine/__tests__/fake-transport"; +import { setTransport } from "$core/runtime/transport"; +import AppsPanel from "./AppsPanel.svelte"; +import type { AppInfo, InstallJob, InstallPhase } from "./client"; + +let host: HTMLElement; +let mounted: Record | null = null; + +async function settle() { + for (let i = 0; i < 24; i += 1) { + await Promise.resolve(); + flushSync(); + } +} + +beforeEach(() => { + host = document.createElement("div"); + document.body.append(host); + vi.useFakeTimers({ shouldAdvanceTime: true }); +}); + +afterEach(() => { + if (mounted) unmount(mounted); + mounted = null; + host.remove(); + setTransport(null); + vi.useRealTimers(); +}); + +describe("AppsPanel", () => { + it("shows empty state when no apps installed", async () => { + const transport = new FakeTransport().on("app_list", []); + setTransport(transport); + + mounted = mount(AppsPanel, { target: host, props: {} }) as Record; + await settle(); + + expect(host.textContent).toContain("No apps installed"); + expect(host.textContent).toContain("Install an app from a git URL"); + }); + + it("lists installed apps with version and source", async () => { + const apps: AppInfo[] = [ + { + id: "hello", + source: "https://github.com/test/hello-app.git", + gitRef: "v1.0.0", + commit: "abc1234567890", + appVersion: "1.0.0", + installedAt: "2026-08-11T12:00:00Z", + }, + ]; + const transport = new FakeTransport().on("app_list", apps); + setTransport(transport); + + mounted = mount(AppsPanel, { target: host, props: {} }) as Record; + await settle(); + + expect(host.textContent).toContain("hello"); + expect(host.textContent).toContain("v1.0.0"); + expect(host.textContent).toContain("https://github.com/test/hello-app.git"); + expect(host.textContent).toContain("abc1234"); + expect(host.querySelector('button')?.textContent).toContain("Install"); + }); + + it("shows install progress phases", async () => { + const transport = new FakeTransport() + .on("app_list", []) + .on("app_install", "job-123"); + setTransport(transport); + + mounted = mount(AppsPanel, { target: host, props: {} }) as Record; + await settle(); + + const urlInput = host.querySelector('input[placeholder*="github"]'); + const installBtn = [...host.querySelectorAll("button")].find( + (b) => b.textContent?.trim() === "Install", + ); + + urlInput!.value = "https://github.com/test/app.git"; + urlInput!.dispatchEvent(new Event("input", { bubbles: true })); + flushSync(); + + // Start install + let currentPhase: InstallPhase = { phase: "cloning" }; + transport.on("app_install_status", (): InstallJob => ({ + jobId: "job-123", + source: "https://github.com/test/app.git", + gitRef: null, + phase: currentPhase, + appId: null, + startedAt: Date.now(), + })); + + installBtn!.click(); + await settle(); + vi.advanceTimersByTime(500); + await settle(); + + expect(host.textContent).toContain("Cloning repository..."); + + // Advance to building + currentPhase = { phase: "building" }; + vi.advanceTimersByTime(500); + await settle(); + + expect(host.textContent).toContain("Building app..."); + }); + + it("shows failure reason when install fails", async () => { + const failedPhase: InstallPhase = { phase: "failed", reason: "Build script failed" }; + const transport = new FakeTransport() + .on("app_list", []) + .on("app_install", "job-fail") + .on("app_install_status", (): InstallJob => ({ + jobId: "job-fail", + source: "https://github.com/test/bad-app.git", + gitRef: null, + phase: failedPhase, + appId: null, + startedAt: Date.now(), + })); + setTransport(transport); + + mounted = mount(AppsPanel, { target: host, props: {} }) as Record; + await settle(); + + const urlInput = host.querySelector('input[placeholder*="github"]'); + urlInput!.value = "https://github.com/test/bad-app.git"; + urlInput!.dispatchEvent(new Event("input", { bubbles: true })); + flushSync(); + + const installBtn = [...host.querySelectorAll("button")].find( + (b) => b.textContent?.trim() === "Install", + ); + installBtn!.click(); + await settle(); + vi.advanceTimersByTime(500); + await settle(); + + expect(host.textContent).toContain("Build script failed"); + }); + + it("shows uninstall confirmation dialog", async () => { + const apps: AppInfo[] = [ + { + id: "hello", + source: "https://github.com/test/hello-app.git", + gitRef: null, + commit: "abc1234", + appVersion: "1.0.0", + installedAt: "2026-08-11T12:00:00Z", + }, + ]; + const transport = new FakeTransport().on("app_list", apps); + setTransport(transport); + + mounted = mount(AppsPanel, { target: host, props: {} }) as Record; + await settle(); + + const uninstallBtn = [...host.querySelectorAll("button")].find( + (b) => b.textContent?.trim() === "Uninstall", + ); + uninstallBtn!.click(); + flushSync(); + + expect(host.textContent).toContain("Uninstall hello?"); + expect(host.textContent).toContain("Also delete its data"); + expect(host.querySelector('[role="dialog"]')).not.toBeNull(); + }); + + it("calls uninstall with purge option", async () => { + const apps: AppInfo[] = [ + { + id: "hello", + source: "https://github.com/test/hello-app.git", + gitRef: null, + commit: "abc1234", + appVersion: "1.0.0", + installedAt: "2026-08-11T12:00:00Z", + }, + ]; + let uninstallCalled = false; + let purgeDataArg = false; + const transport = new FakeTransport() + .on("app_list", apps) + .on("app_uninstall", (args: { id: string; purgeData: boolean }) => { + uninstallCalled = true; + purgeDataArg = args.purgeData; + return null; + }); + setTransport(transport); + + mounted = mount(AppsPanel, { target: host, props: {} }) as Record; + await settle(); + + // Open dialog + const uninstallBtn = [...host.querySelectorAll("button")].find( + (b) => b.textContent?.trim() === "Uninstall", + ); + uninstallBtn!.click(); + flushSync(); + + // Check purge checkbox + const checkbox = host.querySelector('input[type="checkbox"]'); + checkbox!.checked = true; + checkbox!.dispatchEvent(new Event("change", { bubbles: true })); + flushSync(); + + // Confirm + // Need to reload list after uninstall + transport.on("app_list", []); + const confirmBtn = [...host.querySelectorAll("button")].find( + (b) => b.textContent?.trim() === "Uninstall" && b.closest('[role="dialog"]'), + ); + confirmBtn!.click(); + await settle(); + + expect(uninstallCalled).toBe(true); + expect(purgeDataArg).toBe(true); + }); +}); diff --git a/src/apps/settings/client.ts b/src/apps/settings/client.ts index ea7ae05c..516c98d5 100644 --- a/src/apps/settings/client.ts +++ b/src/apps/settings/client.ts @@ -106,4 +106,76 @@ export function listRuns(limit = 40): Promise { } // ---------------------------------------------------------------------------- +// App management // ---------------------------------------------------------------------------- + +/** Info about an installed app (from app_list). */ +export interface AppInfo { + id: string; + source: string; + gitRef: string | null; + commit: string; + appVersion: string; + installedAt: string; +} + +/** Install job phases. */ +export type InstallPhase = + | { phase: "pending" } + | { phase: "cloning" } + | { phase: "building" } + | { phase: "validating" } + | { phase: "installing" } + | { phase: "done"; appId: string } + | { phase: "failed"; reason: string }; + +/** An in-progress install job. */ +export interface InstallJob { + jobId: string; + source: string; + gitRef: string | null; + phase: InstallPhase; + appId: string | null; + startedAt: number; +} + +/** App status: installed, installing, or not installed. */ +export type AppStatus = + | { status: "installed" } & AppInfo + | { status: "installing"; job: InstallJob } + | { status: "notInstalled" }; + +/** List all verified installed apps. */ +export function appList(): Promise { + return invoke("app_list"); +} + +/** Get status of an app by id. */ +export function appStatus(id: string): Promise { + return invoke("app_status", { id }); +} + +/** Get status of an install job by job id. */ +export function appInstallStatus(jobId: string): Promise { + return invoke("app_install_status", { jobId }); +} + +/** Start installing an app from a git URL. Returns the job id. */ +export function appInstall(url: string, gitRef?: string): Promise { + return invoke("app_install", { url, gitRef: gitRef ?? null }); +} + +/** Uninstall an app. Set purgeData to also delete the app's data. */ +export function appUninstall(id: string, purgeData: boolean): Promise { + return invoke("app_uninstall", { id, purgeData }); +} + +/** Reload installed apps manifest. */ +export function appsReload(): Promise { + return invoke("apps_reload"); +} + +/** Get an app's UI source (index.js) from a verified installed app. */ +export function appUiSource(id: string): Promise { + return invoke("app_ui_source", { id }); +} diff --git a/src/engines/agents/local/src/engine.rs b/src/engines/agents/local/src/engine.rs index 462c87a3..1e72709f 100644 --- a/src/engines/agents/local/src/engine.rs +++ b/src/engines/agents/local/src/engine.rs @@ -5,7 +5,7 @@ // A tick is idempotent per (agent, day_key): the durable run-health record is // the only "already fired" state. -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock, RwLockReadGuard}; use chrono::{DateTime, TimeZone}; @@ -20,9 +20,15 @@ use crate::scheduler::{decide, ArmReport, ArmedSlot, Decision, SchedulerConfig}; use crate::slots::{self, Slot}; use crate::AppGates; +/// The shared manifest — an Arc so the workspace resolver can read the same +/// manifest the scheduler holds, and a post-install reload reaches both. +pub type SharedManifest = Arc>; + /// Everything a tick needs. The runner is a trait object for test substitution. pub struct Scheduler { - pub manifest: Manifest, + /// Interior-mutable AND shareable: the workspace resolver holds the same Arc + /// so a post-install reload reaches both the scheduler and run-time resolution. + manifest: SharedManifest, pub config: SchedulerConfig, /// Shared with on_demand.rs: the ONE way a headless run ever starts. pub(crate) runner: Arc, @@ -35,6 +41,22 @@ impl Scheduler { health: HealthStore, runner: Arc, config: SchedulerConfig, + ) -> Self { + Self { + manifest: Arc::new(RwLock::new(manifest)), + config, + runner, + health: Mutex::new(health), + } + } + + /// Build with a pre-existing shared manifest — for when the caller needs to + /// share the same Arc with the workspace resolver. + pub fn with_shared_manifest( + manifest: SharedManifest, + health: HealthStore, + runner: Arc, + config: SchedulerConfig, ) -> Self { Self { manifest, @@ -44,6 +66,35 @@ impl Scheduler { } } + /// The shared manifest Arc — for passing to the workspace resolver so both + /// read from the same source and a reload reaches both. + pub fn shared_manifest(&self) -> SharedManifest { + Arc::clone(&self.manifest) + } + + /// Read access to the manifest. + pub fn manifest(&self) -> RwLockReadGuard<'_, Manifest> { + self.manifest.read().expect("manifest lock poisoned") + } + + /// Write access to the manifest (test-only). + #[cfg(test)] + pub fn manifest_mut(&self) -> std::sync::RwLockWriteGuard<'_, Manifest> { + self.manifest.write().expect("manifest lock poisoned") + } + + /// Swap the manifest and re-arm. Called when installed packs change. + pub fn reload(&self, manifest: Manifest, now: &DateTime, gates: &AppGates) + where + Tz::Offset: std::fmt::Display, + { + { + let mut guard = self.manifest.write().expect("manifest lock poisoned"); + *guard = manifest; + } + self.arm(now, gates); + } + pub fn health(&self) -> std::sync::MutexGuard<'_, HealthStore> { self.health.lock().expect("run-health mutex poisoned") } @@ -54,9 +105,9 @@ impl Scheduler { where Tz::Offset: std::fmt::Display, { + let manifest = self.manifest(); let report = ArmReport { - armed: self - .manifest + armed: manifest .armed_agents(gates) .map(|decl| ArmedSlot { agent_id: decl.id.clone(), @@ -67,18 +118,16 @@ impl Scheduler { skill_path: decl.skill_path.clone(), }) .collect(), - disabled: self - .manifest + disabled: manifest .agents .iter() .filter(|a| !a.enabled) .map(|a| a.id.clone()) .collect(), - gated_off: self - .manifest + gated_off: manifest .agents .iter() - .filter(|a| !self.manifest.app_gate_open(&a.app, gates)) + .filter(|a| !manifest.app_gate_open(&a.app, gates)) .map(|a| a.id.clone()) .collect(), dry_run: self.config.dry_run, @@ -98,13 +147,9 @@ impl Scheduler { Tz::Offset: std::fmt::Display, { let mut earliest: Option> = None; - // Gated-off agents never enter the loop. - for decl in self - .manifest - .armed_agents(gates) - .cloned() - .collect::>() - { + // Snapshot the agents to iterate — lock released before firing. + let agents: Vec<_> = self.manifest().armed_agents(gates).cloned().collect(); + for decl in agents { // Guard released before firing: a std Mutex is not reentrant. let decision = { let health = self.health(); @@ -114,7 +159,7 @@ impl Scheduler { Ok(Decision::Fire { slot, late }) => { // Re-check gate at fire moment — a gate that closed during // the tick pass should not spawn (gates reads live settings). - if !self.manifest.app_gate_open(&decl.app, gates) { + if !self.manifest().app_gate_open(&decl.app, gates) { eprintln!( "[brains-local-agents] {} gate closed before fire — skipping", decl.id diff --git a/src/engines/agents/local/src/lib.rs b/src/engines/agents/local/src/lib.rs index 889baac5..6aff0e91 100644 --- a/src/engines/agents/local/src/lib.rs +++ b/src/engines/agents/local/src/lib.rs @@ -75,7 +75,7 @@ use chrono::Local; pub use actor_runner::{ActorRunner, WorkspaceResolver}; pub use cron::{Cron, CronError}; pub use decl::AgentDecl; -pub use engine::Scheduler; +pub use engine::{Scheduler, SharedManifest}; pub use health::{HealthState, HealthStore, RunHealth, HEALTH_FILE_NAME, UNOWNED_APP}; pub use manifest::{ AgentOverride, AppDecl, Manifest, ManifestError, Overrides, MANIFEST_FILE_NAME, @@ -144,6 +144,22 @@ pub fn engine_with_runner( Scheduler::new(manifest, store, runner, config) } +/// Engine with a SHARED manifest — for when the workspace resolver needs to +/// read the same manifest the scheduler holds (so a post-install reload reaches +/// both the scheduler's arming and the resolver's cwd lookup). +pub fn engine_with_shared_manifest( + manifest: SharedManifest, + data_root: &std::path::Path, + config: SchedulerConfig, + runner: Arc, +) -> LocalAgents { + let store = { + let guard = manifest.read().expect("manifest lock poisoned"); + HealthStore::open(data_root, &guard) + }; + Scheduler::with_shared_manifest(manifest, store, runner, config) +} + /// The timer loop. Never armed for longer than `config.tick_cap`, because a /// monotonic sleep does not advance while the machine is asleep — see /// scheduler.rs for the full wake story. diff --git a/src/engines/agents/local/src/on_demand.rs b/src/engines/agents/local/src/on_demand.rs index 64bc4990..ad4adf5e 100644 --- a/src/engines/agents/local/src/on_demand.rs +++ b/src/engines/agents/local/src/on_demand.rs @@ -36,8 +36,8 @@ impl Scheduler { { // The manifest is the authority: an agent this build did not declare // cannot be run, however it is spelled. - let decl = self - .manifest + let manifest = self.manifest(); + let decl = manifest .get(agent_id) .ok_or_else(|| RunnerError::NotReady { agent: agent_id.to_string(), @@ -48,7 +48,7 @@ impl Scheduler { // the whole isolation boundary undone by one button: the app is hidden, // its slots are disarmed, and anything that can reach this call could // still start the run the schedule refuses to. - if !self.manifest.app_gate_open(&decl.app, gates) { + if !manifest.app_gate_open(&decl.app, gates) { return Err(RunnerError::NotReady { agent: decl.id.clone(), reason: "this build is not running the app that declares it".into(), diff --git a/src/engines/agents/local/src/status.rs b/src/engines/agents/local/src/status.rs index 09eae72d..0329749e 100644 --- a/src/engines/agents/local/src/status.rs +++ b/src/engines/agents/local/src/status.rs @@ -76,10 +76,11 @@ impl Scheduler { where Tz::Offset: std::fmt::Display, { - self.manifest + let manifest = self.manifest(); + manifest .agents .iter() - .filter(|decl| self.manifest.app_gate_open(&decl.app, gates)) + .filter(|decl| manifest.app_gate_open(&decl.app, gates)) .map(|decl| { let day_key = current_day_key(decl, now); let stored = self.health().get(&decl.id, &day_key).cloned(); @@ -204,14 +205,16 @@ mod tests { #[test] fn a_past_midnight_window_reports_yesterdays_day_key() { - let (_tmp, mut scheduler) = scheduler(); - let evening = scheduler - .manifest - .agents - .iter_mut() - .find(|a| a.id == "morning") - .unwrap(); - evening.cron = "30 19 * * *".into(); + let (_tmp, scheduler) = scheduler(); + { + let mut manifest = scheduler.manifest_mut(); + let evening = manifest + .agents + .iter_mut() + .find(|a| a.id == "morning") + .unwrap(); + evening.cron = "30 19 * * *".into(); + } let statuses = scheduler.agents_health(&at(2026, 8, 5, 0, 30), &open()); let evening = statuses.iter().find(|s| s.agent_id == "morning").unwrap(); diff --git a/src/engines/apps/Cargo.toml b/src/engines/apps/Cargo.toml new file mode 100644 index 00000000..eda4ab5d --- /dev/null +++ b/src/engines/apps/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "brains-apps" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +brains-context = { workspace = true } +brains-local-agents = { workspace = true } +brains-storage = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/src/engines/apps/src/installed.rs b/src/engines/apps/src/installed.rs new file mode 100644 index 00000000..c555d12c --- /dev/null +++ b/src/engines/apps/src/installed.rs @@ -0,0 +1,505 @@ +// INSTALLED APP RECORD — the trust anchor for installed apps. +// +// `installed.json` records source URL, pinned commit, version, and SHA-256 +// hashes of every file in the app. At boot/reload, verification checks that +// every recorded file exists and matches its hash. An app that fails +// verification contributes NOTHING to manifests. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::STAGING_DIR; + +/// Schema version for installed.json. +pub const INSTALLED_VERSION: u32 = 1; + +/// The record file name. +pub const INSTALLED_FILE: &str = "installed.json"; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum VerifyError { + #[error("installed.json not found at {0}")] + NotFound(PathBuf), + #[error("could not read installed.json: {0}")] + ReadError(String), + #[error("malformed installed.json: {0}")] + ParseError(String), + #[error("file {path} not found (recorded in installed.json)")] + MissingFile { path: String }, + #[error("file {path} hash mismatch: expected {expected}, got {actual}")] + HashMismatch { + path: String, + expected: String, + actual: String, + }, + #[error("symlink detected at {0}")] + Symlink(String), + #[error("could not read file {path}: {reason}")] + Io { path: String, reason: String }, + #[error("extra file not in installed.json: {0}")] + ExtraFile(String), + #[error("directory id mismatch: record says '{record}', dirname is '{dirname}'")] + IdMismatch { record: String, dirname: String }, +} + +/// The installed.json record. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledApp { + /// Schema version. + pub version: u32, + /// The app's own id (from app.json). + pub id: String, + /// Git URL used for install/upgrade. + pub source: String, + /// Git ref that was requested (tag, branch, or commit). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_ref: Option, + /// The exact commit sha that was checked out. + pub commit: String, + /// Version from the app's app.json. + pub app_version: String, + /// ISO timestamp of installation. + pub installed_at: String, + /// Map of relative path → SHA-256 hash (hex). + pub files: BTreeMap, +} + +impl InstalledApp { + /// Load and verify an installed app from its root directory. + pub fn load_and_verify(app_root: &Path) -> Result { + let record = Self::load(app_root)?; + record.verify(app_root)?; + Ok(record) + } + + /// Load installed.json without verification. + pub fn load(app_root: &Path) -> Result { + let path = app_root.join(INSTALLED_FILE); + if !path.exists() { + return Err(VerifyError::NotFound(path)); + } + + // Check if the file ITSELF is a symlink (not ancestors - macOS /var -> /private/var) + let meta = + std::fs::symlink_metadata(&path).map_err(|e| VerifyError::ReadError(e.to_string()))?; + if meta.is_symlink() { + return Err(VerifyError::Symlink(path.display().to_string())); + } + + let content = + std::fs::read_to_string(&path).map_err(|e| VerifyError::ReadError(e.to_string()))?; + + serde_json::from_str(&content).map_err(|e| VerifyError::ParseError(e.to_string())) + } + + /// Verify all recorded files exist and match their hashes. + /// + /// SECURITY (#2-#4): Also verifies: + /// - No extra files exist beyond what is recorded + /// - The record's id matches the directory basename + /// - No symlinks anywhere in the tree + pub fn verify(&self, app_root: &Path) -> Result<(), VerifyError> { + // Verify id matches directory basename (#3) + if let Some(dirname) = app_root.file_name().and_then(|n| n.to_str()) { + if dirname != self.id { + return Err(VerifyError::IdMismatch { + record: self.id.clone(), + dirname: dirname.to_string(), + }); + } + } + + // Walk the tree and collect all files, checking for symlinks (#2) + let mut actual_files = std::collections::BTreeSet::new(); + collect_files_recursive(app_root, app_root, &mut actual_files)?; + + // Verify recorded files exist and match hashes + for (rel_path, expected_hash) in &self.files { + let full_path = app_root.join(rel_path); + + // Check existence + if !full_path.exists() { + return Err(VerifyError::MissingFile { + path: rel_path.clone(), + }); + } + + // Verify the file ITSELF is not a symlink (using lstat) + let meta = std::fs::symlink_metadata(&full_path).map_err(|e| VerifyError::Io { + path: rel_path.clone(), + reason: e.to_string(), + })?; + + if meta.is_symlink() { + return Err(VerifyError::Symlink(rel_path.clone())); + } + + if !meta.is_file() { + continue; // Skip directories in hash check + } + + // Verify hash + let content = std::fs::read(&full_path).map_err(|e| VerifyError::Io { + path: rel_path.clone(), + reason: e.to_string(), + })?; + + let actual_hash = sha256_hex(&content); + if actual_hash != *expected_hash { + return Err(VerifyError::HashMismatch { + path: rel_path.clone(), + expected: expected_hash.clone(), + actual: actual_hash, + }); + } + } + + // Check for extra files not in the record (#2) + let recorded_files: std::collections::BTreeSet = + self.files.keys().cloned().collect(); + for actual in &actual_files { + // Skip installed.json itself + if actual == INSTALLED_FILE { + continue; + } + if !recorded_files.contains(actual) { + return Err(VerifyError::ExtraFile(actual.clone())); + } + } + + Ok(()) + } + + /// Write installed.json to the app root. + /// + /// #6: Uses temp+rename for atomic write to prevent partial writes on crash. + pub fn write(&self, app_root: &Path) -> std::io::Result<()> { + let path = app_root.join(INSTALLED_FILE); + let temp_path = app_root.join(format!(".{}.tmp", INSTALLED_FILE)); + let content = serde_json::to_string_pretty(self)?; + + // Write to temp file first + std::fs::write(&temp_path, &content)?; + + // Atomic rename into place + std::fs::rename(&temp_path, &path)?; + + Ok(()) + } +} + +/// Compute SHA-256 hash of content, returned as lowercase hex. +pub fn sha256_hex(content: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(content); + format!("{:x}", hasher.finalize()) +} + +/// Hash all files in a directory tree, returning relative path → hash. +pub fn hash_directory(root: &Path) -> std::io::Result> { + let mut files = BTreeMap::new(); + hash_directory_recursive(root, root, &mut files)?; + Ok(files) +} + +fn hash_directory_recursive( + base: &Path, + current: &Path, + files: &mut BTreeMap, +) -> std::io::Result<()> { + for entry in std::fs::read_dir(current)? { + let entry = entry?; + let path = entry.path(); + + // Skip symlinks + let meta = std::fs::symlink_metadata(&path)?; + if meta.is_symlink() { + continue; + } + + if meta.is_dir() { + hash_directory_recursive(base, &path, files)?; + } else if meta.is_file() { + let rel = path + .strip_prefix(base) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + let content = std::fs::read(&path)?; + files.insert(rel, sha256_hex(&content)); + } + } + Ok(()) +} + +/// Collect all file paths in a directory tree, rejecting symlinks. +fn collect_files_recursive( + base: &Path, + current: &Path, + files: &mut std::collections::BTreeSet, +) -> Result<(), VerifyError> { + let entries = std::fs::read_dir(current).map_err(|e| VerifyError::Io { + path: current.display().to_string(), + reason: e.to_string(), + })?; + + for entry in entries { + let entry = entry.map_err(|e| VerifyError::Io { + path: current.display().to_string(), + reason: e.to_string(), + })?; + let path = entry.path(); + + let meta = std::fs::symlink_metadata(&path).map_err(|e| VerifyError::Io { + path: path.display().to_string(), + reason: e.to_string(), + })?; + + // Reject symlinks anywhere in the tree + if meta.is_symlink() { + let rel = path + .strip_prefix(base) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/"); + return Err(VerifyError::Symlink(rel)); + } + + if meta.is_dir() { + collect_files_recursive(base, &path, files)?; + } else if meta.is_file() { + let rel = path + .strip_prefix(base) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + files.insert(rel); + } + } + Ok(()) +} + +/// The installed apps root directory, with methods to scan installed apps. +/// +/// The directory is `/installed/`, NOT `/apps/`, because +/// the storage engine already uses `apps//` for per-app runtime DATA. The +/// two are separate concerns: install tree vs. runtime state. +pub struct AppsRoot { + root: PathBuf, +} + +impl AppsRoot { + pub fn new(data_root: &Path) -> Self { + Self { + root: data_root.join("installed"), + } + } + + pub fn path(&self) -> &Path { + &self.root + } + + /// Ensure the installed apps directory exists. + pub fn ensure(&self) -> std::io::Result<()> { + std::fs::create_dir_all(&self.root) + } + + /// Path to a specific installed app. + pub fn app_path(&self, id: &str) -> PathBuf { + self.root.join(id) + } + + /// Path to the staging directory for a job. + pub fn staging_path(&self, job_id: &str) -> PathBuf { + self.root.join(STAGING_DIR).join(job_id) + } + + /// List all verified installed apps. + /// Apps that fail verification are logged and skipped. + pub fn list_verified(&self) -> Vec { + let mut apps = Vec::new(); + + let Ok(entries) = std::fs::read_dir(&self.root) else { + return apps; + }; + + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + + // Skip dot-directories (staging, etc.) + if name.starts_with('.') { + continue; + } + + // Skip non-directories + let Ok(meta) = entry.metadata() else { + continue; + }; + if !meta.is_dir() { + continue; + } + + // Check for symlinks + if let Ok(lstat) = std::fs::symlink_metadata(entry.path()) { + if lstat.is_symlink() { + eprintln!( + "[brains-app] skipping symlink in installed root: {}", + entry.path().display() + ); + continue; + } + } + + match InstalledApp::load_and_verify(&entry.path()) { + Ok(app) => apps.push(app), + Err(e) => { + eprintln!( + "[brains-app] skipping {}: {} — app contributes nothing", + name, e + ); + } + } + } + + apps + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Create a sample app in a directory with the given id. + /// The directory name MUST equal the app id for verification to pass. + fn sample_app_in(app_dir: &Path, id: &str) -> InstalledApp { + std::fs::create_dir_all(app_dir).unwrap(); + std::fs::write(app_dir.join("app.json"), format!(r#"{{"id":"{}"}}"#, id)).unwrap(); + std::fs::write(app_dir.join("skill.md"), "# Skill").unwrap(); + + let files = hash_directory(app_dir).unwrap(); + InstalledApp { + version: INSTALLED_VERSION, + id: id.into(), + source: "https://github.com/test/app.git".into(), + git_ref: Some("main".into()), + commit: "abc123".into(), + app_version: "1.0.0".into(), + installed_at: "2026-08-11T12:00:00Z".into(), + files, + } + } + + #[test] + fn verify_passes_for_matching_hashes() { + let tmp = tempfile::tempdir().unwrap(); + let app_dir = tmp.path().join("test"); + let app = sample_app_in(&app_dir, "test"); + app.write(&app_dir).unwrap(); + + let loaded = InstalledApp::load_and_verify(&app_dir).unwrap(); + assert_eq!(loaded.id, "test"); + } + + #[test] + fn verify_fails_for_missing_file() { + let tmp = tempfile::tempdir().unwrap(); + let app_dir = tmp.path().join("test"); + let app = sample_app_in(&app_dir, "test"); + app.write(&app_dir).unwrap(); + + // Delete a file + std::fs::remove_file(app_dir.join("skill.md")).unwrap(); + + let err = InstalledApp::load_and_verify(&app_dir).unwrap_err(); + assert!(matches!(err, VerifyError::MissingFile { .. })); + } + + #[test] + fn verify_fails_for_tampered_file() { + let tmp = tempfile::tempdir().unwrap(); + let app_dir = tmp.path().join("test"); + let app = sample_app_in(&app_dir, "test"); + app.write(&app_dir).unwrap(); + + // Tamper with a file + std::fs::write(app_dir.join("skill.md"), "# TAMPERED").unwrap(); + + let err = InstalledApp::load_and_verify(&app_dir).unwrap_err(); + assert!(matches!(err, VerifyError::HashMismatch { .. })); + } + + #[test] + fn verify_fails_for_missing_installed_json() { + let tmp = tempfile::tempdir().unwrap(); + let app_dir = tmp.path().join("test"); + std::fs::create_dir_all(&app_dir).unwrap(); + std::fs::write(app_dir.join("app.json"), "{}").unwrap(); + + let err = InstalledApp::load_and_verify(&app_dir).unwrap_err(); + assert!(matches!(err, VerifyError::NotFound(_))); + } + + #[test] + fn verify_fails_for_extra_file() { + let tmp = tempfile::tempdir().unwrap(); + let app_dir = tmp.path().join("test"); + let app = sample_app_in(&app_dir, "test"); + app.write(&app_dir).unwrap(); + + // Add an extra file not in the record + std::fs::write(app_dir.join("malicious.agents.json"), "[]").unwrap(); + + let err = InstalledApp::load_and_verify(&app_dir).unwrap_err(); + assert!(matches!(err, VerifyError::ExtraFile(_))); + } + + #[test] + fn verify_fails_for_id_mismatch() { + let tmp = tempfile::tempdir().unwrap(); + // Directory named "wrong-name" but record says id is "test" + let app_dir = tmp.path().join("wrong-name"); + let app = sample_app_in(&app_dir, "test"); + app.write(&app_dir).unwrap(); + + let err = InstalledApp::load_and_verify(&app_dir).unwrap_err(); + assert!(matches!(err, VerifyError::IdMismatch { .. })); + } + + #[test] + fn list_verified_skips_invalid_apps() { + let tmp = tempfile::tempdir().unwrap(); + let apps_root = AppsRoot::new(tmp.path()); + apps_root.ensure().unwrap(); + + // Valid app (directory name matches id) + let valid = apps_root.app_path("valid"); + let app = sample_app_in(&valid, "valid"); + app.write(&valid).unwrap(); + + // Invalid app (no installed.json) + let invalid = apps_root.app_path("invalid"); + std::fs::create_dir_all(&invalid).unwrap(); + std::fs::write(invalid.join("app.json"), "{}").unwrap(); + + // Dot-directory (staging) + let staging = apps_root.path().join(".staging"); + std::fs::create_dir_all(&staging).unwrap(); + + let verified = apps_root.list_verified(); + assert_eq!(verified.len(), 1); + assert_eq!(verified[0].id, "valid"); + } + + #[test] + fn sha256_hex_is_consistent() { + let hash = sha256_hex(b"hello world"); + assert_eq!( + hash, + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + ); + } +} diff --git a/src/engines/apps/src/installer.rs b/src/engines/apps/src/installer.rs new file mode 100644 index 00000000..a2a8ab68 --- /dev/null +++ b/src/engines/apps/src/installer.rs @@ -0,0 +1,674 @@ +// INSTALLER PIPELINE — clone → validate → build → install. +// +// Deterministic, no LLM. Given a git URL (+ optional ref): +// 1. Clone to staging dir under apps root +// 2. Validate source conventions (app.json, declarations) +// 3. Run the app's build command +// 4. Validate built artifact (dist/) +// 5. Atomic swap into installed/ +// 6. Write installed.json +// 7. Reload manifests + open gate +// +// size-lint-exception: ~630 lines. Security-critical path with atomic swap, +// containment checks, id validation, and URL sanitization — all inline because +// this is the trust boundary and splitting would scatter the invariants. + +use std::collections::HashSet; +use std::path::Path; +use std::process::Stdio; + +use crate::installed::{hash_directory, AppsRoot, InstalledApp, INSTALLED_VERSION}; +use crate::is_reserved_id; +use crate::is_valid_app_id; +use crate::job::{InstallPhase, JobRegistry}; +use crate::validate::{ValidationError, Validator}; + +/// #21: Strip userinfo (username:password@) from URLs before persistence/logging. +/// Returns the sanitized URL. If parsing fails, returns the original (git URLs +/// like `git@github.com:...` are not standard URLs and don't contain userinfo). +/// +/// #7: Also used by job.rs to sanitize URLs before they reach job state or UI. +pub fn sanitize_url(url: &str) -> String { + // Standard URL with userinfo: https://user:pass@github.com/... + if let Some(idx) = url.find("://") { + let scheme_end = idx + 3; + let rest = &url[scheme_end..]; + // Look for @ before the first / + if let Some(at_idx) = rest.find('@') { + if let Some(slash_idx) = rest.find('/') { + if at_idx < slash_idx { + // Has userinfo — strip it + return format!("{}{}", &url[..scheme_end], &rest[at_idx + 1..]); + } + } else if at_idx > 0 { + // No path, but has userinfo + return format!("{}{}", &url[..scheme_end], &rest[at_idx + 1..]); + } + } + } + // No userinfo found (or git@ style which is the host, not auth) + url.to_string() +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum InstallError { + #[error("git clone failed: {0}")] + Clone(String), + #[error("could not determine commit: {0}")] + Commit(String), + #[error("validation failed: {0}")] + Validation(#[from] ValidationError), + #[error("build failed: {0}")] + Build(String), + #[error("dist/ not found after build")] + DistNotFound, + #[error("dist/ contains symlinks (not allowed)")] + DistHasSymlinks, + #[error("could not copy dist/: {0}")] + Copy(String), + #[error("id collision with shipped app: {0}")] + ShippedCollision(String), + #[error("id collision with installed app: {0}")] + InstalledCollision(String), + #[error("io error: {0}")] + Io(String), +} + +/// The installer. +pub struct Installer { + apps_root: AppsRoot, + shipped_app_ids: HashSet, +} + +impl Installer { + pub fn new(apps_root: AppsRoot, shipped_app_ids: HashSet) -> Self { + Self { + apps_root, + shipped_app_ids, + } + } + + /// Install an app from a git URL. + pub async fn install( + &self, + job_id: &str, + source: &str, + git_ref: Option<&str>, + jobs: &JobRegistry, + ) -> Result { + self.install_or_update(job_id, source, git_ref, jobs, false) + .await + } + + /// Update an existing app. + /// + /// #1: Installs to a staging target, verifies, then swaps over the old one — + /// the app's data dir is untouched. If the update fails, the old version + /// remains in place. + pub async fn update( + &self, + job_id: &str, + source: &str, + git_ref: Option<&str>, + jobs: &JobRegistry, + ) -> Result { + self.install_or_update(job_id, source, git_ref, jobs, true) + .await + } + + async fn install_or_update( + &self, + job_id: &str, + source: &str, + git_ref: Option<&str>, + jobs: &JobRegistry, + is_update: bool, + ) -> Result { + // Ensure apps root exists + self.apps_root + .ensure() + .map_err(|e| InstallError::Io(e.to_string()))?; + + // Create staging dir + let staging = self.apps_root.staging_path(job_id); + std::fs::create_dir_all(&staging).map_err(|e| InstallError::Io(e.to_string()))?; + + let result = self + .install_inner(job_id, source, git_ref, &staging, jobs, is_update) + .await; + + // Clean up staging on success or failure + let _ = std::fs::remove_dir_all(&staging); + + result + } + + async fn install_inner( + &self, + job_id: &str, + source: &str, + git_ref: Option<&str>, + staging: &Path, + jobs: &JobRegistry, + is_update: bool, + ) -> Result { + // 1. Clone + jobs.set_phase(job_id, InstallPhase::Cloning); + let clone_dir = staging.join("repo"); + self.git_clone(source, git_ref, &clone_dir).await?; + + // Get exact commit + let commit = self.git_rev_parse(&clone_dir).await?; + + // 2. Validate source + jobs.set_phase(job_id, InstallPhase::Validating); + let validator = Validator::new(&clone_dir); + let app_json = validator.validate_app_json()?; + + // Check collisions + if self.shipped_app_ids.contains(&app_json.id) || is_reserved_id(&app_json.id) { + return Err(InstallError::ShippedCollision(app_json.id)); + } + + // #1: For updates, the existing app is expected; for installs, it's a collision. + let existing_path = self.apps_root.app_path(&app_json.id); + if existing_path.exists() && !is_update { + return Err(InstallError::InstalledCollision(app_json.id)); + } + if !existing_path.exists() && is_update { + return Err(InstallError::Io(format!( + "cannot update {}: not installed", + app_json.id + ))); + } + + // Set app id in job + if !jobs.set_app_id(job_id, &app_json.id) { + return Err(InstallError::InstalledCollision(app_json.id)); + } + + // 3. Build + jobs.set_phase(job_id, InstallPhase::Building); + self.run_build(&clone_dir, app_json.build.as_deref()) + .await?; + + // 4. Validate dist/ + jobs.set_phase(job_id, InstallPhase::Validating); + let dist_dir = clone_dir.join("dist"); + if !dist_dir.exists() { + return Err(InstallError::DistNotFound); + } + + // Check for symlinks in dist/ + if has_symlinks_in_tree(&dist_dir) { + return Err(InstallError::DistHasSymlinks); + } + + // Validate the built artifact + let dist_validator = Validator::new(&dist_dir); + let built = dist_validator.validate_all()?; + + // #10: Verify built artifact identity matches source — a build must not + // emit a different id, gate, or version than the source declared. + if built.app_json.id != app_json.id { + return Err(InstallError::Validation( + crate::validate::ValidationError::InvalidField { + field: "id".into(), + reason: format!( + "built app.json id '{}' does not match source '{}'", + built.app_json.id, app_json.id + ), + }, + )); + } + if built.app_json.gate != app_json.gate { + return Err(InstallError::Validation( + crate::validate::ValidationError::InvalidField { + field: "gate".into(), + reason: format!( + "built app.json gate '{}' does not match source '{}'", + built.app_json.gate, app_json.gate + ), + }, + )); + } + + // 5. Atomic swap + jobs.set_phase(job_id, InstallPhase::Installing); + let target = self.apps_root.app_path(&app_json.id); + self.atomic_swap(&dist_dir, &target)?; + + // 6. Write installed.json + // #21: Sanitize URL before persistence — strip userinfo credentials + let files = hash_directory(&target).map_err(|e| InstallError::Io(e.to_string()))?; + let installed = InstalledApp { + version: INSTALLED_VERSION, + id: app_json.id.clone(), + source: sanitize_url(source), + git_ref: git_ref.map(String::from), + commit, + app_version: app_json.version, + installed_at: chrono::Utc::now().to_rfc3339(), + files, + }; + installed + .write(&target) + .map_err(|e| InstallError::Io(e.to_string()))?; + + // 7. Mark done + jobs.complete(job_id); + + Ok(installed) + } + + async fn git_clone( + &self, + url: &str, + git_ref: Option<&str>, + target: &Path, + ) -> Result<(), InstallError> { + let mut cmd = tokio::process::Command::new("git"); + cmd.arg("clone"); + + if git_ref.is_none() { + cmd.arg("--depth").arg("1"); + } + + cmd.arg(url).arg(target); + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let output = cmd + .output() + .await + .map_err(|e| InstallError::Clone(e.to_string()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // #7: Sanitize stderr — git may echo the URL (with userinfo) in error messages + return Err(InstallError::Clone(sanitize_url(&stderr))); + } + + // Checkout specific ref if provided + if let Some(ref_) = git_ref { + let mut checkout = tokio::process::Command::new("git"); + checkout + .current_dir(target) + .args(["checkout", ref_]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let output = checkout + .output() + .await + .map_err(|e| InstallError::Clone(e.to_string()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // #7: Sanitize stderr — git may include the URL in error messages + return Err(InstallError::Clone(format!( + "checkout {ref_}: {}", + sanitize_url(&stderr) + ))); + } + } + + Ok(()) + } + + async fn git_rev_parse(&self, repo: &Path) -> Result { + let output = tokio::process::Command::new("git") + .current_dir(repo) + .args(["rev-parse", "HEAD"]) + .stdin(Stdio::null()) + .output() + .await + .map_err(|e| InstallError::Commit(e.to_string()))?; + + if !output.status.success() { + return Err(InstallError::Commit("rev-parse failed".into())); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } + + async fn run_build(&self, repo: &Path, build_cmd: Option<&str>) -> Result<(), InstallError> { + // Default build command + // windows: revisit — see docs/parity/WINDOWS.md + let cmd = build_cmd.unwrap_or("npm install && npm run build"); + + let output = tokio::process::Command::new("sh") + .current_dir(repo) + .args(["-c", cmd]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| InstallError::Build(e.to_string()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(InstallError::Build(format!( + "exit {:?}\nstdout: {}\nstderr: {}", + output.status.code(), + stdout.chars().take(500).collect::(), + stderr.chars().take(500).collect::() + ))); + } + + Ok(()) + } + + /// #6: Truly atomic swap using rename on the same filesystem. + /// + /// 1. Copy to a sibling temp directory (same filesystem as target) + /// 2. If target exists, rename it to a backup + /// 3. Rename temp to target (atomic) + /// 4. Clean up backup on success, restore on failure + fn atomic_swap(&self, src: &Path, dst: &Path) -> Result<(), InstallError> { + let parent = dst + .parent() + .ok_or_else(|| InstallError::Copy("destination has no parent directory".into()))?; + + // Sibling temp dir for atomic rename (same filesystem) + let temp_name = format!( + ".{}-installing-{}", + dst.file_name().unwrap_or_default().to_string_lossy(), + std::process::id() + ); + let temp = parent.join(&temp_name); + + // Clean up any stale temp from a previous crash + if temp.exists() { + let _ = std::fs::remove_dir_all(&temp); + } + + // Copy to temp (same filesystem as target for atomic rename) + copy_dir_recursive(src, &temp)?; + + // Handle existing target with backup/restore semantics + let backup = if dst.exists() { + let backup_name = format!( + ".{}-backup-{}", + dst.file_name().unwrap_or_default().to_string_lossy(), + std::process::id() + ); + let backup = parent.join(&backup_name); + if backup.exists() { + let _ = std::fs::remove_dir_all(&backup); + } + // Move existing to backup + std::fs::rename(dst, &backup).map_err(|e| { + let _ = std::fs::remove_dir_all(&temp); + InstallError::Copy(format!("failed to backup existing: {e}")) + })?; + Some(backup) + } else { + None + }; + + // Atomic rename: temp -> target + if let Err(e) = std::fs::rename(&temp, dst) { + // Restore backup on failure + if let Some(backup) = backup { + let _ = std::fs::rename(&backup, dst); + } + let _ = std::fs::remove_dir_all(&temp); + return Err(InstallError::Copy(format!("atomic rename failed: {e}"))); + } + + // Success: clean up backup + if let Some(backup) = backup { + let _ = std::fs::remove_dir_all(&backup); + } + + Ok(()) + } + + /// Uninstall an app. + /// + /// SECURITY: id must pass validation before ANY path is formed. Reserved ids + /// and uninstalled apps are rejected outright. + pub fn uninstall( + &self, + id: &str, + purge_data: bool, + data_root: &Path, + ) -> Result<(), InstallError> { + // Validate id format: must be lowercase letters, digits, dashes only + if !is_valid_app_id(id) { + return Err(InstallError::Io(format!("invalid app id: {}", id))); + } + + // Never uninstall reserved/shipped ids + if is_reserved_id(id) || self.shipped_app_ids.contains(id) { + return Err(InstallError::ShippedCollision(id.to_string())); + } + + let app_path = self.apps_root.app_path(id); + + // Verify the app is actually installed (has installed.json) + if !app_path.join(crate::installed::INSTALLED_FILE).exists() { + return Err(InstallError::Io(format!("app {} is not installed", id))); + } + + // Containment check: ensure resolved path stays inside apps root + if let (Ok(canon_root), Ok(canon_app)) = ( + std::fs::canonicalize(self.apps_root.path()), + std::fs::canonicalize(&app_path), + ) { + if !canon_app.starts_with(&canon_root) { + return Err(InstallError::Io(format!( + "app path escapes apps root: {}", + id + ))); + } + } + + if app_path.exists() { + std::fs::remove_dir_all(&app_path).map_err(|e| InstallError::Io(e.to_string()))?; + } + + if purge_data { + let data_path = data_root.join("apps").join(id); + // Containment check for data path + if let (Ok(canon_root), Ok(canon_data)) = ( + std::fs::canonicalize(data_root), + std::fs::canonicalize(&data_path), + ) { + if !canon_data.starts_with(&canon_root) { + return Err(InstallError::Io(format!( + "data path escapes data root: {}", + id + ))); + } + } + if data_path.exists() { + std::fs::remove_dir_all(&data_path).map_err(|e| InstallError::Io(e.to_string()))?; + } + } + + Ok(()) + } +} + +/// Check if any file in the tree is a symlink. +fn has_symlinks_in_tree(dir: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(dir) else { + return false; + }; + + for entry in entries.flatten() { + let path = entry.path(); + + if let Ok(meta) = std::fs::symlink_metadata(&path) { + if meta.is_symlink() { + return true; + } + if meta.is_dir() && has_symlinks_in_tree(&path) { + return true; + } + } + } + + false +} + +/// Copy a directory recursively, refusing symlinks. +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), InstallError> { + std::fs::create_dir_all(dst).map_err(|e| InstallError::Copy(e.to_string()))?; + + for entry in std::fs::read_dir(src).map_err(|e| InstallError::Copy(e.to_string()))? { + let entry = entry.map_err(|e| InstallError::Copy(e.to_string()))?; + let path = entry.path(); + let dest = dst.join(entry.file_name()); + + let meta = + std::fs::symlink_metadata(&path).map_err(|e| InstallError::Copy(e.to_string()))?; + + if meta.is_symlink() { + return Err(InstallError::DistHasSymlinks); + } + + if meta.is_dir() { + copy_dir_recursive(&path, &dest)?; + } else { + std::fs::copy(&path, &dest).map_err(|e| InstallError::Copy(e.to_string()))?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn has_symlinks_detects_symlinks() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("test"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("file.txt"), "content").unwrap(); + + assert!(!has_symlinks_in_tree(&dir)); + + // Add a symlink + #[cfg(unix)] + { + std::os::unix::fs::symlink(dir.join("file.txt"), dir.join("link.txt")).unwrap(); + assert!(has_symlinks_in_tree(&dir)); + } + } + + #[test] + fn copy_dir_recursive_works() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("src"); + let dst = tmp.path().join("dst"); + + std::fs::create_dir_all(src.join("sub")).unwrap(); + std::fs::write(src.join("a.txt"), "a").unwrap(); + std::fs::write(src.join("sub/b.txt"), "b").unwrap(); + + copy_dir_recursive(&src, &dst).unwrap(); + + assert!(dst.join("a.txt").exists()); + assert!(dst.join("sub/b.txt").exists()); + assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "a"); + } + + #[test] + fn copy_dir_recursive_refuses_symlinks() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("src"); + let dst = tmp.path().join("dst"); + + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("file.txt"), "content").unwrap(); + + #[cfg(unix)] + { + std::os::unix::fs::symlink(src.join("file.txt"), src.join("link.txt")).unwrap(); + let err = copy_dir_recursive(&src, &dst).unwrap_err(); + assert!(matches!(err, InstallError::DistHasSymlinks)); + } + } + + #[test] + fn uninstall_rejects_path_traversal() { + let tmp = tempfile::tempdir().unwrap(); + let apps_root = AppsRoot::new(tmp.path()); + apps_root.ensure().unwrap(); + + let installer = Installer::new(apps_root, HashSet::new()); + + // Absolute path + let err = installer.uninstall("/etc/passwd", false, tmp.path()); + assert!(err.is_err()); + + // Parent traversal + let err = installer.uninstall("../../../etc", false, tmp.path()); + assert!(err.is_err()); + + // Hidden traversal + let err = installer.uninstall("foo/../../../etc", false, tmp.path()); + assert!(err.is_err()); + } + + #[test] + fn uninstall_rejects_reserved_ids() { + let tmp = tempfile::tempdir().unwrap(); + let apps_root = AppsRoot::new(tmp.path()); + apps_root.ensure().unwrap(); + + let installer = Installer::new(apps_root, HashSet::new()); + + // Reserved base app id + let err = installer.uninstall("settings", false, tmp.path()); + assert!(matches!(err, Err(InstallError::ShippedCollision(_)))); + + let err = installer.uninstall("session", false, tmp.path()); + assert!(matches!(err, Err(InstallError::ShippedCollision(_)))); + } + + #[test] + fn uninstall_rejects_non_installed_app() { + let tmp = tempfile::tempdir().unwrap(); + let apps_root = AppsRoot::new(tmp.path()); + apps_root.ensure().unwrap(); + + let installer = Installer::new(apps_root, HashSet::new()); + + // App that doesn't exist + let err = installer.uninstall("nonexistent-app", false, tmp.path()); + assert!(err.is_err()); + } + + #[test] + fn sanitize_url_strips_userinfo() { + // #21: URLs with embedded credentials should have userinfo stripped + assert_eq!( + sanitize_url("https://user:pass@github.com/org/repo.git"), + "https://github.com/org/repo.git" + ); + assert_eq!( + sanitize_url("https://token@github.com/org/repo.git"), + "https://github.com/org/repo.git" + ); + // git@ style is the SSH host, not auth info — should be preserved + assert_eq!( + sanitize_url("git@github.com:org/repo.git"), + "git@github.com:org/repo.git" + ); + // Clean URLs pass through unchanged + assert_eq!( + sanitize_url("https://github.com/org/repo.git"), + "https://github.com/org/repo.git" + ); + } +} diff --git a/src/engines/apps/src/job.rs b/src/engines/apps/src/job.rs new file mode 100644 index 00000000..972221f8 --- /dev/null +++ b/src/engines/apps/src/job.rs @@ -0,0 +1,437 @@ +// JOB STATE MACHINE — tracks install/upgrade progress. +// +// Jobs are in-memory only (the Settings UI shows progress, but we don't persist +// partial state). One job per app id at a time, keyed by a generated job id +// (the app id isn't known until after clone+read). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +use serde::{Deserialize, Serialize}; + +use crate::installer::sanitize_url; + +/// Generated job id (UUID). +pub type JobId = String; + +/// The phase of an install job. +/// +/// NOTE: `rename_all = "camelCase"` applies to VARIANTS only (Pending → pending). +/// Struct variant FIELDS require their own annotation — without it, `Done { app_id }` +/// serializes as `{ "phase": "done", "app_id": "..." }` and the frontend reads +/// `undefined` for `appId`. (#2) +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "phase", rename_all = "camelCase")] +pub enum InstallPhase { + Pending, + Cloning, + Building, + Validating, + Installing, + Done { + #[serde(rename = "appId")] + app_id: String, + }, + Failed { + #[serde(rename = "reason")] + reason: String, + }, +} + +impl InstallPhase { + pub fn is_terminal(&self) -> bool { + matches!( + self, + InstallPhase::Done { .. } | InstallPhase::Failed { .. } + ) + } + + pub fn is_failed(&self) -> bool { + matches!(self, InstallPhase::Failed { .. }) + } +} + +/// An in-progress install job. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallJob { + pub job_id: JobId, + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub git_ref: Option, + pub phase: InstallPhase, + /// The app id, once known (after clone + app.json read). + #[serde(skip_serializing_if = "Option::is_none")] + pub app_id: Option, + /// Started timestamp (millis since epoch). + pub started_at: u64, +} + +impl InstallJob { + /// Create a new install job. + /// + /// #7: The source URL is sanitized (userinfo stripped) BEFORE storage, so it + /// never reaches job state, the UI, or logs with embedded credentials. + pub fn new(job_id: JobId, source: String, git_ref: Option) -> Self { + Self { + job_id, + source: sanitize_url(&source), + git_ref, + phase: InstallPhase::Pending, + app_id: None, + started_at: SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0), + } + } + + pub fn set_phase(&mut self, phase: InstallPhase) { + self.phase = phase; + } + + pub fn set_app_id(&mut self, id: String) { + self.app_id = Some(id); + } + + pub fn fail(&mut self, reason: impl Into) { + self.phase = InstallPhase::Failed { + reason: reason.into(), + }; + } + + pub fn complete(&mut self) { + if let Some(id) = &self.app_id { + self.phase = InstallPhase::Done { app_id: id.clone() }; + } else { + self.fail("completed without app_id"); + } + } +} + +/// Registry of active install jobs. +#[derive(Debug, Clone, Default)] +pub struct JobRegistry { + inner: Arc>, +} + +#[derive(Debug, Default)] +struct JobRegistryInner { + /// Jobs by job_id. + jobs: HashMap, + /// Map source URL → job_id (for dedup). + by_source: HashMap, + /// Map app_id → job_id (for dedup once app_id is known). + by_app_id: HashMap, +} + +impl JobRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Start a new job. Returns None if a job for this source is already running. + pub fn start(&self, source: &str, git_ref: Option) -> Option { + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + + // Check for existing job with same source + if inner.by_source.contains_key(source) { + return None; + } + + let job_id = generate_job_id(); + let job = InstallJob::new(job_id.clone(), source.to_string(), git_ref); + + inner.jobs.insert(job_id.clone(), job.clone()); + inner.by_source.insert(source.to_string(), job_id); + + Some(job) + } + + /// Update a job's app_id. Returns false if app_id is already taken. + pub fn set_app_id(&self, job_id: &str, app_id: &str) -> bool { + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + + // Check for existing job with same app_id + if inner.by_app_id.contains_key(app_id) { + return false; + } + + if let Some(job) = inner.jobs.get_mut(job_id) { + job.set_app_id(app_id.to_string()); + inner + .by_app_id + .insert(app_id.to_string(), job_id.to_string()); + true + } else { + false + } + } + + /// Update a job's phase. + pub fn set_phase(&self, job_id: &str, phase: InstallPhase) { + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(job) = inner.jobs.get_mut(job_id) { + job.set_phase(phase); + } + } + + /// Mark a job as failed. + /// + /// #8: Remove from active indexes so retries work, keep in jobs for polling. + pub fn fail(&self, job_id: &str, reason: impl Into) { + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + // Collect keys to remove BEFORE mutating + let keys_to_remove = inner + .jobs + .get(job_id) + .map(|job| (job.source.clone(), job.app_id.clone())); + if let Some(job) = inner.jobs.get_mut(job_id) { + job.fail(reason); + } + // Remove from active indexes so the same source/app_id can be retried + if let Some((source, app_id)) = keys_to_remove { + inner.by_source.remove(&source); + if let Some(aid) = app_id { + inner.by_app_id.remove(&aid); + } + } + } + + /// Mark a job as complete. + /// + /// #8: Remove from active indexes, keep in jobs for polling. + pub fn complete(&self, job_id: &str) { + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + // Collect keys to remove BEFORE mutating + let keys_to_remove = inner + .jobs + .get(job_id) + .map(|job| (job.source.clone(), job.app_id.clone())); + if let Some(job) = inner.jobs.get_mut(job_id) { + job.complete(); + } + // Remove from active indexes — the job is done + if let Some((source, app_id)) = keys_to_remove { + inner.by_source.remove(&source); + if let Some(aid) = app_id { + inner.by_app_id.remove(&aid); + } + } + } + + /// Get a job by id. + pub fn get(&self, job_id: &str) -> Option { + let inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + inner.jobs.get(job_id).cloned() + } + + /// Get job for an app id (if any). + /// + /// #8: Only returns non-terminal jobs (active installs), so app_status + /// correctly reports "Installing" only while work is actually in progress. + pub fn get_by_app_id(&self, app_id: &str) -> Option { + let inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + inner + .by_app_id + .get(app_id) + .and_then(|jid| inner.jobs.get(jid)) + .filter(|job| !job.phase.is_terminal()) + .cloned() + } + + /// Remove a completed/failed job. + pub fn remove(&self, job_id: &str) { + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(job) = inner.jobs.remove(job_id) { + inner.by_source.remove(&job.source); + if let Some(app_id) = &job.app_id { + inner.by_app_id.remove(app_id); + } + } + } + + /// List all active jobs. + pub fn list(&self) -> Vec { + let inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + inner.jobs.values().cloned().collect() + } +} + +/// Generate a unique job id using an atomic counter + timestamp. +/// +/// #8: The old `uuid_v4` was `timestamp XOR pid` — same-tick installs in the same +/// process would collide and share a staging dir. This version uses an atomic counter +/// that survives any number of same-tick calls, plus a timestamp prefix for +/// human-readability. +fn generate_job_id() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + // Format: {millis_hex}-{seq_hex}-{pid_hex} — readable, unique, filesystem-safe + format!( + "{:012x}-{:06x}-{:04x}", + millis, + seq, + std::process::id() as u16 + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn job_lifecycle() { + let registry = JobRegistry::new(); + + // Start job + let job = registry + .start("https://github.com/test/app.git", None) + .unwrap(); + assert!(matches!(job.phase, InstallPhase::Pending)); + + // Update phase + registry.set_phase(&job.job_id, InstallPhase::Cloning); + let job = registry.get(&job.job_id).unwrap(); + assert!(matches!(job.phase, InstallPhase::Cloning)); + + // Set app_id + assert!(registry.set_app_id(&job.job_id, "test-app")); + let job = registry.get(&job.job_id).unwrap(); + assert_eq!(job.app_id, Some("test-app".to_string())); + + // Complete + registry.complete(&job.job_id); + let job = registry.get(&job.job_id).unwrap(); + assert!(matches!(job.phase, InstallPhase::Done { app_id } if app_id == "test-app")); + + // Remove + registry.remove(&job.job_id); + assert!(registry.get(&job.job_id).is_none()); + } + + #[test] + fn dedup_by_source() { + let registry = JobRegistry::new(); + + let job1 = registry.start("https://github.com/test/app.git", None); + assert!(job1.is_some()); + + let job2 = registry.start("https://github.com/test/app.git", None); + assert!(job2.is_none(), "should reject duplicate source"); + + // Different source is allowed + let job3 = registry.start("https://github.com/other/app.git", None); + assert!(job3.is_some()); + } + + #[test] + fn dedup_by_app_id() { + let registry = JobRegistry::new(); + + let job1 = registry + .start("https://github.com/test/app1.git", None) + .unwrap(); + assert!(registry.set_app_id(&job1.job_id, "shared-id")); + + let job2 = registry + .start("https://github.com/test/app2.git", None) + .unwrap(); + assert!( + !registry.set_app_id(&job2.job_id, "shared-id"), + "should reject duplicate app_id" + ); + } + + #[test] + fn failed_job_allows_retry() { + // #8: A failed job should be removed from active indexes, allowing retry + let registry = JobRegistry::new(); + + let job1 = registry + .start("https://github.com/test/app.git", None) + .unwrap(); + registry.set_app_id(&job1.job_id, "test-app"); + registry.fail(&job1.job_id, "network error"); + + // The job record is still accessible by job_id + let job = registry.get(&job1.job_id).unwrap(); + assert!(job.phase.is_failed()); + + // But a new install from the same source is now allowed + let job2 = registry.start("https://github.com/test/app.git", None); + assert!(job2.is_some(), "retry should be allowed after failure"); + + // And the same app_id can be used again + assert!(registry.set_app_id(&job2.unwrap().job_id, "test-app")); + } + + #[test] + fn completed_job_not_reported_as_installing() { + // #8: get_by_app_id should only return non-terminal jobs + let registry = JobRegistry::new(); + + let job = registry + .start("https://github.com/test/app.git", None) + .unwrap(); + registry.set_app_id(&job.job_id, "test-app"); + + // Before completion, get_by_app_id returns the job + assert!(registry.get_by_app_id("test-app").is_some()); + + registry.complete(&job.job_id); + + // After completion, get_by_app_id returns None (not "installing") + assert!(registry.get_by_app_id("test-app").is_none()); + + // But get by job_id still works (for status polling) + assert!(registry.get(&job.job_id).is_some()); + } + + /// #2: Wire shape the frontend reads — field names must be camelCase. + /// + /// enum-level `rename_all` only renames variants, not struct-variant fields. + /// Without explicit field rename, `Done { app_id }` serializes as + /// `{ "phase": "done", "app_id": "..." }` and the frontend reads `undefined`. + #[test] + fn install_phase_done_serializes_app_id_as_camel_case() { + let done = InstallPhase::Done { + app_id: "test-app".into(), + }; + let json = serde_json::to_string(&done).unwrap(); + // Must contain "appId", not "app_id" + assert!( + json.contains("\"appId\""), + "Done.app_id must serialize as appId: {json}" + ); + assert!( + !json.contains("\"app_id\""), + "Done.app_id must NOT serialize as app_id: {json}" + ); + // Verify round-trip + let parsed: InstallPhase = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, done); + } + + #[test] + fn install_phase_failed_serializes_reason_correctly() { + let failed = InstallPhase::Failed { + reason: "network error".into(), + }; + let json = serde_json::to_string(&failed).unwrap(); + // Failed.reason stays as "reason" (already one word) + assert!(json.contains("\"reason\""), "Failed serialization: {json}"); + let parsed: InstallPhase = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, failed); + } +} diff --git a/src/engines/apps/src/lib.rs b/src/engines/apps/src/lib.rs new file mode 100644 index 00000000..bdc627f1 --- /dev/null +++ b/src/engines/apps/src/lib.rs @@ -0,0 +1,550 @@ +// crate brains-apps: INSTALLED APP MANAGEMENT. +// +// External apps installed from git repos. Each lives at `/installed//` +// with an `installed.json` recording source, commit, version, and content hashes. +// Only VERIFIED apps (hashes match) contribute to manifests. +// +// The directory is `installed/`, not `apps/`, because `/apps//` +// already holds per-app DATA (storage engine's convention). The two are separate +// concerns: install tree vs. runtime state. +// +// The user's explicit INSTALL action is the trust event. The installer clones, +// validates conventions, runs the app's build, verifies the artifact, and +// atomically swaps it into place. At boot/reload, verified apps merge into the +// agent and context manifests as a third layer (shipped > local > installed). +// +// SECURITY INVARIANT: a stray directory in `installed/` (no `installed.json`, or +// tampered files) loads NOTHING. Only recorded, hash-verified apps contribute +// to manifests, gates, or the materializer. + +pub mod installed; +pub mod job; +pub mod manifest; +pub mod path_safety; +pub mod validate; + +mod installer; + +pub use installed::{hash_directory, AppsRoot, InstalledApp, VerifyError, INSTALLED_VERSION}; +pub use installer::{sanitize_url, InstallError, Installer}; +pub use job::{InstallJob, InstallPhase, JobId, JobRegistry}; +pub use manifest::{merge_installed_agents, merge_installed_contexts, MergeError}; +pub use path_safety::{PathError, SafePath}; +pub use validate::{ValidationError, Validator}; + +/// Base app ids that an installed app may never claim. +/// #11: Complete list of reserved ids — derived from shipped apps, not a +/// hand-maintained subset. An installed app claiming any of these would +/// collide with base functionality. +pub const RESERVED_APP_IDS: &[&str] = &[ + "session", + "gmail", + "gcal", // Google Calendar + "docs", + "settings", + "sites", + "brains-apps", + "chat", + "boards", +]; + +/// Check if an id is reserved (base app or invalid format). +pub fn is_reserved_id(id: &str) -> bool { + RESERVED_APP_IDS.contains(&id) +} + +/// Validate app id format: must start with a lowercase letter or digit, +/// contain only lowercase letters, digits, and dashes, and not be empty. +/// This is the SINGLE validator used by both install and uninstall. +pub fn is_valid_app_id(id: &str) -> bool { + if id.is_empty() { + return false; + } + // Reject absolute paths and parent references + if id.starts_with('/') || id.contains("..") { + return false; + } + let first = id.chars().next().unwrap(); + if !first.is_ascii_lowercase() && !first.is_ascii_digit() { + return false; + } + id.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +/// The staging directory name (dot-prefixed, skipped by app scans). +pub const STAGING_DIR: &str = ".staging"; + +#[cfg(test)] +mod e2e_tests { + use std::collections::HashSet; + + use super::installed::{hash_directory, AppsRoot, InstalledApp, INSTALLED_VERSION}; + use super::manifest::{merge_installed_agents, merge_installed_contexts}; + + fn setup_installed_app(data_root: &std::path::Path, id: &str) { + let apps_root = AppsRoot::new(data_root); + apps_root.ensure().unwrap(); + + let app_root = apps_root.app_path(id); + std::fs::create_dir_all(&app_root).unwrap(); + + let app_json = serde_json::json!({ + "id": id, + "gate": format!("apps.{}.enabled", id), + "title": "End-to-End Test App", + "description": "An app for e2e testing", + "version": "2.0.0" + }); + std::fs::write(app_root.join("app.json"), app_json.to_string()).unwrap(); + + let detail_path = app_root.join("DETAIL.md"); + std::fs::write( + &detail_path, + "# E2E App Detail\n\nFull context for the e2e app.", + ) + .unwrap(); + + // Prelude is a .mjs script (not a .md file) — mirrors build-context-manifest.mjs + std::fs::write( + app_root.join("prelude.mjs"), + "// E2E prelude script\nexport async function run() { return {}; }", + ) + .unwrap(); + + let context_json = serde_json::json!({ + "index": "This app provides e2e test functionality.", + "details": { + "app": "DETAIL.md", + "prelude": "prelude.mjs" + }, + "gate": format!("apps.{}.enabled", id) + }); + std::fs::write(app_root.join("context.json"), context_json.to_string()).unwrap(); + + let skills_dir = app_root.join("skills"); + let prelude_dir = skills_dir.join("e2e-prelude"); + let task_dir = skills_dir.join("e2e-task"); + std::fs::create_dir_all(&prelude_dir).unwrap(); + std::fs::create_dir_all(&task_dir).unwrap(); + + std::fs::write( + prelude_dir.join("SKILL.md"), + "# E2E Prelude\n\nThis is the prelude skill for the e2e app.", + ) + .unwrap(); + + std::fs::write( + task_dir.join("SKILL.md"), + "# E2E Task\n\nThis is the task skill for the e2e app.", + ) + .unwrap(); + + let agents = serde_json::json!([ + {"cron": "0 9 * * *", "skill": "skills/e2e-task/SKILL.md", "enabled": true} + ]); + std::fs::write( + app_root.join(format!("{}.agents.json", id)), + agents.to_string(), + ) + .unwrap(); + + let files = hash_directory(&app_root).unwrap(); + let installed = InstalledApp { + version: INSTALLED_VERSION, + id: id.to_string(), + source: "https://github.com/test/e2e-app.git".into(), + git_ref: Some("v2.0.0".into()), + commit: "e2eabc123".into(), + app_version: "2.0.0".into(), + installed_at: "2026-08-11T12:00:00Z".into(), + files, + }; + installed.write(&app_root).unwrap(); + } + + #[test] + fn e2e_installed_app_merges_and_materializes() { + let tmp = tempfile::tempdir().unwrap(); + let data_root = tmp.path(); + + setup_installed_app(data_root, "e2e-test"); + + let apps_root = AppsRoot::new(data_root); + + let verified = apps_root.list_verified(); + assert_eq!(verified.len(), 1, "should have one verified app"); + assert_eq!(verified[0].id, "e2e-test"); + assert_eq!(verified[0].app_version, "2.0.0"); + + let base_agents = brains_local_agents::Manifest::default(); + let shipped_ids = HashSet::new(); + let (merged_agents, collisions) = + merge_installed_agents(&base_agents, &apps_root, &shipped_ids); + + assert!( + collisions.is_empty(), + "no collisions with empty shipped set" + ); + assert_eq!(merged_agents.apps.len(), 1, "should have one app"); + assert_eq!(merged_agents.apps[0].id, "e2e-test"); + assert_eq!(merged_agents.apps[0].gate, "apps.e2e-test.enabled"); + + assert_eq!(merged_agents.agents.len(), 1, "should have one agent"); + let agent = &merged_agents.agents[0]; + // #10: Agent ids are now namespaced by app id to avoid global collisions + assert_eq!( + agent.id, "e2e-test/e2e-task", + "agent id is namespaced as app/skill" + ); + assert_eq!(agent.app, "e2e-test"); + assert_eq!(agent.cron, "0 9 * * *"); + assert!(agent.enabled); + + assert!( + agent.prelude_path.is_some(), + "prelude path should be set from context.json" + ); + let prelude = agent.prelude_path.as_ref().unwrap(); + assert!( + prelude.starts_with(data_root.to_str().unwrap()), + "prelude path should be absolute" + ); + assert!( + prelude.ends_with("prelude.mjs"), + "prelude path should point to the .mjs script" + ); + + let base_contexts = brains_context::Manifest::default(); + let shipped_context_names = HashSet::new(); + let merged_contexts = + merge_installed_contexts(&base_contexts, &apps_root, &shipped_context_names); + + assert_eq!(merged_contexts.contexts.len(), 1, "should have one context"); + let ctx = &merged_contexts.contexts[0]; + assert_eq!(ctx.name, "e2e-test"); + assert_eq!(ctx.index, "This app provides e2e test functionality."); + assert_eq!(ctx.gate, "apps.e2e-test.enabled"); + + assert_eq!(ctx.skills.len(), 2, "should have two skills embedded"); + let skill_names: Vec<_> = ctx.skills.iter().map(|s| &s.name).collect(); + assert!(skill_names.contains(&&"e2e-prelude".to_string())); + assert!(skill_names.contains(&&"e2e-task".to_string())); + + for skill in &ctx.skills { + assert!( + !skill.body.is_empty(), + "skill body should be embedded at merge time" + ); + assert!( + skill.source_path.starts_with("installed/"), + "source_path should be relative to installed dir" + ); + } + } + + #[test] + fn e2e_shipped_collision_blocks_merge() { + let tmp = tempfile::tempdir().unwrap(); + let data_root = tmp.path(); + + setup_installed_app(data_root, "settings"); + + let apps_root = AppsRoot::new(data_root); + let base_agents = brains_local_agents::Manifest::default(); + let mut shipped_ids = HashSet::new(); + shipped_ids.insert("settings".to_string()); + + let (merged_agents, collisions) = + merge_installed_agents(&base_agents, &apps_root, &shipped_ids); + + assert_eq!(collisions, vec!["settings"]); + assert!(merged_agents.apps.is_empty()); + assert!(merged_agents.agents.is_empty()); + } + + #[test] + fn e2e_tampered_app_loads_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let data_root = tmp.path(); + + setup_installed_app(data_root, "tampered"); + + let apps_root = AppsRoot::new(data_root); + let app_root = apps_root.app_path("tampered"); + std::fs::write( + app_root.join("skills/e2e-task/SKILL.md"), + "# TAMPERED CONTENT", + ) + .unwrap(); + + let verified = apps_root.list_verified(); + assert!(verified.is_empty(), "tampered app should not verify"); + + let base_agents = brains_local_agents::Manifest::default(); + let shipped_ids = HashSet::new(); + let (merged_agents, _) = merge_installed_agents(&base_agents, &apps_root, &shipped_ids); + + assert!( + merged_agents.apps.is_empty(), + "tampered app contributes nothing" + ); + } + + /// Integration test: run the full installer pipeline against a real git repo. + /// This proves: clone → validate → build → verify → swap → record. + #[tokio::test] + async fn e2e_installer_full_pipeline() { + use super::installer::Installer; + use super::job::JobRegistry; + + // 1. Create a temporary git repo with a valid app structure + let source_repo = tempfile::tempdir().unwrap(); + let source_path = source_repo.path(); + + // Write app.json (source) — use build: "true" to skip actual npm build + let app_json = serde_json::json!({ + "id": "test-installer", + "gate": "apps.test-installer.enabled", + "title": "Installer Test App", + "description": "Tests the full installer pipeline", + "version": "1.0.0", + "build": "true" + }); + std::fs::write(source_path.join("app.json"), app_json.to_string()).unwrap(); + + // Create dist/ with the same content (simulating a build) + let dist_dir = source_path.join("dist"); + std::fs::create_dir_all(&dist_dir).unwrap(); + std::fs::write(dist_dir.join("app.json"), app_json.to_string()).unwrap(); + std::fs::write(dist_dir.join("index.js"), "// minimal UI").unwrap(); + + // Initialize as git repo + std::process::Command::new("git") + .args(["init"]) + .current_dir(source_path) + .output() + .expect("git init"); + std::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(source_path) + .output() + .expect("git config email"); + std::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(source_path) + .output() + .expect("git config name"); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(source_path) + .output() + .expect("git add"); + std::process::Command::new("git") + .args(["commit", "-m", "initial"]) + .current_dir(source_path) + .output() + .expect("git commit"); + + // 2. Create temp data root for installation target + let data_root = tempfile::tempdir().unwrap(); + let apps_root = AppsRoot::new(data_root.path()); + + // 3. Run the installer + let jobs = JobRegistry::new(); + let source_url = format!("file://{}", source_path.display()); + let installer = Installer::new(AppsRoot::new(data_root.path()), HashSet::new()); + let job = jobs.start(&source_url, None).expect("should start job"); + let job_id = job.job_id.clone(); + + let result = installer.install(&job_id, &source_url, None, &jobs).await; + + // 4. Assert success + let installed = result.expect("installer should succeed"); + assert_eq!(installed.id, "test-installer"); + assert_eq!(installed.app_version, "1.0.0"); + assert!(installed.source.starts_with("file://")); + + // 5. Verify job phase reached Done + let job = jobs.get(&job_id).expect("job should exist"); + assert!( + matches!(job.phase, super::job::InstallPhase::Done { .. }), + "job should be Done, got {:?}", + job.phase + ); + + // 6. Verify installed app directory structure + let app_dir = apps_root.app_path("test-installer"); + assert!(app_dir.exists(), "app directory should exist"); + assert!(app_dir.join("app.json").exists(), "app.json should exist"); + assert!(app_dir.join("index.js").exists(), "index.js should exist"); + assert!( + app_dir.join("installed.json").exists(), + "installed.json should exist" + ); + + // 7. Verify installed.json round-trips through serde (this caught the camelCase bug) + let loaded = InstalledApp::load(&app_dir).expect("should load written installed.json"); + assert_eq!(loaded.id, installed.id); + assert_eq!(loaded.app_version, installed.app_version); + assert_eq!(loaded.files.len(), installed.files.len()); + + // 8. Verify list_verified returns the app + let verified = apps_root.list_verified(); + assert_eq!(verified.len(), 1, "should have one verified app"); + assert_eq!(verified[0].id, "test-installer"); + + // 9. Negative test: tamper a file and verify it no longer loads + std::fs::write(app_dir.join("index.js"), "// TAMPERED").unwrap(); + let verified_after_tamper = apps_root.list_verified(); + assert!( + verified_after_tamper.is_empty(), + "tampered app should not verify" + ); + } + + /// #1: E2E test for the update path: install v1 → update to v2 → verify. + /// + /// Proves: atomic swap, old version is replaced, data dir untouched, + /// commit changes, version changes. + #[tokio::test] + async fn e2e_installer_update_path() { + use super::installer::Installer; + use super::job::JobRegistry; + + // 1. Create a temporary git repo with v1 + let source_repo = tempfile::tempdir().unwrap(); + let source_path = source_repo.path(); + + let app_json_v1 = serde_json::json!({ + "id": "test-updatable", + "gate": "apps.test-updatable.enabled", + "title": "Updatable App", + "description": "Tests the update path", + "version": "1.0.0", + "build": "true" + }); + std::fs::write(source_path.join("app.json"), app_json_v1.to_string()).unwrap(); + + let dist_dir = source_path.join("dist"); + std::fs::create_dir_all(&dist_dir).unwrap(); + std::fs::write(dist_dir.join("app.json"), app_json_v1.to_string()).unwrap(); + std::fs::write(dist_dir.join("index.js"), "// v1 UI").unwrap(); + + // Initialize git repo and make v1 commit + std::process::Command::new("git") + .args(["init"]) + .current_dir(source_path) + .output() + .expect("git init"); + std::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(source_path) + .output() + .expect("git config email"); + std::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(source_path) + .output() + .expect("git config name"); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(source_path) + .output() + .expect("git add"); + std::process::Command::new("git") + .args(["commit", "-m", "v1.0.0"]) + .current_dir(source_path) + .output() + .expect("git commit v1"); + + // 2. Install v1 + let data_root = tempfile::tempdir().unwrap(); + let apps_root = AppsRoot::new(data_root.path()); + let jobs = JobRegistry::new(); + let source_url = format!("file://{}", source_path.display()); + let installer = Installer::new(AppsRoot::new(data_root.path()), HashSet::new()); + + let job1 = jobs.start(&source_url, None).expect("should start job"); + let v1_result = installer + .install(&job1.job_id, &source_url, None, &jobs) + .await + .expect("v1 install should succeed"); + + assert_eq!(v1_result.app_version, "1.0.0"); + let v1_commit = v1_result.commit.clone(); + + // Simulate app data by writing to the data dir + let data_path = data_root.path().join("apps").join("test-updatable"); + std::fs::create_dir_all(&data_path).unwrap(); + std::fs::write(data_path.join("user-data.json"), r#"{"preserved": true}"#).unwrap(); + + // 3. Update the source repo to v2 + let app_json_v2 = serde_json::json!({ + "id": "test-updatable", + "gate": "apps.test-updatable.enabled", + "title": "Updatable App", + "description": "Tests the update path", + "version": "2.0.0", + "build": "true" + }); + std::fs::write(source_path.join("app.json"), app_json_v2.to_string()).unwrap(); + std::fs::write(dist_dir.join("app.json"), app_json_v2.to_string()).unwrap(); + std::fs::write(dist_dir.join("index.js"), "// v2 UI").unwrap(); + + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(source_path) + .output() + .expect("git add"); + std::process::Command::new("git") + .args(["commit", "-m", "v2.0.0"]) + .current_dir(source_path) + .output() + .expect("git commit v2"); + + // 4. Update to v2 — install() should FAIL because app exists + let job2 = jobs.start(&source_url, None).expect("should start job"); + let install_fail = installer + .install(&job2.job_id, &source_url, None, &jobs) + .await; + assert!( + install_fail.is_err(), + "install() should fail when app exists: {:?}", + install_fail + ); + // Clean up the failed job so the source can be reused + jobs.fail(&job2.job_id, "expected failure: app already exists"); + + // 5. Use update() instead — should succeed + let job3 = jobs + .start(&source_url, None) + .expect("should start update job"); + let v2_result = installer + .update(&job3.job_id, &source_url, None, &jobs) + .await + .expect("update should succeed"); + + // 6. Verify the update + assert_eq!(v2_result.app_version, "2.0.0"); + assert_ne!(v2_result.commit, v1_commit, "commit should change"); + + // 7. Verify the installed app is v2 + let app_dir = apps_root.app_path("test-updatable"); + let loaded = InstalledApp::load(&app_dir).expect("should load"); + assert_eq!(loaded.app_version, "2.0.0"); + + // 8. Verify data dir was NOT touched + assert!(data_path.exists(), "data dir should still exist"); + let data_content = std::fs::read_to_string(data_path.join("user-data.json")).unwrap(); + assert!( + data_content.contains("preserved"), + "data should be preserved" + ); + + // 9. Verify the app is still verified + let verified = apps_root.list_verified(); + assert_eq!(verified.len(), 1); + assert_eq!(verified[0].app_version, "2.0.0"); + } +} diff --git a/src/engines/apps/src/manifest.rs b/src/engines/apps/src/manifest.rs new file mode 100644 index 00000000..900d85c4 --- /dev/null +++ b/src/engines/apps/src/manifest.rs @@ -0,0 +1,469 @@ +// MANIFEST MERGE — build manifest entries from installed apps. +// +// At boot/reload, verified installed apps merge into agent and context +// manifests as a third layer (shipped > local > installed). Skills are +// embedded at merge time (content read from app root). Prelude paths are +// made absolute for installed apps. + +use std::collections::HashSet; +use std::path::Path; + +use brains_context::{ContextDecl, ManifestSkill}; +use brains_local_agents::{AgentDecl, AppDecl, Manifest as AgentManifest}; + +use crate::installed::{AppsRoot, InstalledApp}; +use crate::path_safety::{read_contained, PathError}; +use crate::validate::Validator; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum MergeError { + #[error("validation error: {0}")] + Validation(#[from] crate::validate::ValidationError), + #[error("path error: {0}")] + Path(#[from] PathError), + #[error("id collision with shipped app: {0}")] + ShippedCollision(String), + #[error("could not scan skills: {0}")] + SkillScan(String), +} + +/// Merge verified installed apps' agents into the agent manifest. +/// +/// Shipped > local > installed on collision. Returns the merged manifest and +/// a list of app ids that collided with shipped apps (refused at merge time). +pub fn merge_installed_agents( + base: &AgentManifest, + apps_root: &AppsRoot, + shipped_app_ids: &HashSet, +) -> (AgentManifest, Vec) { + let mut manifest = base.clone(); + let mut collisions = Vec::new(); + + let verified = apps_root.list_verified(); + + for app in verified { + let app_root = apps_root.app_path(&app.id); + + // Check collision with shipped apps + if shipped_app_ids.contains(&app.id) { + eprintln!( + "[brains-app] {} collides with shipped app — skipping agents", + app.id + ); + collisions.push(app.id.clone()); + continue; + } + + // Validate and build entries + match build_agent_entries(&app, &app_root) { + Ok((app_decl, agent_decls)) => { + // Check for duplicate app id in manifest + if manifest.apps.iter().any(|a| a.id == app_decl.id) { + eprintln!( + "[brains-app] {} app id already in manifest — shipped/local wins", + app.id + ); + continue; + } + + manifest.apps.push(app_decl); + + for agent in agent_decls { + // Check for duplicate agent id + if manifest.agents.iter().any(|a| a.id == agent.id) { + eprintln!( + "[brains-app] agent {} already in manifest — shipped/local wins", + agent.id + ); + continue; + } + manifest.agents.push(agent); + } + } + Err(e) => { + eprintln!( + "[brains-app] {} failed agent merge: {} — skipping", + app.id, e + ); + } + } + } + + (manifest, collisions) +} + +fn build_agent_entries( + app: &InstalledApp, + app_root: &Path, +) -> Result<(AppDecl, Vec), MergeError> { + let validator = Validator::new(app_root); + let app_json = validator.validate_app_json()?; + let validated_agents = validator.validate_agents()?; + + // #9: Get prelude from VALIDATED context, not by re-parsing raw. + // find_prelude_path re-parsed context.json, skipping containment and extension + // checks — its result became an absolute path the scheduler EXECUTES. The + // validator already checks containment, extension, and existence. + let validated_context = validator.validate_context()?; + let prelude_from_validated = validated_context.and_then(|c| c.prelude_path); + let absolute_prelude = + prelude_from_validated.map(|rel| app_root.join(&rel).to_string_lossy().into_owned()); + + let app_decl = AppDecl { + id: app.id.clone(), + dir: format!("installed/{}", app.id), + gate: app_json.gate.clone(), + }; + + let mut agent_decls = Vec::new(); + for agent in validated_agents { + // #10: Namespace agent ids by app id to avoid global collisions. + // Two installed apps with a `daily/` skill folder would otherwise collide + // and one would be silently dropped. The prefixed id is what the scheduler + // sees; the skill path remains local to the app. + let namespaced_id = format!("{}/{}", app.id, agent.id); + + agent_decls.push(AgentDecl { + id: namespaced_id, + app: app.id.clone(), + cron: agent.cron, + skill_path: agent.skill_path, + context_paths: Vec::new(), // Embedded in context manifest + prelude_path: absolute_prelude.clone(), + enabled: agent.enabled, + }); + } + + Ok((app_decl, agent_decls)) +} + +// #9: find_prelude_path REMOVED — it re-parsed context.json raw, skipping +// containment and extension checks. Use Validator::validate_context() instead; +// it validates paths and returns ValidatedContext.prelude_path. + +/// Merge verified installed apps' contexts into the context manifest. +pub fn merge_installed_contexts( + base: &brains_context::Manifest, + apps_root: &AppsRoot, + shipped_context_names: &HashSet, +) -> brains_context::Manifest { + let mut manifest = base.clone(); + + let verified = apps_root.list_verified(); + + for app in verified { + let app_root = apps_root.app_path(&app.id); + + match build_context_entry(&app, &app_root) { + Ok(Some(decl)) => { + // Check for duplicate context name + if shipped_context_names.contains(&decl.name) + || manifest.contexts.iter().any(|c| c.name == decl.name) + { + eprintln!( + "[brains-app] context {} already in manifest — shipped/local wins", + decl.name + ); + continue; + } + manifest.contexts.push(decl); + } + Ok(None) => { + // No context declaration — that's fine + } + Err(e) => { + eprintln!( + "[brains-app] {} failed context merge: {} — skipping", + app.id, e + ); + } + } + } + + manifest +} + +fn build_context_entry( + app: &InstalledApp, + app_root: &Path, +) -> Result, MergeError> { + let validator = Validator::new(app_root); + + let app_json = match validator.validate_app_json() { + Ok(aj) => aj, + Err(_) => return Ok(None), + }; + + let context = match validator.validate_context()? { + Some(c) => c, + None => return Ok(None), + }; + + // Scan and embed skills + let skills = scan_and_embed_skills(app_root)?; + + Ok(Some(ContextDecl { + name: app.id.clone(), + dir: format!("installed/{}", app.id), + index: context.index, + gate: app_json.gate, + detail: context.detail, + detail_path: context.detail_path, + skills, + })) +} + +/// Scan skill files and embed their content into ManifestSkill entries. +fn scan_and_embed_skills(app_root: &Path) -> Result, MergeError> { + let mut skills = Vec::new(); + + // Walk the app root looking for */SKILL.md patterns + scan_skills_recursive(app_root, app_root, &mut skills)?; + + Ok(skills) +} + +fn scan_skills_recursive( + base: &Path, + current: &Path, + skills: &mut Vec, +) -> Result<(), MergeError> { + let entries = match std::fs::read_dir(current) { + Ok(e) => e, + Err(_) => return Ok(()), + }; + + for entry in entries.flatten() { + let path = entry.path(); + + // Skip symlinks + if let Ok(meta) = std::fs::symlink_metadata(&path) { + if meta.is_symlink() { + continue; + } + } + + if path.is_dir() { + let skill_file = path.join("SKILL.md"); + if skill_file.exists() { + // Found a skill + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("skill") + .to_string(); + + let rel_path = skill_file + .strip_prefix(base) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + + let body = read_contained(base, &rel_path).map_err(|e| { + MergeError::SkillScan(format!("could not read {}: {}", rel_path, e)) + })?; + + let description = body + .lines() + .find(|line| !line.trim().is_empty()) + .map(|line| { + line.trim() + .trim_start_matches('#') + .trim() + .chars() + .take(100) + .collect::() + }) + .unwrap_or_else(|| name.clone()); + + skills.push(ManifestSkill { + name, + description, + body, + source_path: format!("installed/{}", rel_path), + }); + } + + // Continue recursing + scan_skills_recursive(base, &path, skills)?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn setup_app(tmp: &Path, id: &str) -> PathBuf { + let app_root = tmp.join("installed").join(id); + std::fs::create_dir_all(&app_root).unwrap(); + + // app.json + let app_json = serde_json::json!({ + "id": id, + "gate": format!("apps.{}.enabled", id), + "title": "Test App", + "description": "A test", + "version": "1.0.0" + }); + std::fs::write(app_root.join("app.json"), app_json.to_string()).unwrap(); + + // context.json + let context_json = serde_json::json!({ + "index": "Test app index", + "detail": "Test app detail", + "gate": format!("apps.{}.enabled", id) + }); + std::fs::write(app_root.join("context.json"), context_json.to_string()).unwrap(); + + // Skill + let skill_dir = app_root.join("skills").join("test-skill"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write(skill_dir.join("SKILL.md"), "# Test Skill\n\nSkill body").unwrap(); + + // Agent + let agents = serde_json::json!([ + {"cron": "30 6 * * *", "skill": "skills/test-skill/SKILL.md"} + ]); + std::fs::write( + app_root.join(format!("{}.agents.json", id)), + agents.to_string(), + ) + .unwrap(); + + // installed.json + let files = crate::installed::hash_directory(&app_root).unwrap(); + let installed = InstalledApp { + version: crate::installed::INSTALLED_VERSION, + id: id.to_string(), + source: "https://github.com/test/app.git".into(), + git_ref: Some("main".into()), + commit: "abc123".into(), + app_version: "1.0.0".into(), + installed_at: "2026-08-11T12:00:00Z".into(), + files, + }; + installed.write(&app_root).unwrap(); + + app_root + } + + #[test] + fn merges_installed_agents() { + let tmp = tempfile::tempdir().unwrap(); + setup_app(tmp.path(), "test-app"); + + let apps_root = AppsRoot::new(tmp.path()); + let base = AgentManifest::default(); + let shipped = HashSet::new(); + + let (merged, collisions) = merge_installed_agents(&base, &apps_root, &shipped); + + assert!(collisions.is_empty()); + assert_eq!(merged.apps.len(), 1); + assert_eq!(merged.apps[0].id, "test-app"); + assert_eq!(merged.agents.len(), 1); + // #10: Agent ids are namespaced by app id to avoid global collisions + assert_eq!(merged.agents[0].id, "test-app/test-skill"); + } + + #[test] + fn merges_installed_contexts_with_embedded_skills() { + let tmp = tempfile::tempdir().unwrap(); + setup_app(tmp.path(), "test-app"); + + let apps_root = AppsRoot::new(tmp.path()); + let base = brains_context::Manifest::default(); + let shipped = HashSet::new(); + + let merged = merge_installed_contexts(&base, &apps_root, &shipped); + + assert_eq!(merged.contexts.len(), 1); + let ctx = &merged.contexts[0]; + assert_eq!(ctx.name, "test-app"); + assert_eq!(ctx.skills.len(), 1); + assert_eq!(ctx.skills[0].name, "test-skill"); + assert!(ctx.skills[0].body.contains("Skill body")); + } + + #[test] + fn skips_collision_with_shipped() { + let tmp = tempfile::tempdir().unwrap(); + setup_app(tmp.path(), "settings"); + + let apps_root = AppsRoot::new(tmp.path()); + let base = AgentManifest::default(); + let mut shipped = HashSet::new(); + shipped.insert("settings".to_string()); + + let (merged, collisions) = merge_installed_agents(&base, &apps_root, &shipped); + + assert_eq!(collisions, vec!["settings"]); + assert!(merged.apps.is_empty()); + } + + /// #10: With namespacing, the same skill folder name no longer collides. + /// This test verifies that two apps CAN have a `test-skill/` folder without + /// conflict — each gets its own namespaced id. + #[test] + fn same_skill_folder_name_no_longer_collides_with_namespacing() { + let tmp = tempfile::tempdir().unwrap(); + setup_app(tmp.path(), "test-app"); + + let apps_root = AppsRoot::new(tmp.path()); + + // Base manifest has an agent with the OLD (un-namespaced) id. + // With namespacing, the installed app's agent is "test-app/test-skill", + // which does NOT collide with "test-skill". + let mut base = AgentManifest::default(); + base.agents.push(AgentDecl { + id: "test-skill".into(), + app: "shipped".into(), + cron: "0 0 * * *".into(), + skill_path: "shipped.md".into(), + context_paths: Vec::new(), + prelude_path: None, + enabled: true, + }); + + let shipped = HashSet::new(); + let (merged, _) = merge_installed_agents(&base, &apps_root, &shipped); + + // Both agents exist — no collision because ids are different + assert_eq!(merged.agents.len(), 2); + let ids: Vec<&str> = merged.agents.iter().map(|a| a.id.as_str()).collect(); + assert!(ids.contains(&"test-skill")); + assert!(ids.contains(&"test-app/test-skill")); + } + + /// #10: A shipped agent using the NEW namespaced format would still win. + #[test] + fn shipped_wins_on_namespaced_agent_id_collision() { + let tmp = tempfile::tempdir().unwrap(); + setup_app(tmp.path(), "test-app"); + + let apps_root = AppsRoot::new(tmp.path()); + + // Base manifest has agent with the SAME namespaced id + let mut base = AgentManifest::default(); + base.agents.push(AgentDecl { + id: "test-app/test-skill".into(), + app: "shipped".into(), + cron: "0 0 * * *".into(), + skill_path: "shipped.md".into(), + context_paths: Vec::new(), + prelude_path: None, + enabled: true, + }); + + let shipped = HashSet::new(); + let (merged, _) = merge_installed_agents(&base, &apps_root, &shipped); + + // Only the shipped agent should exist + assert_eq!(merged.agents.len(), 1); + assert_eq!(merged.agents[0].app, "shipped"); + } +} diff --git a/src/engines/apps/src/path_safety.rs b/src/engines/apps/src/path_safety.rs new file mode 100644 index 00000000..dc6d8464 --- /dev/null +++ b/src/engines/apps/src/path_safety.rs @@ -0,0 +1,251 @@ +// PATH CONTAINMENT — declared paths must stay inside the app root. +// +// Every path an app DECLARES (skill, context detail, prelude, skill folder names) +// must be: relative, no `..` components, no absolute paths, and the resolved path +// must stay inside `//`. Components-based checks, not string +// prefixes. Symlinks are refused outright (same spirit as the materializer's +// lstat refusal in area.rs). +// +// Enforced at INSTALL validation and again defensively at merge/embed time. + +use std::path::{Component, Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum PathError { + #[error("path is absolute: {0}")] + Absolute(String), + #[error("path contains parent reference (..): {0}")] + ParentReference(String), + #[error("path escapes app root: {path} resolves outside {root}")] + EscapesRoot { path: String, root: PathBuf }, + #[error("path is a symlink: {0}")] + Symlink(String), + #[error("path does not exist: {0}")] + NotFound(String), + #[error("could not read path metadata: {path}: {reason}")] + Io { path: String, reason: String }, +} + +/// A validated path that is known to be safe (relative, contained, not a symlink). +#[derive(Debug, Clone)] +pub struct SafePath { + /// The original declared path (relative). + pub declared: String, + /// The resolved absolute path (inside app_root). + pub resolved: PathBuf, +} + +impl SafePath { + /// Validate a declared path against an app root. + /// + /// Checks: + /// 1. Path is relative (not absolute) + /// 2. No `..` components + /// 3. Resolved path stays inside app_root (using components, not string prefix) + /// 4. Not a symlink (lstat check) + /// 5. Path exists + pub fn validate(declared: &str, app_root: &Path) -> Result { + let declared = declared.trim(); + if declared.is_empty() { + return Err(PathError::NotFound(declared.to_string())); + } + + let path = Path::new(declared); + + // 1. Must be relative + if path.is_absolute() { + return Err(PathError::Absolute(declared.to_string())); + } + + // 2. No parent references + for component in path.components() { + if matches!(component, Component::ParentDir) { + return Err(PathError::ParentReference(declared.to_string())); + } + } + + // 3. Resolve and check containment + let resolved = app_root.join(path); + + // Use canonicalize for the app_root (must exist), but the resolved path + // might not exist yet during validation. For existing paths, canonicalize + // both and check containment. + if resolved.exists() { + // 4. Check for symlinks using lstat (symlink_metadata) + let meta = std::fs::symlink_metadata(&resolved).map_err(|e| PathError::Io { + path: declared.to_string(), + reason: e.to_string(), + })?; + + if meta.is_symlink() { + return Err(PathError::Symlink(declared.to_string())); + } + + // Canonicalize both and verify containment + let canon_root = std::fs::canonicalize(app_root).map_err(|e| PathError::Io { + path: app_root.display().to_string(), + reason: e.to_string(), + })?; + let canon_resolved = std::fs::canonicalize(&resolved).map_err(|e| PathError::Io { + path: declared.to_string(), + reason: e.to_string(), + })?; + + if !canon_resolved.starts_with(&canon_root) { + return Err(PathError::EscapesRoot { + path: declared.to_string(), + root: app_root.to_path_buf(), + }); + } + + Ok(Self { + declared: declared.to_string(), + resolved: canon_resolved, + }) + } else { + // Path doesn't exist yet — validate components only + // (used during copy validation before files are in place) + Err(PathError::NotFound(declared.to_string())) + } + } + + /// Validate that a path would be safe, without requiring it to exist. + /// Used during dist/ copy to pre-validate paths before copying. + pub fn validate_shape(declared: &str) -> Result<(), PathError> { + let declared = declared.trim(); + if declared.is_empty() { + return Err(PathError::NotFound(declared.to_string())); + } + + let path = Path::new(declared); + + if path.is_absolute() { + return Err(PathError::Absolute(declared.to_string())); + } + + for component in path.components() { + if matches!(component, Component::ParentDir) { + return Err(PathError::ParentReference(declared.to_string())); + } + } + + Ok(()) + } +} + +/// Validate a source path and read its content, ensuring no symlinks in the path. +pub fn read_contained(app_root: &Path, rel_path: &str) -> Result { + let safe = SafePath::validate(rel_path, app_root)?; + std::fs::read_to_string(&safe.resolved).map_err(|e| PathError::Io { + path: rel_path.to_string(), + reason: e.to_string(), + }) +} + +/// Check if a path is a symlink (or any ancestor is a symlink). +pub fn has_symlink_in_path(path: &Path) -> bool { + let mut current = path.to_path_buf(); + loop { + if let Ok(meta) = std::fs::symlink_metadata(¤t) { + if meta.is_symlink() { + return true; + } + } + if !current.pop() { + break; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_absolute_paths() { + let tmp = tempfile::tempdir().unwrap(); + let err = SafePath::validate("/etc/passwd", tmp.path()).unwrap_err(); + assert!(matches!(err, PathError::Absolute(_))); + } + + #[test] + fn rejects_parent_references() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("file.md"), "content").unwrap(); + + let err = SafePath::validate("../file.md", tmp.path()).unwrap_err(); + assert!(matches!(err, PathError::ParentReference(_))); + + let err = SafePath::validate("foo/../../../etc/passwd", tmp.path()).unwrap_err(); + assert!(matches!(err, PathError::ParentReference(_))); + } + + // #27: Unix-only symlink tests — gate with cfg(unix) for Windows parity + #[cfg(unix)] + #[test] + fn rejects_symlinks() { + use std::os::unix::fs::symlink; + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("real.md"); + let link = tmp.path().join("link.md"); + std::fs::write(&target, "content").unwrap(); + symlink(&target, &link).unwrap(); + + let err = SafePath::validate("link.md", tmp.path()).unwrap_err(); + assert!(matches!(err, PathError::Symlink(_))); + } + + #[test] + fn accepts_valid_relative_paths() { + let tmp = tempfile::tempdir().unwrap(); + let skills = tmp.path().join("skills"); + std::fs::create_dir_all(&skills).unwrap(); + std::fs::write(skills.join("morning.md"), "skill content").unwrap(); + + let safe = SafePath::validate("skills/morning.md", tmp.path()).unwrap(); + assert_eq!(safe.declared, "skills/morning.md"); + assert!(safe.resolved.ends_with("skills/morning.md")); + } + + #[test] + fn rejects_nonexistent_paths() { + let tmp = tempfile::tempdir().unwrap(); + let err = SafePath::validate("does/not/exist.md", tmp.path()).unwrap_err(); + assert!(matches!(err, PathError::NotFound(_))); + } + + #[test] + fn validate_shape_checks_structure_only() { + // Valid shape + assert!(SafePath::validate_shape("skills/morning.md").is_ok()); + assert!(SafePath::validate_shape("context.json").is_ok()); + + // Invalid shapes + assert!(SafePath::validate_shape("/absolute/path").is_err()); + assert!(SafePath::validate_shape("../escape").is_err()); + assert!(SafePath::validate_shape("foo/../bar").is_err()); + } + + #[test] + fn read_contained_validates_and_reads() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("file.md"), "hello").unwrap(); + + let content = read_contained(tmp.path(), "file.md").unwrap(); + assert_eq!(content, "hello"); + } + + // #27: Unix-only symlink test — gate with cfg(unix) for Windows parity + #[cfg(unix)] + #[test] + fn read_contained_rejects_symlinks() { + use std::os::unix::fs::symlink; + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("target.md"); + let link = tmp.path().join("sym.md"); + std::fs::write(&target, "target").unwrap(); + symlink(&target, &link).unwrap(); + assert!(read_contained(tmp.path(), "sym.md").is_err()); + } +} diff --git a/src/engines/apps/src/validate.rs b/src/engines/apps/src/validate.rs new file mode 100644 index 00000000..ea171a0c --- /dev/null +++ b/src/engines/apps/src/validate.rs @@ -0,0 +1,765 @@ +// APP VALIDATION — enforce the same rules as the build scripts. +// +// Validates: +// - app.json: id, gate, title, description, version, build +// - Agent declarations: cron parses, skill files exist, ids unique, unknown keys fail +// - Context declarations: index/detail present, paths valid, prelude exists +// - All declared paths are contained (no escapes, no symlinks) +// +// size-lint-exception: ~650 lines. Validation rules + tests are co-located for +// clarity — the test documents each rule's behavior, and splitting would +// scatter the contract. + +use std::collections::HashSet; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::is_reserved_id; +use crate::path_safety::{read_contained, PathError, SafePath}; + +/// Validation errors. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ValidationError { + #[error("app.json not found")] + AppJsonNotFound, + #[error("app.json parse error: {0}")] + AppJsonParse(String), + #[error("app.json missing required field: {0}")] + MissingField(String), + #[error("app.json field {field} is invalid: {reason}")] + InvalidField { field: String, reason: String }, + #[error("app id '{0}' is reserved")] + ReservedId(String), + #[error("app id '{id}' does not match gate '{gate}' (expected apps.{id}.enabled)")] + GateMismatch { id: String, gate: String }, + #[error("agent declaration error in {file}: {reason}")] + AgentDecl { file: String, reason: String }, + #[error("context declaration error: {0}")] + ContextDecl(String), + #[error("path error: {0}")] + Path(#[from] PathError), + #[error("duplicate agent id: {0}")] + DuplicateAgentId(String), + #[error("could not read {path}: {reason}")] + Io { path: String, reason: String }, +} + +/// Validated app.json content. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppJson { + pub id: String, + pub gate: String, + pub title: String, + pub description: String, + pub version: String, + #[serde(default)] + pub build: Option, +} + +/// Raw app.json for parsing (all fields optional for error messages). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawAppJson { + id: Option, + gate: Option, + title: Option, + description: Option, + version: Option, + build: Option, +} + +/// Raw agent declaration from *.agents.json. +/// #18: deny_unknown_fields so typos like `enable:` fail instead of silently defaulting. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RawAgentDecl { + pub cron: Option, + pub skill: Option, + pub id: Option, + pub enabled: Option, +} + +/// The app validator. +pub struct Validator<'a> { + app_root: &'a Path, +} + +impl<'a> Validator<'a> { + pub fn new(app_root: &'a Path) -> Self { + Self { app_root } + } + + /// Validate app.json and return its content. + pub fn validate_app_json(&self) -> Result { + let path = self.app_root.join("app.json"); + if !path.exists() { + return Err(ValidationError::AppJsonNotFound); + } + + let content = std::fs::read_to_string(&path).map_err(|e| ValidationError::Io { + path: "app.json".into(), + reason: e.to_string(), + })?; + + let raw: RawAppJson = serde_json::from_str(&content) + .map_err(|e| ValidationError::AppJsonParse(e.to_string()))?; + + // Required fields + let id = raw + .id + .ok_or_else(|| ValidationError::MissingField("id".into()))?; + let gate = raw + .gate + .ok_or_else(|| ValidationError::MissingField("gate".into()))?; + let title = raw + .title + .ok_or_else(|| ValidationError::MissingField("title".into()))?; + let description = raw + .description + .ok_or_else(|| ValidationError::MissingField("description".into()))?; + let version = raw + .version + .ok_or_else(|| ValidationError::MissingField("version".into()))?; + + // Validate id format + if !is_valid_id(&id) { + return Err(ValidationError::InvalidField { + field: "id".into(), + reason: "must be lowercase letters, digits, and dashes".into(), + }); + } + + // Check reserved ids + if is_reserved_id(&id) { + return Err(ValidationError::ReservedId(id)); + } + + // Validate gate matches id + let expected_gate = format!("apps.{}.enabled", id); + if gate != expected_gate { + return Err(ValidationError::GateMismatch { id, gate }); + } + + // Validate title/description non-empty + if title.trim().is_empty() { + return Err(ValidationError::InvalidField { + field: "title".into(), + reason: "cannot be empty".into(), + }); + } + if description.trim().is_empty() { + return Err(ValidationError::InvalidField { + field: "description".into(), + reason: "cannot be empty".into(), + }); + } + + // Validate version is semver-ish (simple check) + if !version.chars().any(|c| c.is_ascii_digit()) { + return Err(ValidationError::InvalidField { + field: "version".into(), + reason: "must contain at least one digit".into(), + }); + } + + Ok(AppJson { + id, + gate, + title, + description, + version, + build: raw.build, + }) + } + + /// Validate all agent declarations in the app. + pub fn validate_agents(&self) -> Result, ValidationError> { + let mut agents = Vec::new(); + let mut seen_ids = HashSet::new(); + + // Find all *.agents.json files + for entry in std::fs::read_dir(self.app_root).map_err(|e| ValidationError::Io { + path: self.app_root.display().to_string(), + reason: e.to_string(), + })? { + let entry = entry.map_err(|e| ValidationError::Io { + path: self.app_root.display().to_string(), + reason: e.to_string(), + })?; + + let name = entry.file_name().to_string_lossy().to_string(); + if !name.ends_with(".agents.json") { + continue; + } + + let content = + std::fs::read_to_string(entry.path()).map_err(|e| ValidationError::Io { + path: name.clone(), + reason: e.to_string(), + })?; + + let decls: Vec = + serde_json::from_str(&content).map_err(|e| ValidationError::AgentDecl { + file: name.clone(), + reason: e.to_string(), + })?; + + for (i, decl) in decls.into_iter().enumerate() { + let validated = self.validate_agent(&name, i, decl)?; + + if seen_ids.contains(&validated.id) { + return Err(ValidationError::DuplicateAgentId(validated.id)); + } + seen_ids.insert(validated.id.clone()); + + agents.push(validated); + } + } + + Ok(agents) + } + + fn validate_agent( + &self, + file: &str, + index: usize, + decl: RawAgentDecl, + ) -> Result { + let where_ = format!("{file}[{index}]"); + + // Required: cron + let cron = decl.cron.ok_or_else(|| ValidationError::AgentDecl { + file: where_.clone(), + reason: "missing cron".into(), + })?; + + // Validate cron parses + if let Err(e) = brains_local_agents::Cron::parse(&cron) { + return Err(ValidationError::AgentDecl { + file: where_.clone(), + reason: format!("invalid cron: {e}"), + }); + } + + // Required: skill + let skill = decl.skill.ok_or_else(|| ValidationError::AgentDecl { + file: where_.clone(), + reason: "missing skill".into(), + })?; + + // Validate skill path is contained and exists + SafePath::validate(&skill, self.app_root)?; + + // Id: declared or derived from skill path (parent folder if SKILL.md, else filename) + let id = decl.id.unwrap_or_else(|| { + let filename = skill.rsplit('/').next().unwrap_or(&skill); + if filename.eq_ignore_ascii_case("skill.md") { + // Use parent folder name for SKILL.md convention + let parts: Vec<&str> = skill.split('/').collect(); + if parts.len() >= 2 { + parts[parts.len() - 2].to_string() + } else { + filename.trim_end_matches(".md").to_lowercase() + } + } else { + filename.trim_end_matches(".md").to_string() + } + }); + + if !is_valid_id(&id) { + return Err(ValidationError::AgentDecl { + file: where_, + reason: format!("invalid id: {id}"), + }); + } + + Ok(ValidatedAgent { + id, + cron, + skill_path: skill, + enabled: decl.enabled.unwrap_or(true), + }) + } + + /// Validate context.json if present. + pub fn validate_context(&self) -> Result, ValidationError> { + let path = self.app_root.join("context.json"); + if !path.exists() { + return Ok(None); + } + + let content = std::fs::read_to_string(&path).map_err(|e| ValidationError::Io { + path: "context.json".into(), + reason: e.to_string(), + })?; + + let raw: RawContextJson = serde_json::from_str(&content) + .map_err(|e| ValidationError::ContextDecl(format!("parse error: {e}")))?; + + // Required: index + let index = raw + .index + .ok_or_else(|| ValidationError::ContextDecl("missing index".into()))?; + + if index.trim().is_empty() { + return Err(ValidationError::ContextDecl("index cannot be empty".into())); + } + + // Detail: inline or path, plus prelude validation (#5) + let (detail, detail_path, prelude_path) = match (raw.detail, raw.details) { + (Some(detail), None) => { + if detail.trim().to_lowercase().ends_with(".md") { + // It's a path + let content = read_contained(self.app_root, &detail)?; + (content, detail, None) + } else { + (detail, String::new(), None) + } + } + (None, Some(details)) => { + // Handle details.app + if let Some(app) = details.app.clone() { + let content = read_contained(self.app_root, &app)?; + + // Validate details.agent paths if present (agent manifest consumes them) + // Mirrors build-context-manifest.mjs: validates paths exist and are .md + if let Some(agent) = &details.agent { + let validate_agent_path = |path: &str| -> Result<(), ValidationError> { + if !path.to_lowercase().ends_with(".md") { + return Err(ValidationError::ContextDecl(format!( + "details.agent path must be a .md file: {}", + path + ))); + } + read_contained(self.app_root, path)?; + Ok(()) + }; + + match agent { + serde_json::Value::String(path) => { + validate_agent_path(path)?; + } + serde_json::Value::Array(paths) => { + if paths.is_empty() { + return Err(ValidationError::ContextDecl( + "details.agent cannot be an empty array".into(), + )); + } + for path in paths { + if let Some(p) = path.as_str() { + validate_agent_path(p)?; + } else { + return Err(ValidationError::ContextDecl( + "details.agent array elements must be strings".into(), + )); + } + } + } + _ => { + return Err(ValidationError::ContextDecl( + "details.agent must be a string or array of strings".into(), + )); + } + } + } + + // #5: Validate prelude if present — must be .mjs or .js script + // (mirrors build-context-manifest.mjs: prelude is run with node) + let prelude = if let Some(prelude) = details.prelude { + // Validate prelude path is contained and exists + SafePath::validate(&prelude, self.app_root)?; + // Enforce .mjs/.js extension (prelude is a script, not a document) + let lower = prelude.to_lowercase(); + if !lower.ends_with(".mjs") && !lower.ends_with(".js") { + return Err(ValidationError::ContextDecl(format!( + "prelude must be a .mjs or .js script: {}", + prelude + ))); + } + Some(prelude) + } else { + None + }; + + (content, app, prelude) + } else { + return Err(ValidationError::ContextDecl("details without app".into())); + } + } + (None, None) => { + return Err(ValidationError::ContextDecl( + "missing detail or details".into(), + )) + } + (Some(_), Some(_)) => { + return Err(ValidationError::ContextDecl( + "cannot have both detail and details".into(), + )) + } + }; + + Ok(Some(ValidatedContext { + index, + detail, + detail_path, + gate: raw.gate, + prelude_path, + })) + } + + /// Full validation: app.json + agents + context. + pub fn validate_all(&self) -> Result { + let app_json = self.validate_app_json()?; + let agents = self.validate_agents()?; + let context = self.validate_context()?; + + Ok(ValidatedApp { + app_json, + agents, + context, + }) + } +} + +#[derive(Debug, Clone)] +pub struct ValidatedAgent { + pub id: String, + pub cron: String, + pub skill_path: String, + pub enabled: bool, +} + +#[derive(Debug, Clone)] +pub struct ValidatedContext { + pub index: String, + pub detail: String, + pub detail_path: String, + pub gate: Option, + /// #5: prelude path, if declared (validated for containment). + pub prelude_path: Option, +} + +#[derive(Debug, Clone)] +pub struct ValidatedApp { + pub app_json: AppJson, + pub agents: Vec, + pub context: Option, +} + +/// #18: deny_unknown_fields so typos fail validation. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawContextJson { + index: Option, + detail: Option, + details: Option, + gate: Option, +} + +/// #5: prelude must be validated (contained, exists, right extension). +/// #18: deny_unknown_fields so typos fail validation. +/// Mirrors build-context-manifest.mjs DETAILS_KEYS: app, agent, prelude. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawDetails { + app: Option, + /// Agent detail path(s) — validated here, consumed by agent manifest merge. + agent: Option, + prelude: Option, +} + +fn is_valid_id(id: &str) -> bool { + if id.is_empty() { + return false; + } + let first = id.chars().next().unwrap(); + if !first.is_ascii_lowercase() && !first.is_ascii_digit() { + return false; + } + id.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_app_json(dir: &Path, id: &str) { + let json = serde_json::json!({ + "id": id, + "gate": format!("apps.{}.enabled", id), + "title": "Test App", + "description": "A test app", + "version": "1.0.0" + }); + std::fs::write(dir.join("app.json"), json.to_string()).unwrap(); + } + + #[test] + fn validates_app_json() { + let tmp = tempfile::tempdir().unwrap(); + write_app_json(tmp.path(), "test-app"); + + let v = Validator::new(tmp.path()); + let app = v.validate_app_json().unwrap(); + assert_eq!(app.id, "test-app"); + assert_eq!(app.gate, "apps.test-app.enabled"); + } + + #[test] + fn rejects_reserved_id() { + let tmp = tempfile::tempdir().unwrap(); + write_app_json(tmp.path(), "settings"); + + let v = Validator::new(tmp.path()); + let err = v.validate_app_json().unwrap_err(); + assert!(matches!(err, ValidationError::ReservedId(_))); + } + + #[test] + fn rejects_gate_mismatch() { + let tmp = tempfile::tempdir().unwrap(); + let json = serde_json::json!({ + "id": "my-app", + "gate": "apps.other.enabled", + "title": "Test", + "description": "Test", + "version": "1.0" + }); + std::fs::write(tmp.path().join("app.json"), json.to_string()).unwrap(); + + let v = Validator::new(tmp.path()); + let err = v.validate_app_json().unwrap_err(); + assert!(matches!(err, ValidationError::GateMismatch { .. })); + } + + #[test] + fn validates_agents() { + let tmp = tempfile::tempdir().unwrap(); + write_app_json(tmp.path(), "test"); + + // Create skill file + std::fs::create_dir_all(tmp.path().join("skills")).unwrap(); + std::fs::write(tmp.path().join("skills/morning.md"), "# Morning").unwrap(); + + // Create agents file + let agents = serde_json::json!([ + {"cron": "30 6 * * *", "skill": "skills/morning.md"} + ]); + std::fs::write(tmp.path().join("test.agents.json"), agents.to_string()).unwrap(); + + let v = Validator::new(tmp.path()); + let agents = v.validate_agents().unwrap(); + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].id, "morning"); + } + + #[test] + fn rejects_invalid_cron() { + let tmp = tempfile::tempdir().unwrap(); + write_app_json(tmp.path(), "test"); + std::fs::create_dir_all(tmp.path().join("skills")).unwrap(); + std::fs::write(tmp.path().join("skills/s.md"), "x").unwrap(); + + let agents = serde_json::json!([ + {"cron": "invalid cron", "skill": "skills/s.md"} + ]); + std::fs::write(tmp.path().join("test.agents.json"), agents.to_string()).unwrap(); + + let v = Validator::new(tmp.path()); + let err = v.validate_agents().unwrap_err(); + assert!(matches!(err, ValidationError::AgentDecl { .. })); + } + + #[test] + fn rejects_missing_skill_file() { + let tmp = tempfile::tempdir().unwrap(); + write_app_json(tmp.path(), "test"); + + let agents = serde_json::json!([ + {"cron": "30 6 * * *", "skill": "skills/missing.md"} + ]); + std::fs::write(tmp.path().join("test.agents.json"), agents.to_string()).unwrap(); + + let v = Validator::new(tmp.path()); + let err = v.validate_agents().unwrap_err(); + assert!(matches!(err, ValidationError::Path(PathError::NotFound(_)))); + } + + #[test] + fn rejects_duplicate_agent_ids() { + let tmp = tempfile::tempdir().unwrap(); + write_app_json(tmp.path(), "test"); + + std::fs::create_dir_all(tmp.path().join("skills")).unwrap(); + std::fs::write(tmp.path().join("skills/a.md"), "x").unwrap(); + std::fs::write(tmp.path().join("skills/b.md"), "x").unwrap(); + + let agents = serde_json::json!([ + {"cron": "30 6 * * *", "skill": "skills/a.md", "id": "same"}, + {"cron": "30 7 * * *", "skill": "skills/b.md", "id": "same"} + ]); + std::fs::write(tmp.path().join("test.agents.json"), agents.to_string()).unwrap(); + + let v = Validator::new(tmp.path()); + let err = v.validate_agents().unwrap_err(); + assert!(matches!(err, ValidationError::DuplicateAgentId(_))); + } + + #[test] + fn rejects_unknown_agent_fields() { + // #18: deny_unknown_fields - a typo like "enable:" should fail + let tmp = tempfile::tempdir().unwrap(); + write_app_json(tmp.path(), "test"); + std::fs::create_dir_all(tmp.path().join("skills")).unwrap(); + std::fs::write(tmp.path().join("skills/s.md"), "x").unwrap(); + + // Note: "enable" instead of "enabled" - a typo + let agents = serde_json::json!([ + {"cron": "30 6 * * *", "skill": "skills/s.md", "enable": false} + ]); + std::fs::write(tmp.path().join("test.agents.json"), agents.to_string()).unwrap(); + + let v = Validator::new(tmp.path()); + let err = v.validate_agents().unwrap_err(); + assert!(matches!(err, ValidationError::AgentDecl { .. })); + } + + #[test] + fn validates_prelude_path() { + // #5: prelude in details must be a .mjs/.js script (not .md) + let tmp = tempfile::tempdir().unwrap(); + write_app_json(tmp.path(), "test"); + std::fs::create_dir_all(tmp.path().join("skills")).unwrap(); + std::fs::write(tmp.path().join("skills/prelude.mjs"), "// prelude script").unwrap(); + std::fs::write(tmp.path().join("DETAIL.md"), "# Detail").unwrap(); + + let context = serde_json::json!({ + "index": "Test context", + "details": { + "app": "DETAIL.md", + "prelude": "skills/prelude.mjs" + } + }); + std::fs::write(tmp.path().join("context.json"), context.to_string()).unwrap(); + + let v = Validator::new(tmp.path()); + let ctx = v.validate_context().unwrap().unwrap(); + assert_eq!(ctx.prelude_path, Some("skills/prelude.mjs".to_string())); + } + + #[test] + fn rejects_prelude_path_escape() { + // #5: prelude with parent traversal should fail + let tmp = tempfile::tempdir().unwrap(); + write_app_json(tmp.path(), "test"); + std::fs::write(tmp.path().join("DETAIL.md"), "# Detail").unwrap(); + + let context = serde_json::json!({ + "index": "Test context", + "details": { + "app": "DETAIL.md", + "prelude": "../../../etc/passwd" + } + }); + std::fs::write(tmp.path().join("context.json"), context.to_string()).unwrap(); + + let v = Validator::new(tmp.path()); + let err = v.validate_context().unwrap_err(); + assert!(matches!( + err, + ValidationError::Path(PathError::ParentReference(_)) + )); + } + + #[test] + fn rejects_prelude_non_script_extension() { + // #5: prelude must be a .mjs/.js script, not .md or other extensions + let tmp = tempfile::tempdir().unwrap(); + write_app_json(tmp.path(), "test"); + std::fs::write(tmp.path().join("DETAIL.md"), "# Detail").unwrap(); + std::fs::write(tmp.path().join("prelude.md"), "# Not a script").unwrap(); + + let context = serde_json::json!({ + "index": "Test context", + "details": { + "app": "DETAIL.md", + "prelude": "prelude.md" + } + }); + std::fs::write(tmp.path().join("context.json"), context.to_string()).unwrap(); + + let v = Validator::new(tmp.path()); + let err = v.validate_context().unwrap_err(); + assert!(matches!(err, ValidationError::ContextDecl(_))); + } + + #[test] + fn accepts_prelude_js_extension() { + // .js is also valid for prelude (not just .mjs) + let tmp = tempfile::tempdir().unwrap(); + write_app_json(tmp.path(), "test"); + std::fs::write(tmp.path().join("DETAIL.md"), "# Detail").unwrap(); + std::fs::write(tmp.path().join("prelude.js"), "// valid js prelude").unwrap(); + + let context = serde_json::json!({ + "index": "Test context", + "details": { + "app": "DETAIL.md", + "prelude": "prelude.js" + } + }); + std::fs::write(tmp.path().join("context.json"), context.to_string()).unwrap(); + + let v = Validator::new(tmp.path()); + let ctx = v.validate_context().unwrap().unwrap(); + assert_eq!(ctx.prelude_path, Some("prelude.js".to_string())); + } + + #[test] + fn accepts_details_agent_field() { + // details.agent is valid (consumed by agent manifest merge) + let tmp = tempfile::tempdir().unwrap(); + write_app_json(tmp.path(), "test"); + std::fs::write(tmp.path().join("DETAIL.md"), "# App detail").unwrap(); + std::fs::write(tmp.path().join("agent-context.md"), "# Agent context").unwrap(); + + let context = serde_json::json!({ + "index": "Test context", + "details": { + "app": "DETAIL.md", + "agent": "agent-context.md" + } + }); + std::fs::write(tmp.path().join("context.json"), context.to_string()).unwrap(); + + let v = Validator::new(tmp.path()); + let ctx = v.validate_context().unwrap().unwrap(); + assert_eq!(ctx.detail, "# App detail"); + } + + #[test] + fn accepts_details_agent_array() { + // details.agent can be an array of .md paths + let tmp = tempfile::tempdir().unwrap(); + write_app_json(tmp.path(), "test"); + std::fs::write(tmp.path().join("DETAIL.md"), "# App detail").unwrap(); + std::fs::write(tmp.path().join("agent1.md"), "# Agent 1").unwrap(); + std::fs::write(tmp.path().join("agent2.md"), "# Agent 2").unwrap(); + + let context = serde_json::json!({ + "index": "Test context", + "details": { + "app": "DETAIL.md", + "agent": ["agent1.md", "agent2.md"] + } + }); + std::fs::write(tmp.path().join("context.json"), context.to_string()).unwrap(); + + let v = Validator::new(tmp.path()); + let ctx = v.validate_context().unwrap().unwrap(); + assert_eq!(ctx.detail, "# App detail"); + } +} diff --git a/src/engines/brains/native/src/readiness.rs b/src/engines/brains/native/src/readiness.rs index b6a4fab1..0fd40a0a 100644 --- a/src/engines/brains/native/src/readiness.rs +++ b/src/engines/brains/native/src/readiness.rs @@ -23,7 +23,9 @@ use crate::{keychain, BrainsClient, BrainsError}; const KEYCHAIN_DENIED_MARKERS: &[&str] = &["-128", "User canceled", "user cancelled"]; /// The label the index shows for this domain. -pub const TOKEN_LABEL: &str = "brains token valid"; +/// #29: Names the DOMAIN ("brains account"), not the success state, so blocking +/// messages like "waiting on: