From b4b2fbe33e42dadc24ca8bf636179b95f6762f00 Mon Sep 17 00:00:00 2001 From: Surma Date: Thu, 12 Nov 2026 12:22:01 +0000 Subject: [PATCH] Reload extension file snapshots without Git --- config/config.yaml | 2 +- docs/RUNNING.md | 26 +- extensions/README.md | 54 +- host/js/automations-test-bundle.js | 155 ++--- host/js/bundle.js | 153 ++--- host/js/chain-test-bundle.js | 1 + host/js/core-ext-test-bundle.js | 153 ++--- host/js/dream-test-bundle.js | 153 ++--- host/js/events-test-bundle.js | 153 ++--- host/js/extensions-test-bundle.js | 821 ++++++++++++------------- host/js/extensions-test.ts | 97 ++- host/js/extensions.ts | 125 ++-- host/js/goal-questions-test-bundle.js | 155 ++--- host/js/harness-api-test-bundle.js | 153 ++--- host/js/messages-test-bundle.js | 153 ++--- host/js/p3-test-bundle.js | 153 ++--- host/js/plan-todos-test-bundle.js | 153 ++--- host/js/provider-models-test-bundle.js | 1 + host/js/subagents-test-bundle.js | 153 ++--- host/js/undo-harness-test-bundle.js | 1 + host/js/watchdog-test-bundle.js | 153 ++--- host/js/workspaces-test-bundle.js | 153 ++--- host/src/world.rs | 42 +- host/tests/it/core_ext.rs | 42 +- host/tests/it/extensions.rs | 280 +-------- host/tests/it/p1.rs | 62 +- userland/exec-tools/Cargo.toml | 4 - userland/exec-tools/src/revert.rs | 25 - 28 files changed, 1690 insertions(+), 1886 deletions(-) delete mode 100644 userland/exec-tools/src/revert.rs diff --git a/config/config.yaml b/config/config.yaml index 999ba89..8133d64 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -21,7 +21,7 @@ prompt: |- Sausage is a world of programs and durable agents. All agents see and change the same world. Use the programs in /bin through bash. Use explicit repository paths. Worktrees live at /worktrees/. - The platform source lives in /src/sausage. Extensions reload after a commit to main. + The platform source lives in /src/sausage. Saved extension file edits reload automatically. No Git commit is required. /etc/sausage/config.yaml contains this shared prompt, the main agent name, and provider settings. Agent definitions live in /etc/sausage/roster/.md. Project definitions live in /.sausage/roster/*.md. You can read and edit the config and the roster files. Changes apply at the next run. diff --git a/docs/RUNNING.md b/docs/RUNNING.md index c37dd5d..7595589 100644 --- a/docs/RUNNING.md +++ b/docs/RUNNING.md @@ -34,7 +34,7 @@ nix develop -c nu userland/bundle.nu It builds the world commands and creates `target/userland/bundle` by default. It snapshots committed source, not uncommitted edits. The source archive includes the complete `config/`, `skills/`, and `extensions/` directories. -The bundle contains plain source files. `sausage init` creates their repository and its first commit on `main`. +The bundle contains plain source files. `sausage init` copies them without a Git repository or first commit. The world copier preserves symlinks, including dangling links in the source tree. A fresh development world needs no manual source copy. The bundle includes `/bin/web.wasm` and an enabled `/etc/systemd/system/web.service`. @@ -192,25 +192,21 @@ and [`models`](../extensions/models/README.md). ## Change extensions -The world checkout is `/src/sausage`. The loader follows its **`main`** branch, not the host checkout's `v3` branch. -Each extension lives at `extensions/NAME/server.ts` inside that checkout. -Commit extension edits to `main` with world git. A saved, uncommitted edit does not reload an extension. -Use `--no-gpg-sign` for commits. +The platform source is `/src/sausage`. Each extension lives at `extensions/NAME/server.ts`. +Save extension edits directly. No Git commit or restart is required. +The VFS watcher waits for a short quiet period, then hashes the extension files. +Unchanged contents do not reload. -The loader imports a detached snapshot under `/run/sausage/extensions/COMMIT`. +The loader copies the files under `/run/sausage/extensions/HASH`. It validates the entire candidate before it replaces the live registry. -A broken candidate leaves the previous good commit active. +A broken candidate leaves the previous good set active. An active tool call retains its implementation. The next call uses the new implementation. Read `/proc/extensions` through the agent's file tools to inspect `current`, `good`, and `error`. -To restore the last good branch ref, ask the agent to execute: - -```sh -extensions-revert -``` - -The command preserves editable files. It changes `refs/heads/main`, which triggers the normal reload. -At startup, the loader tries the tip and then the last good commit before it resumes work. +Use `undo` to restore earlier files. The normal file watch reloads them. +At startup, the loader hashes the current files, including edits made while the host was offline. +If they fail, it loads the last good copy from `host/harness.sqlite` and shows the error. +The fallback does not change editable files. Source: [`extensions`](../extensions/README.md) and [`extensions.ts`](../host/js/extensions.ts). diff --git a/extensions/README.md b/extensions/README.md index b5af262..f1e9af2 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -1,29 +1,35 @@ # World extensions -The harness loads `/src/sausage` from its `main` branch. -Each extension occupies `extensions//server.ts` in that repository. +The harness loads files under `/src/sausage/extensions/`. +Each extension occupies `/server.ts` in that directory. No Git repository or commit is required. The default export is an extension or a factory that receives a `Host`. The extension name must match its folder. An optional numeric `order` export controls order. Names break ties. ## Reload contract -A change to the branch ref or `packed-refs` triggers a reload. -The harness creates a detached world worktree at `/run/sausage/extensions/`. -Imports read that snapshot through the world VFS, not the editable checkout. +The VFS watcher observes the extension directory recursively. +After changes, the harness waits for 75 milliseconds of quiet before it reads the files. +It computes a SHA-256 hash over sorted paths and their byte contents, only at startup and after file changes. +Unchanged contents do not reload. +The harness copies the captured bytes to `/run/sausage/extensions//extensions/`. +Imports read that snapshot through the world VFS, not the editable files. Relative imports must include their file extensions. TypeScript modules use the same `deno_ast` transpiler as the harness. -The harness imports and validates the whole commit before it changes the live registry. -An import error, factory error, name mismatch, or registry validation error retains the previous commit. +The harness imports and validates the whole file set before it changes the live registry. +An import error, factory error, name mismatch, or registry validation error retains the previous set. A running tool call keeps its old implementation. The next call uses the new implementation. Removed extensions leave the registry after a successful reload. -`/proc/extensions` contains JSON with `good`, `current`, and `error`. -A session document persists these values in the harness database. -On startup, the harness tries the tip, then the last good commit, before `resume()`. +`/proc/extensions` contains JSON with `good`, `current`, and `error`. Version values are content hashes. +A session document persists these values in the host database, `/host/harness.sqlite`. +After successful validation, another session document retains the complete last good file set in that database. +Broken edits do not replace that copy. +On startup, the harness hashes and loads the current files, including offline edits. +If they fail, it loads the host-side copy and reports the current error before `resume()`. Tasks without a registered definition remain blocked. -The snapshot worktrees disappear with `/run` on restart. The loader prunes stale worktree registrations before it recreates them. +The snapshot directories disappear with `/run` on restart. The host-side copy survives. ## Host @@ -35,7 +41,7 @@ The host supplies these members: - `exec(command, options)` for shell strings or argument arrays. Its result contains `code`, `stdout`, and `stderr`. - Exec options: `cwd`, `env` pairs, `timeout` in seconds, `conversations`, `signal`, and `onOutput(text, stream)`. - `git(cwd, ...args)`, which returns trimmed stdout and throws with stderr on failure. -- `implement(api, value)`, which stages a service until the commit installs. +- `implement(api, value)`, which stages a service until the file set installs. - `need(api)`, which returns a proxy. Every method call resolves the current provider, including a retained method reference. - `want(api)`, which returns a proxy or `undefined` when no provider exists. - `onOpen(callback)` and `onClose(callback)` for facet lifecycle. @@ -58,25 +64,15 @@ The registry supplies the default extension selection for existing conversations New root conversations do not pin the built-ins. Explicit per-conversation extension selections remain explicit. -## Revert +## Restore files -Run this command inside the world: - -```sh -extensions-revert -``` - -The Rust program lives in `userland/exec-tools/src/revert.rs`. -It reads `/src/sausage/.git/sausage-last-good` and calls world git to update `refs/heads/main`. -The loader writes this helper file after it persists the session status. -An empty helper file means no commit succeeded yet. -The command preserves the editable checkout. The branch change triggers the normal reload. +Use `undo` to restore earlier files. The normal file watch reloads the restored set. +The startup fallback does not change editable files. ## Tests -`host/tests/extensions.rs` runs `host/js/extensions-test-bundle.js` with world git and a faux model. -It tests A, broken B, changed C, an old call across reload, a retained service handle, and restart recovery. -It also tests the revert command, file operations, and exec environment options. -The world git and helper components use the executor's shared `CARGO_TARGET_DIR`. -The API and hook fixtures use that directory too. +`host/tests/it/extensions.rs` runs `host/js/extensions-test-bundle.js` with a faux model and a process interface that rejects all commands. +It tests file edits, broken edits, a multi-file burst, an old call across reload, and a retained service handle. +It also tests unchanged hashes, offline edits, and recovery from the host-side copy across restarts. +The init tests verify that `/src/sausage` contains no `.git` directory. The Nix shell preserves the user's Cargo configuration and its kache wrapper. diff --git a/host/js/automations-test-bundle.js b/host/js/automations-test-bundle.js index fe8aafc..ec015a2 100644 --- a/host/js/automations-test-bundle.js +++ b/host/js/automations-test-bundle.js @@ -39914,28 +39914,54 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b let state = { ...await harness2.snapshot(ExtensionState, context) ?? { good: null, current: null, error: null } }; let settings = {}; let active = true; - const commands = new AbortController(); - const loaderGit = async (...args) => { - for (let attempt = 0; ; attempt++) { - try { - const result = await exec(["git", ...args], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(result.stderr || `git exited ${result.code}`); - return result.stdout.trim(); - } catch (error) { - const message = `git ${args.join(" ")}: ${String(error)}`; - state.error = message; - ops2.op_extension_status(JSON.stringify(state)); - if (attempt === 0 && !commands.signal.aborted && String(error).includes("command timed out")) continue; - throw new Error(message); + let installed = false; + let queue2 = Promise.resolve(); + const publish2 = async (copy2) => { + await harness2.commit(async (tx) => { + Object.assign(await tx.doc(ExtensionState), state); + if (copy2) Object.assign(await tx.doc(ExtensionFiles), copy2); + }, context); + ops2.op_extension_status(JSON.stringify(state)); + }; + const readCopy = async () => { + const files = []; + const walk = async (relative) => { + const path = `${repo}/extensions${relative}`; + const entries2 = await fs.list(path).catch((error) => { + if (error.code === "not_found") return []; + throw error; + }); + entries2.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + for (const entry2 of entries2) { + const name = `${relative}/${entry2.name}`; + if (entry2.kind === "directory") await walk(name); + else files.push([name, await file("read", `${repo}/extensions${name}`)]); } + }; + await walk(""); + const digest = await crypto.subtle.digest("SHA-256", encoder4.encode(JSON.stringify(files))); + const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + return { hash, files }; + }; + const mkdir = async (path) => { + try { + await fs.mkdir(path); + } catch (error) { + if (error.code !== "already_exists") throw error; } }; - let queue2 = Promise.resolve(); - const publish2 = async () => { - await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context); - ops2.op_extension_status(JSON.stringify(state)); - await fs.write("/src/sausage/.git/sausage-last-good.tmp", state.good ?? ""); - await fs.rename("/src/sausage/.git/sausage-last-good.tmp", "/src/sausage/.git/sausage-last-good"); + const snapshot = async ({ hash, files }) => { + const base = `/run/sausage/extensions/${hash}`; + for (const path of ["/run/sausage", "/run/sausage/extensions", base, `${base}/extensions`]) await mkdir(path); + for (const [name, bytes2] of files) { + let parent = `${base}/extensions`; + for (const part of name.slice(1).split("/").slice(0, -1)) { + parent += `/${part}`; + await mkdir(parent); + } + await file("write", `${base}/extensions${name}`, bytes2); + } + return base; }; const dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -40010,42 +40036,18 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b }; return { host, open, close }; }; - const load = async (sha) => { + const load = async (copy2) => { const next2 = []; const staged = /* @__PURE__ */ new Map(); try { - const snapshot = `/run/sausage/extensions/${sha}`; - for (const path of ["/run/sausage", "/run/sausage/extensions"]) { - try { - await fs.mkdir(path); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - } - try { - await fs.stat(snapshot); - } catch { - const staging = `${snapshot}.tmp`, archive = `${snapshot}.tar`; - try { - await fs.mkdir(staging); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - if (await loaderGit("ls-tree", "--name-only", sha, "extensions")) { - await loaderGit("archive", "--format=tar", `--output=${archive}`, sha, "extensions"); - const result = await exec(["tar", "-xf", archive, "-C", staging], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(`tar: ${result.stderr || result.code}`); - await fs.remove(archive); - } - await fs.rename(staging, snapshot); - } + const pathRoot = await snapshot(copy2); const modules = []; - for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { + for (const entry2 of await fs.list(`${pathRoot}/extensions`).catch((error) => { if (error.code === "not_found") return []; throw error; })) { if (entry2.kind !== "directory") continue; - const path = `${snapshot}/extensions/${entry2.name}/server.ts`; + const path = `${pathRoot}/extensions/${entry2.name}/server.ts`; try { await fs.stat(path); } catch (error) { @@ -40068,7 +40070,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } const probe = createRegistry(); for (const extension of [...builtins, ...next2.map((f) => f.extension)]) probe.install(extension); - for (const old2 of facets) registry2.uninstall(old2.extension); + const names2 = new Set(next2.map((f) => f.extension.name)); + for (const old2 of facets) if (!names2.has(old2.extension.name)) registry2.uninstall(old2.extension); for (const extension of [...builtins, ...next2.map((f) => f.extension)]) registry2.install(extension); const old = facets; facets = next2; @@ -40081,7 +40084,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b facet.host.notice(`onOpen: ${String(error)}`); } } - state.good = sha; + installed = true; + state.good = copy2.hash; return null; } catch (error) { await dispose(next2); @@ -40089,14 +40093,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } }; const reload = async () => { - let tip; - try { - tip = await loaderGit("rev-parse", "refs/heads/main"); - } catch (error) { - state.error = String(error); - ops2.op_extension_status(JSON.stringify(state)); - return; - } + let good; try { settings = JSON.parse(await fs.read(`${repo}/settings.json`)); } catch (error) { @@ -40108,24 +40105,39 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b `); } } - if (state.current === tip && facets.length) return; - state.current = tip; - state.error = await load(tip); - if (state.error && !facets.length && state.good && state.good !== tip) { - const failed = state.error; - const fallback = await load(state.good); - state.error = fallback ? `${failed} -Last good: ${fallback}` : failed; + try { + const copy2 = await readCopy(); + if (state.current === copy2.hash && installed) return; + state.current = copy2.hash; + state.error = await load(copy2); + if (!state.error) good = copy2; + } catch (error) { + state.error = String(error); } - await publish2(); + if (state.error && !installed) { + const saved = await harness2.snapshot(ExtensionFiles, context); + if (saved?.hash) { + const failed = state.error; + const fallback = await load(saved); + state.error = fallback ? `${failed} +Last good: ${fallback}` : failed; + } + } + await publish2(good); }; const enqueue2 = () => queue2 = queue2.then(reload); - const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]); + const rid = ops2.op_watch([`${repo}/extensions`, `${repo}/settings.json`]); + let timer; await enqueue2(); const pending = (async () => { while (active) { - await ops2.op_watch_next(rid); - if (active) await enqueue2(); + const event = await ops2.op_watch_next(rid); + if (!event || !active) break; + clearTimeout(timer); + timer = setTimeout(() => { + timer = void 0; + void enqueue2(); + }, 75); } })(); return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() { @@ -40140,14 +40152,14 @@ Last good: ${fallback}` : failed; return action(args, { host: facet.host, signal, conversationId, source: "ui" }); }, close: async () => { active = false; - commands.abort(); + clearTimeout(timer); Deno.core.close(rid); await pending; await queue2; await dispose(facets); } }; } -var ops2, encoder4, decoder4, ExtensionState, Metadata, fs; +var ops2, encoder4, decoder4, ExtensionState, ExtensionFiles, Metadata, fs; var init_extensions = __esm({ "extensions.ts"() { init_dist4(); @@ -40164,6 +40176,7 @@ var init_extensions = __esm({ encoder4 = new TextEncoder(); decoder4 = new TextDecoder(); ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) }); + ExtensionFiles = defineDoc({ kind: "sausage.extensions.files", version: 1, scope: "session", history: "latest", checkpointWhen: () => false, initial: () => ({ hash: null, files: [] }) }); Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false }); fs = { read: async (path) => decoder4.decode(new Uint8Array(await file("read", path))), diff --git a/host/js/bundle.js b/host/js/bundle.js index 042810f..be2142a 100644 --- a/host/js/bundle.js +++ b/host/js/bundle.js @@ -78030,6 +78030,7 @@ var ops2 = Deno.core.ops; var encoder5 = new TextEncoder(); var decoder4 = new TextDecoder(); var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) }); +var ExtensionFiles = defineDoc({ kind: "sausage.extensions.files", version: 1, scope: "session", history: "latest", checkpointWhen: () => false, initial: () => ({ hash: null, files: [] }) }); var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false }); async function file(kind, path5, bytes2 = []) { const result = await ops2.op_file(kind, path5, bytes2); @@ -78102,28 +78103,54 @@ async function loadExtensions(harness2, registry3, storage2, models2, context, b let state2 = { ...await harness2.snapshot(ExtensionState, context) ?? { good: null, current: null, error: null } }; let settings = {}; let active = true; - const commands = new AbortController(); - const loaderGit = async (...args) => { - for (let attempt = 0; ; attempt++) { - try { - const result = await exec(["git", ...args], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(result.stderr || `git exited ${result.code}`); - return result.stdout.trim(); - } catch (error) { - const message = `git ${args.join(" ")}: ${String(error)}`; - state2.error = message; - ops2.op_extension_status(JSON.stringify(state2)); - if (attempt === 0 && !commands.signal.aborted && String(error).includes("command timed out")) continue; - throw new Error(message); + let installed = false; + let queue2 = Promise.resolve(); + const publish2 = async (copy2) => { + await harness2.commit(async (tx) => { + Object.assign(await tx.doc(ExtensionState), state2); + if (copy2) Object.assign(await tx.doc(ExtensionFiles), copy2); + }, context); + ops2.op_extension_status(JSON.stringify(state2)); + }; + const readCopy = async () => { + const files = []; + const walk = async (relative) => { + const path5 = `${repo}/extensions${relative}`; + const entries2 = await fs2.list(path5).catch((error) => { + if (error.code === "not_found") return []; + throw error; + }); + entries2.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + for (const entry2 of entries2) { + const name = `${relative}/${entry2.name}`; + if (entry2.kind === "directory") await walk(name); + else files.push([name, await file("read", `${repo}/extensions${name}`)]); } + }; + await walk(""); + const digest = await crypto.subtle.digest("SHA-256", encoder5.encode(JSON.stringify(files))); + const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + return { hash, files }; + }; + const mkdir = async (path5) => { + try { + await fs2.mkdir(path5); + } catch (error) { + if (error.code !== "already_exists") throw error; } }; - let queue2 = Promise.resolve(); - const publish2 = async () => { - await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state2), context); - ops2.op_extension_status(JSON.stringify(state2)); - await fs2.write("/src/sausage/.git/sausage-last-good.tmp", state2.good ?? ""); - await fs2.rename("/src/sausage/.git/sausage-last-good.tmp", "/src/sausage/.git/sausage-last-good"); + const snapshot = async ({ hash, files }) => { + const base = `/run/sausage/extensions/${hash}`; + for (const path5 of ["/run/sausage", "/run/sausage/extensions", base, `${base}/extensions`]) await mkdir(path5); + for (const [name, bytes2] of files) { + let parent = `${base}/extensions`; + for (const part of name.slice(1).split("/").slice(0, -1)) { + parent += `/${part}`; + await mkdir(parent); + } + await file("write", `${base}/extensions${name}`, bytes2); + } + return base; }; const dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -78198,42 +78225,18 @@ async function loadExtensions(harness2, registry3, storage2, models2, context, b }; return { host, open, close }; }; - const load = async (sha) => { + const load = async (copy2) => { const next = []; const staged = /* @__PURE__ */ new Map(); try { - const snapshot = `/run/sausage/extensions/${sha}`; - for (const path5 of ["/run/sausage", "/run/sausage/extensions"]) { - try { - await fs2.mkdir(path5); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - } - try { - await fs2.stat(snapshot); - } catch { - const staging = `${snapshot}.tmp`, archive = `${snapshot}.tar`; - try { - await fs2.mkdir(staging); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - if (await loaderGit("ls-tree", "--name-only", sha, "extensions")) { - await loaderGit("archive", "--format=tar", `--output=${archive}`, sha, "extensions"); - const result = await exec(["tar", "-xf", archive, "-C", staging], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(`tar: ${result.stderr || result.code}`); - await fs2.remove(archive); - } - await fs2.rename(staging, snapshot); - } + const pathRoot = await snapshot(copy2); const modules = []; - for (const entry2 of await fs2.list(`${snapshot}/extensions`).catch((error) => { + for (const entry2 of await fs2.list(`${pathRoot}/extensions`).catch((error) => { if (error.code === "not_found") return []; throw error; })) { if (entry2.kind !== "directory") continue; - const path5 = `${snapshot}/extensions/${entry2.name}/server.ts`; + const path5 = `${pathRoot}/extensions/${entry2.name}/server.ts`; try { await fs2.stat(path5); } catch (error) { @@ -78256,7 +78259,8 @@ async function loadExtensions(harness2, registry3, storage2, models2, context, b } const probe = createRegistry(); for (const extension of [...builtins, ...next.map((f) => f.extension)]) probe.install(extension); - for (const old2 of facets) registry3.uninstall(old2.extension); + const names2 = new Set(next.map((f) => f.extension.name)); + for (const old2 of facets) if (!names2.has(old2.extension.name)) registry3.uninstall(old2.extension); for (const extension of [...builtins, ...next.map((f) => f.extension)]) registry3.install(extension); const old = facets; facets = next; @@ -78269,7 +78273,8 @@ async function loadExtensions(harness2, registry3, storage2, models2, context, b facet.host.notice(`onOpen: ${String(error)}`); } } - state2.good = sha; + installed = true; + state2.good = copy2.hash; return null; } catch (error) { await dispose(next); @@ -78277,14 +78282,7 @@ async function loadExtensions(harness2, registry3, storage2, models2, context, b } }; const reload = async () => { - let tip; - try { - tip = await loaderGit("rev-parse", "refs/heads/main"); - } catch (error) { - state2.error = String(error); - ops2.op_extension_status(JSON.stringify(state2)); - return; - } + let good; try { settings = JSON.parse(await fs2.read(`${repo}/settings.json`)); } catch (error) { @@ -78296,24 +78294,39 @@ async function loadExtensions(harness2, registry3, storage2, models2, context, b `); } } - if (state2.current === tip && facets.length) return; - state2.current = tip; - state2.error = await load(tip); - if (state2.error && !facets.length && state2.good && state2.good !== tip) { - const failed = state2.error; - const fallback = await load(state2.good); - state2.error = fallback ? `${failed} -Last good: ${fallback}` : failed; + try { + const copy2 = await readCopy(); + if (state2.current === copy2.hash && installed) return; + state2.current = copy2.hash; + state2.error = await load(copy2); + if (!state2.error) good = copy2; + } catch (error) { + state2.error = String(error); } - await publish2(); + if (state2.error && !installed) { + const saved = await harness2.snapshot(ExtensionFiles, context); + if (saved?.hash) { + const failed = state2.error; + const fallback = await load(saved); + state2.error = fallback ? `${failed} +Last good: ${fallback}` : failed; + } + } + await publish2(good); }; const enqueue2 = () => queue2 = queue2.then(reload); - const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]); + const rid = ops2.op_watch([`${repo}/extensions`, `${repo}/settings.json`]); + let timer; await enqueue2(); const pending = (async () => { while (active) { - await ops2.op_watch_next(rid); - if (active) await enqueue2(); + const event = await ops2.op_watch_next(rid); + if (!event || !active) break; + clearTimeout(timer); + timer = setTimeout(() => { + timer = void 0; + void enqueue2(); + }, 75); } })(); return { reload: enqueue2, state: () => ({ ...state2 }), async mainAgent() { @@ -78328,7 +78341,7 @@ Last good: ${fallback}` : failed; return action(args, { host: facet.host, signal, conversationId, source: "ui" }); }, close: async () => { active = false; - commands.abort(); + clearTimeout(timer); Deno.core.close(rid); await pending; await queue2; diff --git a/host/js/chain-test-bundle.js b/host/js/chain-test-bundle.js index 27ef2dc..c3f0242 100644 --- a/host/js/chain-test-bundle.js +++ b/host/js/chain-test-bundle.js @@ -23713,6 +23713,7 @@ var ops2 = Deno.core.ops; var encoder3 = new TextEncoder(); var decoder2 = new TextDecoder(); var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) }); +var ExtensionFiles = defineDoc({ kind: "sausage.extensions.files", version: 1, scope: "session", history: "latest", checkpointWhen: () => false, initial: () => ({ hash: null, files: [] }) }); var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false }); async function file(kind, path2, bytes2 = []) { const result = await ops2.op_file(kind, path2, bytes2); diff --git a/host/js/core-ext-test-bundle.js b/host/js/core-ext-test-bundle.js index baf8894..8421574 100644 --- a/host/js/core-ext-test-bundle.js +++ b/host/js/core-ext-test-bundle.js @@ -38050,6 +38050,7 @@ var ops2 = Deno.core.ops; var encoder4 = new TextEncoder(); var decoder4 = new TextDecoder(); var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) }); +var ExtensionFiles = defineDoc({ kind: "sausage.extensions.files", version: 1, scope: "session", history: "latest", checkpointWhen: () => false, initial: () => ({ hash: null, files: [] }) }); var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false }); async function file(kind, path2, bytes2 = []) { const result = await ops2.op_file(kind, path2, bytes2); @@ -38122,28 +38123,54 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b let state = { ...await harness2.snapshot(ExtensionState, context) ?? { good: null, current: null, error: null } }; let settings = {}; let active = true; - const commands = new AbortController(); - const loaderGit = async (...args) => { - for (let attempt = 0; ; attempt++) { - try { - const result = await exec(["git", ...args], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(result.stderr || `git exited ${result.code}`); - return result.stdout.trim(); - } catch (error) { - const message = `git ${args.join(" ")}: ${String(error)}`; - state.error = message; - ops2.op_extension_status(JSON.stringify(state)); - if (attempt === 0 && !commands.signal.aborted && String(error).includes("command timed out")) continue; - throw new Error(message); + let installed = false; + let queue2 = Promise.resolve(); + const publish2 = async (copy2) => { + await harness2.commit(async (tx) => { + Object.assign(await tx.doc(ExtensionState), state); + if (copy2) Object.assign(await tx.doc(ExtensionFiles), copy2); + }, context); + ops2.op_extension_status(JSON.stringify(state)); + }; + const readCopy = async () => { + const files = []; + const walk = async (relative) => { + const path2 = `${repo}/extensions${relative}`; + const entries2 = await fs.list(path2).catch((error) => { + if (error.code === "not_found") return []; + throw error; + }); + entries2.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + for (const entry2 of entries2) { + const name = `${relative}/${entry2.name}`; + if (entry2.kind === "directory") await walk(name); + else files.push([name, await file("read", `${repo}/extensions${name}`)]); } + }; + await walk(""); + const digest = await crypto.subtle.digest("SHA-256", encoder4.encode(JSON.stringify(files))); + const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + return { hash, files }; + }; + const mkdir = async (path2) => { + try { + await fs.mkdir(path2); + } catch (error) { + if (error.code !== "already_exists") throw error; } }; - let queue2 = Promise.resolve(); - const publish2 = async () => { - await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context); - ops2.op_extension_status(JSON.stringify(state)); - await fs.write("/src/sausage/.git/sausage-last-good.tmp", state.good ?? ""); - await fs.rename("/src/sausage/.git/sausage-last-good.tmp", "/src/sausage/.git/sausage-last-good"); + const snapshot = async ({ hash, files }) => { + const base = `/run/sausage/extensions/${hash}`; + for (const path2 of ["/run/sausage", "/run/sausage/extensions", base, `${base}/extensions`]) await mkdir(path2); + for (const [name, bytes2] of files) { + let parent = `${base}/extensions`; + for (const part of name.slice(1).split("/").slice(0, -1)) { + parent += `/${part}`; + await mkdir(parent); + } + await file("write", `${base}/extensions${name}`, bytes2); + } + return base; }; const dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -38218,42 +38245,18 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b }; return { host, open, close }; }; - const load = async (sha) => { + const load = async (copy2) => { const next = []; const staged = /* @__PURE__ */ new Map(); try { - const snapshot = `/run/sausage/extensions/${sha}`; - for (const path2 of ["/run/sausage", "/run/sausage/extensions"]) { - try { - await fs.mkdir(path2); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - } - try { - await fs.stat(snapshot); - } catch { - const staging = `${snapshot}.tmp`, archive = `${snapshot}.tar`; - try { - await fs.mkdir(staging); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - if (await loaderGit("ls-tree", "--name-only", sha, "extensions")) { - await loaderGit("archive", "--format=tar", `--output=${archive}`, sha, "extensions"); - const result = await exec(["tar", "-xf", archive, "-C", staging], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(`tar: ${result.stderr || result.code}`); - await fs.remove(archive); - } - await fs.rename(staging, snapshot); - } + const pathRoot = await snapshot(copy2); const modules = []; - for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { + for (const entry2 of await fs.list(`${pathRoot}/extensions`).catch((error) => { if (error.code === "not_found") return []; throw error; })) { if (entry2.kind !== "directory") continue; - const path2 = `${snapshot}/extensions/${entry2.name}/server.ts`; + const path2 = `${pathRoot}/extensions/${entry2.name}/server.ts`; try { await fs.stat(path2); } catch (error) { @@ -38276,7 +38279,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } const probe = createRegistry(); for (const extension of [...builtins, ...next.map((f) => f.extension)]) probe.install(extension); - for (const old2 of facets) registry2.uninstall(old2.extension); + const names2 = new Set(next.map((f) => f.extension.name)); + for (const old2 of facets) if (!names2.has(old2.extension.name)) registry2.uninstall(old2.extension); for (const extension of [...builtins, ...next.map((f) => f.extension)]) registry2.install(extension); const old = facets; facets = next; @@ -38289,7 +38293,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b facet.host.notice(`onOpen: ${String(error)}`); } } - state.good = sha; + installed = true; + state.good = copy2.hash; return null; } catch (error) { await dispose(next); @@ -38297,14 +38302,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } }; const reload = async () => { - let tip; - try { - tip = await loaderGit("rev-parse", "refs/heads/main"); - } catch (error) { - state.error = String(error); - ops2.op_extension_status(JSON.stringify(state)); - return; - } + let good; try { settings = JSON.parse(await fs.read(`${repo}/settings.json`)); } catch (error) { @@ -38316,24 +38314,39 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b `); } } - if (state.current === tip && facets.length) return; - state.current = tip; - state.error = await load(tip); - if (state.error && !facets.length && state.good && state.good !== tip) { - const failed = state.error; - const fallback = await load(state.good); - state.error = fallback ? `${failed} -Last good: ${fallback}` : failed; + try { + const copy2 = await readCopy(); + if (state.current === copy2.hash && installed) return; + state.current = copy2.hash; + state.error = await load(copy2); + if (!state.error) good = copy2; + } catch (error) { + state.error = String(error); } - await publish2(); + if (state.error && !installed) { + const saved = await harness2.snapshot(ExtensionFiles, context); + if (saved?.hash) { + const failed = state.error; + const fallback = await load(saved); + state.error = fallback ? `${failed} +Last good: ${fallback}` : failed; + } + } + await publish2(good); }; const enqueue2 = () => queue2 = queue2.then(reload); - const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]); + const rid = ops2.op_watch([`${repo}/extensions`, `${repo}/settings.json`]); + let timer; await enqueue2(); const pending = (async () => { while (active) { - await ops2.op_watch_next(rid); - if (active) await enqueue2(); + const event = await ops2.op_watch_next(rid); + if (!event || !active) break; + clearTimeout(timer); + timer = setTimeout(() => { + timer = void 0; + void enqueue2(); + }, 75); } })(); return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() { @@ -38348,7 +38361,7 @@ Last good: ${fallback}` : failed; return action(args, { host: facet.host, signal, conversationId, source: "ui" }); }, close: async () => { active = false; - commands.abort(); + clearTimeout(timer); Deno.core.close(rid); await pending; await queue2; diff --git a/host/js/dream-test-bundle.js b/host/js/dream-test-bundle.js index 5d35701..ab3cbd7 100644 --- a/host/js/dream-test-bundle.js +++ b/host/js/dream-test-bundle.js @@ -37110,6 +37110,7 @@ var ops2 = Deno.core.ops; var encoder4 = new TextEncoder(); var decoder4 = new TextDecoder(); var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) }); +var ExtensionFiles = defineDoc({ kind: "sausage.extensions.files", version: 1, scope: "session", history: "latest", checkpointWhen: () => false, initial: () => ({ hash: null, files: [] }) }); var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false }); async function file(kind, path, bytes2 = []) { const result = await ops2.op_file(kind, path, bytes2); @@ -37182,28 +37183,54 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b let state = { ...await harness2.snapshot(ExtensionState, context) ?? { good: null, current: null, error: null } }; let settings = {}; let active = true; - const commands = new AbortController(); - const loaderGit = async (...args) => { - for (let attempt = 0; ; attempt++) { - try { - const result = await exec(["git", ...args], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(result.stderr || `git exited ${result.code}`); - return result.stdout.trim(); - } catch (error) { - const message = `git ${args.join(" ")}: ${String(error)}`; - state.error = message; - ops2.op_extension_status(JSON.stringify(state)); - if (attempt === 0 && !commands.signal.aborted && String(error).includes("command timed out")) continue; - throw new Error(message); + let installed = false; + let queue2 = Promise.resolve(); + const publish2 = async (copy2) => { + await harness2.commit(async (tx) => { + Object.assign(await tx.doc(ExtensionState), state); + if (copy2) Object.assign(await tx.doc(ExtensionFiles), copy2); + }, context); + ops2.op_extension_status(JSON.stringify(state)); + }; + const readCopy = async () => { + const files = []; + const walk = async (relative) => { + const path = `${repo}/extensions${relative}`; + const entries3 = await fs.list(path).catch((error) => { + if (error.code === "not_found") return []; + throw error; + }); + entries3.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + for (const entry2 of entries3) { + const name = `${relative}/${entry2.name}`; + if (entry2.kind === "directory") await walk(name); + else files.push([name, await file("read", `${repo}/extensions${name}`)]); } + }; + await walk(""); + const digest = await crypto.subtle.digest("SHA-256", encoder4.encode(JSON.stringify(files))); + const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + return { hash, files }; + }; + const mkdir = async (path) => { + try { + await fs.mkdir(path); + } catch (error) { + if (error.code !== "already_exists") throw error; } }; - let queue2 = Promise.resolve(); - const publish2 = async () => { - await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context); - ops2.op_extension_status(JSON.stringify(state)); - await fs.write("/src/sausage/.git/sausage-last-good.tmp", state.good ?? ""); - await fs.rename("/src/sausage/.git/sausage-last-good.tmp", "/src/sausage/.git/sausage-last-good"); + const snapshot = async ({ hash, files }) => { + const base2 = `/run/sausage/extensions/${hash}`; + for (const path of ["/run/sausage", "/run/sausage/extensions", base2, `${base2}/extensions`]) await mkdir(path); + for (const [name, bytes2] of files) { + let parent = `${base2}/extensions`; + for (const part of name.slice(1).split("/").slice(0, -1)) { + parent += `/${part}`; + await mkdir(parent); + } + await file("write", `${base2}/extensions${name}`, bytes2); + } + return base2; }; const dispose = async (entries3) => { for (const facet of entries3) for (const cleanup of facet.close) { @@ -37278,42 +37305,18 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b }; return { host, open, close }; }; - const load = async (sha) => { + const load = async (copy2) => { const next = []; const staged = /* @__PURE__ */ new Map(); try { - const snapshot = `/run/sausage/extensions/${sha}`; - for (const path of ["/run/sausage", "/run/sausage/extensions"]) { - try { - await fs.mkdir(path); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - } - try { - await fs.stat(snapshot); - } catch { - const staging = `${snapshot}.tmp`, archive = `${snapshot}.tar`; - try { - await fs.mkdir(staging); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - if (await loaderGit("ls-tree", "--name-only", sha, "extensions")) { - await loaderGit("archive", "--format=tar", `--output=${archive}`, sha, "extensions"); - const result = await exec(["tar", "-xf", archive, "-C", staging], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(`tar: ${result.stderr || result.code}`); - await fs.remove(archive); - } - await fs.rename(staging, snapshot); - } + const pathRoot = await snapshot(copy2); const modules = []; - for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { + for (const entry2 of await fs.list(`${pathRoot}/extensions`).catch((error) => { if (error.code === "not_found") return []; throw error; })) { if (entry2.kind !== "directory") continue; - const path = `${snapshot}/extensions/${entry2.name}/server.ts`; + const path = `${pathRoot}/extensions/${entry2.name}/server.ts`; try { await fs.stat(path); } catch (error) { @@ -37336,7 +37339,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } const probe = createRegistry(); for (const extension of [...builtins, ...next.map((f) => f.extension)]) probe.install(extension); - for (const old2 of facets) registry2.uninstall(old2.extension); + const names2 = new Set(next.map((f) => f.extension.name)); + for (const old2 of facets) if (!names2.has(old2.extension.name)) registry2.uninstall(old2.extension); for (const extension of [...builtins, ...next.map((f) => f.extension)]) registry2.install(extension); const old = facets; facets = next; @@ -37349,7 +37353,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b facet.host.notice(`onOpen: ${String(error)}`); } } - state.good = sha; + installed = true; + state.good = copy2.hash; return null; } catch (error) { await dispose(next); @@ -37357,14 +37362,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } }; const reload = async () => { - let tip; - try { - tip = await loaderGit("rev-parse", "refs/heads/main"); - } catch (error) { - state.error = String(error); - ops2.op_extension_status(JSON.stringify(state)); - return; - } + let good; try { settings = JSON.parse(await fs.read(`${repo}/settings.json`)); } catch (error) { @@ -37376,24 +37374,39 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b `); } } - if (state.current === tip && facets.length) return; - state.current = tip; - state.error = await load(tip); - if (state.error && !facets.length && state.good && state.good !== tip) { - const failed = state.error; - const fallback = await load(state.good); - state.error = fallback ? `${failed} -Last good: ${fallback}` : failed; + try { + const copy2 = await readCopy(); + if (state.current === copy2.hash && installed) return; + state.current = copy2.hash; + state.error = await load(copy2); + if (!state.error) good = copy2; + } catch (error) { + state.error = String(error); } - await publish2(); + if (state.error && !installed) { + const saved = await harness2.snapshot(ExtensionFiles, context); + if (saved?.hash) { + const failed = state.error; + const fallback = await load(saved); + state.error = fallback ? `${failed} +Last good: ${fallback}` : failed; + } + } + await publish2(good); }; const enqueue2 = () => queue2 = queue2.then(reload); - const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]); + const rid = ops2.op_watch([`${repo}/extensions`, `${repo}/settings.json`]); + let timer; await enqueue2(); const pending = (async () => { while (active) { - await ops2.op_watch_next(rid); - if (active) await enqueue2(); + const event = await ops2.op_watch_next(rid); + if (!event || !active) break; + clearTimeout(timer); + timer = setTimeout(() => { + timer = void 0; + void enqueue2(); + }, 75); } })(); return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() { @@ -37408,7 +37421,7 @@ Last good: ${fallback}` : failed; return action(args, { host: facet.host, signal, conversationId, source: "ui" }); }, close: async () => { active = false; - commands.abort(); + clearTimeout(timer); Deno.core.close(rid); await pending; await queue2; diff --git a/host/js/events-test-bundle.js b/host/js/events-test-bundle.js index 7a3ba0b..6b97757 100644 --- a/host/js/events-test-bundle.js +++ b/host/js/events-test-bundle.js @@ -38050,6 +38050,7 @@ var ops2 = Deno.core.ops; var encoder4 = new TextEncoder(); var decoder4 = new TextDecoder(); var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) }); +var ExtensionFiles = defineDoc({ kind: "sausage.extensions.files", version: 1, scope: "session", history: "latest", checkpointWhen: () => false, initial: () => ({ hash: null, files: [] }) }); var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false }); async function file(kind, path, bytes2 = []) { const result = await ops2.op_file(kind, path, bytes2); @@ -38122,28 +38123,54 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b let state = { ...await harness2.snapshot(ExtensionState, context) ?? { good: null, current: null, error: null } }; let settings = {}; let active = true; - const commands = new AbortController(); - const loaderGit = async (...args) => { - for (let attempt = 0; ; attempt++) { - try { - const result = await exec(["git", ...args], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(result.stderr || `git exited ${result.code}`); - return result.stdout.trim(); - } catch (error) { - const message = `git ${args.join(" ")}: ${String(error)}`; - state.error = message; - ops2.op_extension_status(JSON.stringify(state)); - if (attempt === 0 && !commands.signal.aborted && String(error).includes("command timed out")) continue; - throw new Error(message); + let installed = false; + let queue2 = Promise.resolve(); + const publish2 = async (copy2) => { + await harness2.commit(async (tx) => { + Object.assign(await tx.doc(ExtensionState), state); + if (copy2) Object.assign(await tx.doc(ExtensionFiles), copy2); + }, context); + ops2.op_extension_status(JSON.stringify(state)); + }; + const readCopy = async () => { + const files = []; + const walk = async (relative) => { + const path = `${repo}/extensions${relative}`; + const entries2 = await fs.list(path).catch((error) => { + if (error.code === "not_found") return []; + throw error; + }); + entries2.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + for (const entry2 of entries2) { + const name = `${relative}/${entry2.name}`; + if (entry2.kind === "directory") await walk(name); + else files.push([name, await file("read", `${repo}/extensions${name}`)]); } + }; + await walk(""); + const digest = await crypto.subtle.digest("SHA-256", encoder4.encode(JSON.stringify(files))); + const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + return { hash, files }; + }; + const mkdir = async (path) => { + try { + await fs.mkdir(path); + } catch (error) { + if (error.code !== "already_exists") throw error; } }; - let queue2 = Promise.resolve(); - const publish2 = async () => { - await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context); - ops2.op_extension_status(JSON.stringify(state)); - await fs.write("/src/sausage/.git/sausage-last-good.tmp", state.good ?? ""); - await fs.rename("/src/sausage/.git/sausage-last-good.tmp", "/src/sausage/.git/sausage-last-good"); + const snapshot = async ({ hash, files }) => { + const base = `/run/sausage/extensions/${hash}`; + for (const path of ["/run/sausage", "/run/sausage/extensions", base, `${base}/extensions`]) await mkdir(path); + for (const [name, bytes2] of files) { + let parent = `${base}/extensions`; + for (const part of name.slice(1).split("/").slice(0, -1)) { + parent += `/${part}`; + await mkdir(parent); + } + await file("write", `${base}/extensions${name}`, bytes2); + } + return base; }; const dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -38218,42 +38245,18 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b }; return { host, open: open2, close }; }; - const load = async (sha) => { + const load = async (copy2) => { const next = []; const staged = /* @__PURE__ */ new Map(); try { - const snapshot = `/run/sausage/extensions/${sha}`; - for (const path of ["/run/sausage", "/run/sausage/extensions"]) { - try { - await fs.mkdir(path); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - } - try { - await fs.stat(snapshot); - } catch { - const staging = `${snapshot}.tmp`, archive = `${snapshot}.tar`; - try { - await fs.mkdir(staging); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - if (await loaderGit("ls-tree", "--name-only", sha, "extensions")) { - await loaderGit("archive", "--format=tar", `--output=${archive}`, sha, "extensions"); - const result = await exec(["tar", "-xf", archive, "-C", staging], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(`tar: ${result.stderr || result.code}`); - await fs.remove(archive); - } - await fs.rename(staging, snapshot); - } + const pathRoot = await snapshot(copy2); const modules = []; - for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { + for (const entry2 of await fs.list(`${pathRoot}/extensions`).catch((error) => { if (error.code === "not_found") return []; throw error; })) { if (entry2.kind !== "directory") continue; - const path = `${snapshot}/extensions/${entry2.name}/server.ts`; + const path = `${pathRoot}/extensions/${entry2.name}/server.ts`; try { await fs.stat(path); } catch (error) { @@ -38276,7 +38279,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } const probe = createRegistry(); for (const extension of [...builtins, ...next.map((f) => f.extension)]) probe.install(extension); - for (const old2 of facets) registry2.uninstall(old2.extension); + const names2 = new Set(next.map((f) => f.extension.name)); + for (const old2 of facets) if (!names2.has(old2.extension.name)) registry2.uninstall(old2.extension); for (const extension of [...builtins, ...next.map((f) => f.extension)]) registry2.install(extension); const old = facets; facets = next; @@ -38289,7 +38293,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b facet.host.notice(`onOpen: ${String(error)}`); } } - state.good = sha; + installed = true; + state.good = copy2.hash; return null; } catch (error) { await dispose(next); @@ -38297,14 +38302,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } }; const reload = async () => { - let tip; - try { - tip = await loaderGit("rev-parse", "refs/heads/main"); - } catch (error) { - state.error = String(error); - ops2.op_extension_status(JSON.stringify(state)); - return; - } + let good; try { settings = JSON.parse(await fs.read(`${repo}/settings.json`)); } catch (error) { @@ -38316,24 +38314,39 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b `); } } - if (state.current === tip && facets.length) return; - state.current = tip; - state.error = await load(tip); - if (state.error && !facets.length && state.good && state.good !== tip) { - const failed = state.error; - const fallback = await load(state.good); - state.error = fallback ? `${failed} -Last good: ${fallback}` : failed; + try { + const copy2 = await readCopy(); + if (state.current === copy2.hash && installed) return; + state.current = copy2.hash; + state.error = await load(copy2); + if (!state.error) good = copy2; + } catch (error) { + state.error = String(error); } - await publish2(); + if (state.error && !installed) { + const saved = await harness2.snapshot(ExtensionFiles, context); + if (saved?.hash) { + const failed = state.error; + const fallback = await load(saved); + state.error = fallback ? `${failed} +Last good: ${fallback}` : failed; + } + } + await publish2(good); }; const enqueue2 = () => queue2 = queue2.then(reload); - const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]); + const rid = ops2.op_watch([`${repo}/extensions`, `${repo}/settings.json`]); + let timer; await enqueue2(); const pending = (async () => { while (active) { - await ops2.op_watch_next(rid); - if (active) await enqueue2(); + const event = await ops2.op_watch_next(rid); + if (!event || !active) break; + clearTimeout(timer); + timer = setTimeout(() => { + timer = void 0; + void enqueue2(); + }, 75); } })(); return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() { @@ -38348,7 +38361,7 @@ Last good: ${fallback}` : failed; return action(args, { host: facet.host, signal, conversationId, source: "ui" }); }, close: async () => { active = false; - commands.abort(); + clearTimeout(timer); Deno.core.close(rid); await pending; await queue2; diff --git a/host/js/extensions-test-bundle.js b/host/js/extensions-test-bundle.js index efe7b6f..0aecf62 100644 --- a/host/js/extensions-test-bundle.js +++ b/host/js/extensions-test-bundle.js @@ -591,12 +591,12 @@ function createSetupErrorMessage(model2, error, timestamp2) { timestamp: timestamp2 }; } -function hasResult(source) { - return typeof source.result === "function"; +function hasResult(source2) { + return typeof source2.result === "function"; } -async function forwardStream(target, source, observer, observeResult) { +async function forwardStream(target, source2, observer, observeResult) { let result; - for await (const event of source) { + for await (const event of source2) { target.push(event); observer?.onEvent?.(event); if (event.type === "done") @@ -604,8 +604,8 @@ async function forwardStream(target, source, observer, observeResult) { else if (event.type === "error") result = event.error; } - if (hasResult(source)) - result = await source.result(); + if (hasResult(source2)) + result = await source2.result(); target.end(result); observeResult(result); } @@ -1174,16 +1174,16 @@ var NVIDIA_NIM_HOST = "integrate.api.nvidia.com"; var OPENCODE_HOST = "opencode.ai"; function mergeClientIdentity(...sources) { let merged; - for (const source of sources) { - if (!source) + for (const source2 of sources) { + if (!source2) continue; merged = { ...merged }; - if (source.userAgent !== void 0) - merged.userAgent = source.userAgent; - if (source.attribution !== void 0) - merged.attribution = source.attribution; - if (source.version !== void 0) - merged.version = source.version; + if (source2.userAgent !== void 0) + merged.userAgent = source2.userAgent; + if (source2.attribution !== void 0) + merged.attribution = source2.attribution; + if (source2.version !== void 0) + merged.version = source2.version; } return merged; } @@ -3568,24 +3568,24 @@ function documentOf(uri) { } // node_modules/@earendil-works/pi-schema/dist/shared.js -function sharedRegex(source) { - const hit = regexes.get(source)?.deref(); +function sharedRegex(source2) { + const hit = regexes.get(source2)?.deref(); if (hit !== void 0) return hit; let re; try { - re = new RegExp(source, "u"); + re = new RegExp(source2, "u"); } catch { - re = new RegExp(source); + re = new RegExp(source2); } - regexes.set(source, new WeakRef(re)); - regexCleanup.register(re, source); + regexes.set(source2, new WeakRef(re)); + regexCleanup.register(re, source2); return re; } var regexes = /* @__PURE__ */ new Map(); -var regexCleanup = new FinalizationRegistry((source) => { - if (regexes.get(source)?.deref() === void 0) - regexes.delete(source); +var regexCleanup = new FinalizationRegistry((source2) => { + if (regexes.get(source2)?.deref() === void 0) + regexes.delete(source2); }); // node_modules/@earendil-works/pi-schema/dist/coerce.js @@ -8022,11 +8022,11 @@ var arrayMutators = { for (const [index, valueIndex] of overlay.baseOverrides ?? []) baseSnapshot.set(index, storedValue(node.context, valueIndex)); const insertSnapshots = /* @__PURE__ */ new Map(); - for (const [source, overrides] of overlay.insertOverrides ?? []) { + for (const [source2, overrides] of overlay.insertOverrides ?? []) { const snapshot = /* @__PURE__ */ new Map(); for (const [index, valueIndex] of overrides) snapshot.set(index, storedValue(node.context, valueIndex)); - insertSnapshots.set(source, snapshot); + insertSnapshots.set(source2, snapshot); } const generation = overlay.generation; order.sort((left, right) => { @@ -8088,19 +8088,19 @@ function sortTokenIndex(token, indices) { } function publicSortValue(node, token, sources, indices) { const overlay = arrayOverlay(node); - const source = sortTokenSource(token, sources); + const source2 = sortTokenSource(token, sources); const sourceIndex = sortTokenIndex(token, indices); - const overrideIndex = source === void 0 ? overlay.baseOverrides?.get(sourceIndex) : overlay.insertOverrides?.get(source)?.get(sourceIndex); - const value2 = overrideIndex !== void 0 ? storedValue(node.context, overrideIndex) : source === void 0 ? nodeBase(node)[sourceIndex] : storedValue(node.context, source.refs[sourceIndex]); + const overrideIndex = source2 === void 0 ? overlay.baseOverrides?.get(sourceIndex) : overlay.insertOverrides?.get(source2)?.get(sourceIndex); + const value2 = overrideIndex !== void 0 ? storedValue(node.context, overrideIndex) : source2 === void 0 ? nodeBase(node)[sourceIndex] : storedValue(node.context, source2.refs[sourceIndex]); if (!isContainer3(value2)) return value2; - return createNode(node.context, value2, node, source === void 0 ? 1 : 2, sourceIndex, source, source !== void 0 || (overlay.baseOverrides?.has(sourceIndex) ?? false)).proxy; + return createNode(node.context, value2, node, source2 === void 0 ? 1 : 2, sourceIndex, source2, source2 !== void 0 || (overlay.baseOverrides?.has(sourceIndex) ?? false)).proxy; } function restoreSortOverride(node, token, sources, indices, baseSnapshot, insertSnapshots) { const overlay = arrayOverlay(node); - const source = sortTokenSource(token, sources); + const source2 = sortTokenSource(token, sources); const sourceIndex = sortTokenIndex(token, indices); - if (source === void 0) { + if (source2 === void 0) { const existingIndex2 = overlay.baseOverrides?.get(sourceIndex); const snapshotValue2 = baseSnapshot.get(sourceIndex); if (snapshotValue2 !== void 0) { @@ -8115,13 +8115,13 @@ function restoreSortOverride(node, token, sources, indices, baseSnapshot, insert } return; } - let overrides = overlay.insertOverrides?.get(source); + let overrides = overlay.insertOverrides?.get(source2); const existingIndex = overrides?.get(sourceIndex); - const snapshotValue = insertSnapshots.get(source)?.get(sourceIndex); + const snapshotValue = insertSnapshots.get(source2)?.get(sourceIndex); if (snapshotValue !== void 0) { if (overrides === void 0) { overrides = /* @__PURE__ */ new Map(); - insertOverridesForWrite(overlay).set(source, overrides); + insertOverridesForWrite(overlay).set(source2, overrides); } if (existingIndex === void 0) overrides.set(sourceIndex, storeValue(node.context, snapshotValue)); @@ -8132,7 +8132,7 @@ function restoreSortOverride(node, token, sources, indices, baseSnapshot, insert releaseStoredValue(node.context, existingIndex); overrides?.delete(sourceIndex); if (overrides?.size === 0) - overlay.insertOverrides?.delete(source); + overlay.insertOverrides?.delete(source2); } } function sameSortTokenAt(overlay, logicalIndex, token, sources, indices) { @@ -8181,10 +8181,10 @@ function deduplicateArrayEntries(node) { function piecesFromSortOrder(order, sources, indices) { const pieces = []; for (const token of order) { - const source = token < 0 ? sources[-token - 1] : void 0; + const source2 = token < 0 ? sources[-token - 1] : void 0; const sourceIndex = token < 0 ? indices[-token - 1] : token; const previous = pieces.at(-1); - const sameSource = previous !== void 0 && (source === void 0 && previous.kind === "base" || source !== void 0 && previous.kind === "insert" && previous.source === source); + const sameSource = previous !== void 0 && (source2 === void 0 && previous.kind === "base" || source2 !== void 0 && previous.kind === "insert" && previous.source === source2); if (previous !== void 0 && sameSource) { if (previous.length === 1) { const step = sourceIndex - previous.start; @@ -8198,7 +8198,7 @@ function piecesFromSortOrder(order, sources, indices) { continue; } } - pieces.push(source === void 0 ? { kind: "base", start: sourceIndex, length: 1, step: 1 } : { kind: "insert", source, start: sourceIndex, length: 1, step: 1 }); + pieces.push(source2 === void 0 ? { kind: "base", start: sourceIndex, length: 1, step: 1 } : { kind: "insert", source: source2, start: sourceIndex, length: 1, step: 1 }); } return pieces; } @@ -8813,11 +8813,11 @@ function ensurePieceLocations(overlay) { for (const locations of overlay.insertLocations.values()) locations.sort(byMinimum); } -function findEntryIndex(overlay, kind, sourceIndex, source) { +function findEntryIndex(overlay, kind, sourceIndex, source2) { if (kind === 1 && !overlay.structural) return sourceIndex; ensurePieceLocations(overlay); - const locations = kind === 1 ? overlay.baseLocations : overlay.insertLocations.get(source); + const locations = kind === 1 ? overlay.baseLocations : overlay.insertLocations.get(source2); if (locations === void 0) return void 0; let low = 0; @@ -9860,8 +9860,8 @@ function sharesStructure(a, b) { } function computeDependents(changed, value2, validate2 = true) { const pending = /* @__PURE__ */ new Set(); - const visit3 = (source) => { - for (const derived of source.dependents) { + const visit3 = (source2) => { + for (const derived of source2.dependents) { if (pending.has(derived)) continue; pending.add(derived); @@ -9872,7 +9872,7 @@ function computeDependents(changed, value2, validate2 = true) { const values = /* @__PURE__ */ new Map([[changed, value2]]); const result = /* @__PURE__ */ new Map(); for (const derived of [...pending].sort((a, b) => a.seq - b.seq)) { - const next = derived.compute((source) => values.has(source) ? values.get(source) : source.value); + const next = derived.compute((source2) => values.has(source2) ? values.get(source2) : source2.value); if (validate2) derived.validate(next); values.set(derived, next); @@ -9888,10 +9888,10 @@ var Derived = class extends Source { #external = []; constructor(key, sources, fn, options) { super(key, options); - this.sources = sources.map((source) => { - if (!isReadable(source)) + this.sources = sources.map((source2) => { + if (!isReadable(source2)) throw new TypeError(`derive ${key}: sources must be readables`); - return source; + return source2; }); this.#fn = fn; this.#value = void 0; @@ -9915,18 +9915,18 @@ var Derived = class extends Source { * the sources (at install, never inside a factory). */ attach(precomputed) { - this.#value = precomputed?.has(this) ? precomputed.get(this) : this.compute((source) => source.value); - for (const source of this.sources) { - if (source instanceof Source) - source.dependents.add(this); + this.#value = precomputed?.has(this) ? precomputed.get(this) : this.compute((source2) => source2.value); + for (const source2 of this.sources) { + if (source2 instanceof Source) + source2.dependents.add(this); else - this.#external.push(source.subscribe(() => this.refresh())); + this.#external.push(source2.subscribe(() => this.refresh())); } } detach() { - for (const source of this.sources) - if (source instanceof Source) - source.dependents.delete(this); + for (const source2 of this.sources) + if (source2 instanceof Source) + source2.dependents.delete(this); for (const unsubscribe of this.#external.splice(0)) unsubscribe(); } @@ -9939,7 +9939,7 @@ var Derived = class extends Source { /** Recomputes and republishes, including dependents. Errors are reported (the source write already happened). */ refresh() { try { - const next = this.compute((source) => source.value); + const next = this.compute((source2) => source2.value); const dependents = computeDependents(this, next); holdPublications(() => { this.install(next); @@ -10177,18 +10177,18 @@ function linkedSignal(sources) { for (const remove of detach.splice(0)) remove(); }; - for (const source of sources) { - if (source.aborted) { + for (const source2 of sources) { + if (source2.aborted) { release(); - controller.abort(source.reason); + controller.abort(source2.reason); break; } const forward = () => { release(); - controller.abort(source.reason); + controller.abort(source2.reason); }; - source.addEventListener("abort", forward, { once: true }); - detach.push(() => source.removeEventListener("abort", forward)); + source2.addEventListener("abort", forward, { once: true }); + detach.push(() => source2.removeEventListener("abort", forward)); } return { signal: controller.signal, release }; } @@ -10275,11 +10275,11 @@ var MemberReadable = class { const view2 = ref.currentView(); if (view2 === void 0) throw notReady(`${ref.address}.${this.#name}`); - const source = this.#current(); - if (source === void 0) { + const source2 = this.#current(); + if (source2 === void 0) { throw new ChordError("not_in_variant", `${ref.address}: ${this.#name} is not a state member of variant ${variantOf(view2) ?? "(local)"}`); } - return source.value; + return source2.value; } /** Not hydrated while the reference is not live, or while its source (a forwarded remote member) is not. */ get [HYDRATION]() { @@ -10296,19 +10296,19 @@ var MemberReadable = class { } subscribe(listener) { this.#values.add(listener); - const source = this.#readable(); - if (source !== void 0) { - this.#seen = { value: source.value }; - this.#safe(() => listener(source.value)); + const source2 = this.#readable(); + if (source2 !== void 0) { + this.#seen = { value: source2.value }; + this.#safe(() => listener(source2.value)); } this.bind(); return () => this.#remove(this.#values, listener); } events(listener) { this.#events.add(listener); - const source = this.#readable(); - if (source !== void 0) { - const value2 = source.value; + const source2 = this.#readable(); + if (source2 !== void 0) { + const value2 = source2.value; this.#seen = { value: value2 }; this.#safe(() => listener({ type: "snap", value: value2 })); } else @@ -10321,16 +10321,16 @@ var MemberReadable = class { } /** (Re)subscribes upstream to the current source; a changed source is announced as a reset. */ bind() { - const source = this.#values.size + this.#events.size > 0 ? this.#current() : void 0; - if (source === this.#source) + const source2 = this.#values.size + this.#events.size > 0 ? this.#current() : void 0; + if (source2 === this.#source) return; this.#upstream(); this.#upstream = noop; - this.#source = source; - if (source === void 0) + this.#source = source2; + if (source2 === void 0) return; let first = true; - this.#upstream = source.events((event) => { + this.#upstream = source2.events((event) => { const isFirst = first; first = false; if (isFirst && this.#unseen.size === 0 && this.#seen !== void 0 && event.value === this.#seen.value) @@ -10369,8 +10369,8 @@ var MemberReadable = class { } /** The current source if its value can be read now (live, in the variant, hydrated). */ #readable() { - const source = this.#current(); - return source === void 0 || hydrationOf(source)?.hydrated === false ? void 0 : source; + const source2 = this.#current(); + return source2 === void 0 || hydrationOf(source2)?.hydrated === false ? void 0 : source2; } #remove(set2, listener) { set2.delete(listener); @@ -10846,13 +10846,13 @@ var ObjectImpl = class { refreshSchemas() { const schemas2 = /* @__PURE__ */ new Map(); for (const installed of this.implementations.values()) { - for (const [source, list] of memberSchemas(installed.service, installed.implementation)) { - schemas2.set(source, [...schemas2.get(source) ?? [], ...list]); + for (const [source2, list] of memberSchemas(installed.service, installed.implementation)) { + schemas2.set(source2, [...schemas2.get(source2) ?? [], ...list]); } } for (const decls of this.allDecls()) { - for (const source of [...decls.cells.values(), ...decls.derives.values()]) - source.schemas = schemas2.get(source) ?? []; + for (const source2 of [...decls.cells.values(), ...decls.derives.values()]) + source2.schemas = schemas2.get(source2) ?? []; } } allDecls() { @@ -10870,10 +10870,10 @@ var ObjectImpl = class { #listenSource(key, decl) { const resource = resourceOf(decl.source); const value2 = resource === void 0 ? decl.source : resource.value; - const source = decl.select === void 0 ? value2 : decl.select(value2); - if (typeof source?.subscribe !== "function") + const source2 = decl.select === void 0 ? value2 : decl.select(value2); + if (typeof source2?.subscribe !== "function") throw invalid("implementation", `listener ${key}: source has no subscribe()`); - return source; + return source2; } /** * Subscribes the listeners of `decls` without delivering: events are buffered until `activate`. A listener declared @@ -10892,7 +10892,7 @@ var ObjectImpl = class { kept.add(key); continue; } - const source = this.#listenSource(key, decl); + const source2 = this.#listenSource(key, decl); const entry2 = { origin, select: decl.select, @@ -10900,14 +10900,14 @@ var ObjectImpl = class { buffer: [] }; fresh.set(key, entry2); - if (isSource(source) || isReadableLike(source)) { + if (isSource(source2) || isReadableLike(source2)) { entry2.buffer = void 0; entry2.subscribe = () => { - entry2.unsubscribe = source.subscribe((event) => this.#deliver(key, event)); + entry2.unsubscribe = source2.subscribe((event) => this.#deliver(key, event)); }; continue; } - entry2.unsubscribe = source.subscribe((event) => { + entry2.unsubscribe = source2.subscribe((event) => { if (entry2.buffer !== void 0) entry2.buffer.push(event); else @@ -11257,10 +11257,10 @@ function memberSchemas(service, implementation) { return schemas2; const members = contract.description.variants[variantOf(implementation) ?? ""] ?? {}; for (const [name, description] of Object.entries(members)) { - const source = implementation[name]; - if (description.kind !== "state" || !isSource(source)) + const source2 = implementation[name]; + if (description.kind !== "state" || !isSource(source2)) continue; - schemas2.set(source, [...schemas2.get(source) ?? [], description.schema]); + schemas2.set(source2, [...schemas2.get(source2) ?? [], description.schema]); } return schemas2; } @@ -11378,7 +11378,7 @@ var SelfImpl = class { this.#decls.resources.set(name, resource); return new ResourceFacade(resource, this.#object.host); } - on(name, source, ...rest) { + on(name, source2, ...rest) { this.#unique(this.#decls.listeners, "listener", name); const [select, fn] = rest.length === 1 ? [void 0, rest[0]] : [rest[0], rest[1]]; if (typeof fn !== "function") @@ -11386,7 +11386,7 @@ var SelfImpl = class { if (select !== void 0 && typeof select !== "function") throw new TypeError(`listener ${name}: select must be a function`); this.#decls.listeners.set(name, { - source, + source: source2, select, fn }); @@ -11438,16 +11438,16 @@ function dryRun(candidate) { const values = new Map(migrated); const derives = [...run2.decls.derives].sort(([, a], [, b]) => a.seq - b.seq); for (const [name, derived] of derives) { - const value2 = derived.compute((source) => values.has(source) ? values.get(source) : candidateValue(source)); + const value2 = derived.compute((source2) => values.has(source2) ? values.get(source2) : candidateValue(source2)); for (const schema4 of exposed.get(derived) ?? []) assertValid("state", `${object.address}: derivation ${name}`, schema4, value2); values.set(derived, value2); } candidate.derived = values; } -function candidateValue(source) { +function candidateValue(source2) { try { - return source.value; + return source2.value; } catch (error) { if (isChordError(error, "not_ready") || isChordError(error, "not_in_variant")) return void 0; @@ -15560,8 +15560,8 @@ var AttachedReplicatedStateImpl = class { } } }; -function attachReplicatedStateSource(source, options = {}) { - const attachment = source.attach(); +function attachReplicatedStateSource(source2, options = {}) { + const attachment = source2.attach(); try { const state = new AttachedReplicatedStateImpl(attachment, options); state.activate(); @@ -16666,24 +16666,24 @@ async function collectCopies(storage2, scope, at, policy, childConversationId, c let cursor; do { const page3 = await storage2.scanDocuments({ scope, at }, SCAN_PAGE_SIZE, cursor, context); - for (const source of page3.items) { - if (source.scope.kind !== "conversation" || source.fork !== policy) + for (const source2 of page3.items) { + if (source2.scope.kind !== "conversation" || source2.fork !== policy) continue; const id = await storage2.mintId(); const identity = { id, - kind: source.kind, - ...source.key === void 0 ? {} : { key: source.key }, + kind: source2.kind, + ...source2.key === void 0 ? {} : { key: source2.key }, scope: { kind: "conversation", conversationId: childConversationId } }; - const record2 = source.history === "latest" ? { ...identity, history: "latest", fork: source.fork } : { ...identity, history: "rewindable", fork: source.fork }; + const record2 = source2.history === "latest" ? { ...identity, history: "latest", fork: source2.fork } : { ...identity, history: "rewindable", fork: source2.fork }; const copyAddress = addressId(record2); if (copiedAddresses.has(copyAddress)) { const member = record2.key === void 0 ? record2.kind : `${record2.kind}/${record2.key}`; throw new Error(`Fork selects multiple source documents for ${member}`); } copiedAddresses.add(copyAddress); - copies.push({ record: record2, source: { id: source.id, at } }); + copies.push({ record: record2, source: { id: source2.id, at } }); } cursor = page3.next; } while (cursor !== void 0); @@ -27922,11 +27922,11 @@ var clone = (value2) => { return value2; if (Array.isArray(value2)) return value2.map((item) => clone(item)); - const source = value2; + const source2 = value2; const nullPrototype = Object.getPrototypeOf(value2) === null; const result = nullPrototype ? /* @__PURE__ */ Object.create(null) : {}; - for (const key of Object.keys(source)) { - const copied = clone(source[key]); + for (const key of Object.keys(source2)) { + const copied = clone(source2[key]); if (!nullPrototype && key in result) { Object.defineProperty(result, key, { value: copied, @@ -29919,26 +29919,26 @@ var Directives = class _Directives { * @returns Resolved tag, which may also be the non-specific tag `'!'` or a * `'!local'` tag, or `null` if unresolvable. */ - tagName(source, onError) { - if (source === "!") + tagName(source2, onError) { + if (source2 === "!") return "!"; - if (source[0] !== "!") { - onError(`Not a valid tag: ${source}`); + if (source2[0] !== "!") { + onError(`Not a valid tag: ${source2}`); return null; } - if (source[1] === "<") { - const verbatim = source.slice(2, -1); + if (source2[1] === "<") { + const verbatim = source2.slice(2, -1); if (verbatim === "!" || verbatim === "!!") { - onError(`Verbatim tags aren't resolved, so ${source} is invalid.`); + onError(`Verbatim tags aren't resolved, so ${source2} is invalid.`); return null; } - if (source[source.length - 1] !== ">") + if (source2[source2.length - 1] !== ">") onError("Verbatim tags must end with a >"); return verbatim; } - const [, handle, suffix] = source.match(/^(.*!)([^!]*)$/s); + const [, handle, suffix] = source2.match(/^(.*!)([^!]*)$/s); if (!suffix) - onError(`The ${source} tag has no suffix`); + onError(`The ${source2} tag has no suffix`); const prefix = this.tags[handle]; if (prefix) { try { @@ -29949,8 +29949,8 @@ var Directives = class _Directives { } } if (handle === "!") - return source; - onError(`Could not resolve tag: ${source}`); + return source2; + onError(`Could not resolve tag: ${source2}`); return null; } /** @@ -30020,8 +30020,8 @@ function createNodeAnchors(doc, prefix) { const sourceObjects = /* @__PURE__ */ new Map(); let prevAnchors = null; return { - onAnchor: (source) => { - aliasObjects.push(source); + onAnchor: (source2) => { + aliasObjects.push(source2); prevAnchors ?? (prevAnchors = anchorNames(doc)); const anchor = findNewAnchor(prefix, prevAnchors); prevAnchors.add(anchor); @@ -30033,13 +30033,13 @@ function createNodeAnchors(doc, prefix) { * the nodes have been created. */ setAnchors: () => { - for (const source of aliasObjects) { - const ref = sourceObjects.get(source); + for (const source2 of aliasObjects) { + const ref = sourceObjects.get(source2); if (typeof ref === "object" && ref.anchor && (isScalar(ref.node) || isCollection(ref.node))) { ref.node.anchor = ref.anchor; } else { const error = new Error("Failed to resolve repeated object (this should not happen)"); - error.source = source; + error.source = source2; throw error; } } @@ -30149,9 +30149,9 @@ var NodeBase = class { // node_modules/yaml/browser/dist/nodes/Alias.js var Alias = class extends NodeBase { - constructor(source) { + constructor(source2) { super(ALIAS); - this.source = source; + this.source = source2; Object.defineProperty(this, "tag", { set() { throw new Error("Alias nodes cannot have tags"); @@ -30212,12 +30212,12 @@ var Alias = class extends NodeBase { toJSON(_arg, ctx) { if (!ctx) return { source: this.source }; - const source = this.resolve(ctx.doc, ctx); - if (!source) { + const source2 = this.resolve(ctx.doc, ctx); + if (!source2) { const msg = `Unresolved alias (the anchor must be set before the alias): ${this.source}`; throw new ReferenceError(msg); } - return ctx.anchors.get(source).res; + return ctx.anchors.get(source2).res; } toString(ctx, _onComment, _onChompKeep) { const src = `*${this.source}`; @@ -30235,8 +30235,8 @@ var Alias = class extends NodeBase { }; function getAliasCount(doc, node, anchors) { if (isAlias(node)) { - const source = node.resolve(doc); - const anchor = anchors && source && anchors.get(source); + const source2 = node.resolve(doc); + const anchor = anchors && source2 && anchors.get(source2); return anchor ? anchor.count * anchor.aliasCount : 0; } else if (isCollection(node)) { let count = 0; @@ -31140,21 +31140,21 @@ var merge3 = { }; var isMergeKey = (ctx, key) => (merge3.identify(key) || isScalar(key) && (!key.type || key.type === Scalar.PLAIN) && merge3.identify(key.value)) && ctx?.doc.schema.tags.some((tag) => tag.tag === merge3.tag && tag.default); function addMergeToJSMap(ctx, map3, value2) { - const source = resolveAliasValue(ctx, value2); - if (isSeq(source)) - for (const it of source.items) + const source2 = resolveAliasValue(ctx, value2); + if (isSeq(source2)) + for (const it of source2.items) mergeValue(ctx, map3, it); - else if (Array.isArray(source)) - for (const it of source) + else if (Array.isArray(source2)) + for (const it of source2) mergeValue(ctx, map3, it); else - mergeValue(ctx, map3, source); + mergeValue(ctx, map3, source2); } function mergeValue(ctx, map3, value2) { - const source = resolveAliasValue(ctx, value2); - if (!isMap(source)) + const source2 = resolveAliasValue(ctx, value2); + if (!isMap(source2)) throw new Error("Merge sources must be maps or map aliases"); - const srcMap = source.toJSON(null, ctx, Map); + const srcMap = source2.toJSON(null, ctx, Map); for (const [key, value3] of srcMap) { if (map3 instanceof Map) { if (!map3.has(key)) @@ -31683,7 +31683,7 @@ var nullTag = { tag: "tag:yaml.org,2002:null", test: /^(?:~|[Nn]ull|NULL)?$/, resolve: () => new Scalar(null), - stringify: ({ source }, ctx) => typeof source === "string" && nullTag.test.test(source) ? source : ctx.options.nullStr + stringify: ({ source: source2 }, ctx) => typeof source2 === "string" && nullTag.test.test(source2) ? source2 : ctx.options.nullStr }; // node_modules/yaml/browser/dist/schema/core/bool.js @@ -31693,11 +31693,11 @@ var boolTag = { tag: "tag:yaml.org,2002:bool", test: /^(?:[Tt]rue|TRUE|[Ff]alse|FALSE)$/, resolve: (str2) => new Scalar(str2[0] === "t" || str2[0] === "T"), - stringify({ source, value: value2 }, ctx) { - if (source && boolTag.test.test(source)) { - const sv = source[0] === "t" || source[0] === "T"; + stringify({ source: source2, value: value2 }, ctx) { + if (source2 && boolTag.test.test(source2)) { + const sv = source2[0] === "t" || source2[0] === "T"; if (value2 === sv) - return source; + return source2; } return value2 ? ctx.options.trueStr : ctx.options.falseStr; } @@ -32055,10 +32055,10 @@ var omap = { }; // node_modules/yaml/browser/dist/schema/yaml-1.1/bool.js -function boolStringify({ value: value2, source }, ctx) { +function boolStringify({ value: value2, source: source2 }, ctx) { const boolObj = value2 ? trueTag : falseTag; - if (source && boolObj.test.test(source)) - return source; + if (source2 && boolObj.test.test(source2)) + return source2; return value2 ? ctx.options.trueStr : ctx.options.falseStr; } var trueTag = { @@ -33209,7 +33209,7 @@ function resolveEnd(end, offset, reqSpace, onError) { let hasSpace = false; let sep = ""; for (const token of end) { - const { source, type } = token; + const { source: source2, type } = token; switch (type) { case "space": hasSpace = true; @@ -33217,7 +33217,7 @@ function resolveEnd(end, offset, reqSpace, onError) { case "comment": { if (reqSpace && !hasSpace) onError(token, "MISSING_CHAR", "Comments must be separated from other tokens by white space characters"); - const cb = source.substring(1) || " "; + const cb = source2.substring(1) || " "; if (!comment) comment = cb; else @@ -33227,13 +33227,13 @@ function resolveEnd(end, offset, reqSpace, onError) { } case "newline": if (comment) - sep += source; + sep += source2; hasSpace = true; break; default: onError(token, "UNEXPECTED_TOKEN", `Unexpected ${type} at node end`); } - offset += source.length; + offset += source2.length; } } return { comment, offset }; @@ -33581,13 +33581,13 @@ function parseBlockScalarHeader({ offset, props }, strict, onError) { onError(props[0], "IMPOSSIBLE", "Block scalar header not found"); return null; } - const { source } = props[0]; - const mode = source[0]; + const { source: source2 } = props[0]; + const mode = source2[0]; let indent = 0; let chomp = ""; let error = -1; - for (let i = 1; i < source.length; ++i) { - const ch = source[i]; + for (let i = 1; i < source2.length; ++i) { + const ch = source2[i]; if (!chomp && (ch === "-" || ch === "+")) chomp = ch; else { @@ -33599,10 +33599,10 @@ function parseBlockScalarHeader({ offset, props }, strict, onError) { } } if (error !== -1) - onError(error, "UNEXPECTED_TOKEN", `Block scalar header includes extra characters: ${source}`); + onError(error, "UNEXPECTED_TOKEN", `Block scalar header includes extra characters: ${source2}`); let hasSpace = false; let comment = ""; - let length = source.length; + let length = source2.length; for (let i = 1; i < props.length; ++i) { const token = props[i]; switch (token.type) { @@ -33636,8 +33636,8 @@ function parseBlockScalarHeader({ offset, props }, strict, onError) { } return { mode, indent, chomp, comment, length }; } -function splitLines(source) { - const split = source.split(/\n( *)/); +function splitLines(source2) { + const split = source2.split(/\n( *)/); const first = split[0]; const m = first.match(/^( *)/); const line0 = m?.[1] ? [m[1], first.slice(m[1].length)] : ["", first]; @@ -33649,22 +33649,22 @@ function splitLines(source) { // node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js function resolveFlowScalar(scalar, strict, onError) { - const { offset, type, source, end } = scalar; + const { offset, type, source: source2, end } = scalar; let _type; let value2; const _onError = (rel, code, msg) => onError(offset + rel, code, msg); switch (type) { case "scalar": _type = Scalar.PLAIN; - value2 = plainValue(source, _onError); + value2 = plainValue(source2, _onError); break; case "single-quoted-scalar": _type = Scalar.QUOTE_SINGLE; - value2 = singleQuotedValue(source, _onError); + value2 = singleQuotedValue(source2, _onError); break; case "double-quoted-scalar": _type = Scalar.QUOTE_DOUBLE; - value2 = doubleQuotedValue(source, _onError); + value2 = doubleQuotedValue(source2, _onError); break; /* istanbul ignore next should not happen */ default: @@ -33673,10 +33673,10 @@ function resolveFlowScalar(scalar, strict, onError) { value: "", type: null, comment: "", - range: [offset, offset + source.length, offset + source.length] + range: [offset, offset + source2.length, offset + source2.length] }; } - const valueEnd = offset + source.length; + const valueEnd = offset + source2.length; const re = resolveEnd(end, valueEnd, strict, onError); return { value: value2, @@ -33685,9 +33685,9 @@ function resolveFlowScalar(scalar, strict, onError) { range: [offset, valueEnd, re.offset] }; } -function plainValue(source, onError) { +function plainValue(source2, onError) { let badChar = ""; - switch (source[0]) { + switch (source2[0]) { /* istanbul ignore next should not happen */ case " ": badChar = "a tab character"; @@ -33700,29 +33700,29 @@ function plainValue(source, onError) { break; case "|": case ">": { - badChar = `block scalar indicator ${source[0]}`; + badChar = `block scalar indicator ${source2[0]}`; break; } case "@": case "`": { - badChar = `reserved character ${source[0]}`; + badChar = `reserved character ${source2[0]}`; break; } } if (badChar) onError(0, "BAD_SCALAR_START", `Plain value cannot start with ${badChar}`); - return unfoldLines(source); + return unfoldLines(source2); } -function singleQuotedValue(source, onError) { - if (source[source.length - 1] !== "'" || source.length === 1) - onError(source.length, "MISSING_CHAR", "Missing closing 'quote"); - return unfoldLines(source.slice(1, -1)).replace(/''/g, "'"); +function singleQuotedValue(source2, onError) { + if (source2[source2.length - 1] !== "'" || source2.length === 1) + onError(source2.length, "MISSING_CHAR", "Missing closing 'quote"); + return unfoldLines(source2.slice(1, -1)).replace(/''/g, "'"); } -function unfoldLines(source) { +function unfoldLines(source2) { const line = /(.*?)\r?\n/sy; - let match = line.exec(source); + let match = line.exec(source2); if (!match) - return source; + return source2; let trimEnd, trimBoth; try { trimEnd = new RegExp("(? wsStart ? source.slice(wsStart, i + 1) : ch; + next = source2[++i + 1]; + if (next !== "\n" && !(next === "\r" && source2[i + 2] === "\n")) + res += i > wsStart ? source2.slice(wsStart, i + 1) : ch; } else { res += ch; } } - if (source[source.length - 1] !== '"' || source.length === 1) - onError(source.length, "MISSING_CHAR", 'Missing closing "quote'); + if (source2[source2.length - 1] !== '"' || source2.length === 1) + onError(source2.length, "MISSING_CHAR", 'Missing closing "quote'); return res; } -function foldNewline(source, offset) { +function foldNewline(source2, offset) { let fold = ""; - let ch = source[offset + 1]; + let ch = source2[offset + 1]; while (ch === " " || ch === " " || ch === "\n" || ch === "\r") { - if (ch === "\r" && source[offset + 2] !== "\n") + if (ch === "\r" && source2[offset + 2] !== "\n") break; if (ch === "\n") fold += "\n"; offset += 1; - ch = source[offset + 1]; + ch = source2[offset + 1]; } if (!fold) fold = " "; @@ -33847,14 +33847,14 @@ var escapeCodes = { "\\": "\\", " ": " " }; -function parseCharCode(source, offset, length, onError) { - const cc = source.substr(offset, length); +function parseCharCode(source2, offset, length, onError) { + const cc = source2.substr(offset, length); const ok = cc.length === length && /^[0-9a-fA-F]+$/.test(cc); const code = ok ? parseInt(cc, 16) : NaN; try { return String.fromCodePoint(code); } catch { - const raw = source.substr(offset - 2, length + 2); + const raw = source2.substr(offset - 2, length + 2); onError(offset - 2, "BAD_DQ_ESCAPE", `Invalid escape sequence ${raw}`); return raw; } @@ -34034,13 +34034,13 @@ function composeEmptyNode(ctx, offset, before, pos, { spaceBefore, comment, anch } return node; } -function composeAlias({ options }, { offset, source, end }, onError) { - const alias = new Alias(source.substring(1)); +function composeAlias({ options }, { offset, source: source2, end }, onError) { + const alias = new Alias(source2.substring(1)); if (alias.source === "") onError(offset, "BAD_ALIAS", "Alias cannot be an empty string"); if (alias.source.endsWith(":")) - onError(offset + source.length - 1, "BAD_ALIAS", "Alias ending in : is ambiguous", true); - const valueEnd = offset + source.length; + onError(offset + source2.length - 1, "BAD_ALIAS", "Alias ending in : is ambiguous", true); + const valueEnd = offset + source2.length; const re = resolveEnd(end, valueEnd, options.strict, onError); alias.range = [offset, valueEnd, re.offset]; if (re.comment) @@ -34087,18 +34087,18 @@ function getErrorPos(src) { return [src, src + 1]; if (Array.isArray(src)) return src.length === 2 ? src : [src[0], src[1]]; - const { offset, source } = src; - return [offset, offset + (typeof source === "string" ? source.length : 1)]; + const { offset, source: source2 } = src; + return [offset, offset + (typeof source2 === "string" ? source2.length : 1)]; } function parsePrelude(prelude) { let comment = ""; let atComment = false; let afterEmptyLine = false; for (let i = 0; i < prelude.length; ++i) { - const source = prelude[i]; - switch (source[0]) { + const source2 = prelude[i]; + switch (source2[0]) { case "#": - comment += (comment === "" ? "" : afterEmptyLine ? "\n\n" : "\n") + (source.substring(1) || " "); + comment += (comment === "" ? "" : afterEmptyLine ? "\n\n" : "\n") + (source2.substring(1) || " "); atComment = true; afterEmptyLine = false; break; @@ -34122,8 +34122,8 @@ var Composer = class { this.prelude = []; this.errors = []; this.warnings = []; - this.onError = (source, code, message, warning) => { - const pos = getErrorPos(source); + this.onError = (source2, code, message, warning) => { + const pos = getErrorPos(source2); if (warning) this.warnings.push(new YAMLWarning(pos, code, message)); else @@ -34315,7 +34315,7 @@ function resolveAsScalar(token, strict = true, onError) { } function createScalarToken(value2, context) { const { implicitKey = false, indent, inFlow = false, offset = -1, type = "PLAIN" } = context; - const source = stringifyString({ type, value: value2 }, { + const source2 = stringifyString({ type, value: value2 }, { implicitKey, indent: indent > 0 ? " ".repeat(indent) : "", inFlow, @@ -34324,12 +34324,12 @@ function createScalarToken(value2, context) { const end = context.end ?? [ { type: "newline", offset: -1, indent, source: "\n" } ]; - switch (source[0]) { + switch (source2[0]) { case "|": case ">": { - const he = source.indexOf("\n"); - const head = source.substring(0, he); - const body = source.substring(he + 1) + "\n"; + const he = source2.indexOf("\n"); + const head = source2.substring(0, he); + const body = source2.substring(he + 1) + "\n"; const props = [ { type: "block-scalar-header", offset, indent, source: head } ]; @@ -34338,11 +34338,11 @@ function createScalarToken(value2, context) { return { type: "block-scalar", offset, indent, props, source: body }; } case '"': - return { type: "double-quoted-scalar", offset, indent, source, end }; + return { type: "double-quoted-scalar", offset, indent, source: source2, end }; case "'": - return { type: "single-quoted-scalar", offset, indent, source, end }; + return { type: "single-quoted-scalar", offset, indent, source: source2, end }; default: - return { type: "scalar", offset, indent, source, end }; + return { type: "scalar", offset, indent, source: source2, end }; } } function setScalarValue(token, value2, context = {}) { @@ -34368,31 +34368,31 @@ function setScalarValue(token, value2, context = {}) { default: type = "PLAIN"; } - const source = stringifyString({ type, value: value2 }, { + const source2 = stringifyString({ type, value: value2 }, { implicitKey: implicitKey || indent === null, indent: indent !== null && indent > 0 ? " ".repeat(indent) : "", inFlow, options: { blockQuote: true, lineWidth: -1 } }); - switch (source[0]) { + switch (source2[0]) { case "|": case ">": - setBlockScalarValue(token, source); + setBlockScalarValue(token, source2); break; case '"': - setFlowScalarValue(token, source, "double-quoted-scalar"); + setFlowScalarValue(token, source2, "double-quoted-scalar"); break; case "'": - setFlowScalarValue(token, source, "single-quoted-scalar"); + setFlowScalarValue(token, source2, "single-quoted-scalar"); break; default: - setFlowScalarValue(token, source, "scalar"); + setFlowScalarValue(token, source2, "scalar"); } } -function setBlockScalarValue(token, source) { - const he = source.indexOf("\n"); - const head = source.substring(0, he); - const body = source.substring(he + 1) + "\n"; +function setBlockScalarValue(token, source2) { + const he = source2.indexOf("\n"); + const head = source2.substring(0, he); + const body = source2.substring(he + 1) + "\n"; if (token.type === "block-scalar") { const header = token.props[0]; if (header.type !== "block-scalar-header") @@ -34427,31 +34427,31 @@ function addEndtoBlockProps(props, end) { } return false; } -function setFlowScalarValue(token, source, type) { +function setFlowScalarValue(token, source2, type) { switch (token.type) { case "scalar": case "double-quoted-scalar": case "single-quoted-scalar": token.type = type; - token.source = source; + token.source = source2; break; case "block-scalar": { const end = token.props.slice(1); - let oa = source.length; + let oa = source2.length; if (token.props[0].type === "block-scalar-header") oa -= token.props[0].source.length; for (const tok of end) tok.offset += oa; delete token.props; - Object.assign(token, { type, source, end }); + Object.assign(token, { type, source: source2, end }); break; } case "block-map": case "block-seq": { - const offset = token.offset + source.length; + const offset = token.offset + source2.length; const nl = { type: "newline", offset, indent: token.indent, source: "\n" }; delete token.items; - Object.assign(token, { type, source, end: [nl] }); + Object.assign(token, { type, source: source2, end: [nl] }); break; } default: { @@ -34460,7 +34460,7 @@ function setFlowScalarValue(token, source, type) { for (const key of Object.keys(token)) if (key !== "type" && key !== "offset") delete token[key]; - Object.assign(token, { type, indent, source, end }); + Object.assign(token, { type, indent, source: source2, end }); } } } @@ -34597,8 +34597,8 @@ function prettyToken(token) { return JSON.stringify(token); } } -function tokenType(source) { - switch (source) { +function tokenType(source2) { + switch (source2) { case BOM: return "byte-order-mark"; case DOCUMENT: @@ -34632,7 +34632,7 @@ function tokenType(source) { case ",": return "comma"; } - switch (source[0]) { + switch (source2[0]) { case " ": case " ": return "space"; @@ -34695,11 +34695,11 @@ var Lexer = class { * * @returns A generator of lexical tokens */ - *lex(source, incomplete = false) { - if (source) { - if (typeof source !== "string") + *lex(source2, incomplete = false) { + if (source2) { + if (typeof source2 !== "string") throw TypeError("source is not a string"); - this.buffer = this.buffer ? this.buffer + source : source; + this.buffer = this.buffer ? this.buffer + source2 : source2; this.lineEndPos = null; } this.atEnd = !incomplete; @@ -35329,12 +35329,12 @@ function getFirstKeyStartProps(prev) { } return prev.splice(i, prev.length); } -function arrayPushArray(target, source) { - if (source.length < 1e5) - Array.prototype.push.apply(target, source); +function arrayPushArray(target, source2) { + if (source2.length < 1e5) + Array.prototype.push.apply(target, source2); else - for (let i = 0; i < source.length; ++i) - target.push(source[i]); + for (let i = 0; i < source2.length; ++i) + target.push(source2[i]); } function fixFlowSeqItems(fc) { if (fc.start.type === "flow-seq-start") { @@ -35380,10 +35380,10 @@ var Parser = class { * * @returns A generator of tokens representing each directive, document, and other structure. */ - *parse(source, incomplete = false) { + *parse(source2, incomplete = false) { if (this.onNewLine && this.offset === 0) this.onNewLine(0); - for (const lexeme of this.lexer.lex(source, incomplete)) + for (const lexeme of this.lexer.lex(source2, incomplete)) yield* this.next(lexeme); if (!incomplete) yield* this.end(); @@ -35391,19 +35391,19 @@ var Parser = class { /** * Advance the parser by the `source` of one lexical token. */ - *next(source) { - this.source = source; + *next(source2) { + this.source = source2; if (this.atScalar) { this.atScalar = false; yield* this.step(); - this.offset += source.length; + this.offset += source2.length; return; } - const type = tokenType(source); + const type = tokenType(source2); if (!type) { - const message = `Not a YAML token: ${source}`; - yield* this.pop({ type: "error", offset: this.offset, message, source }); - this.offset += source.length; + const message = `Not a YAML token: ${source2}`; + yield* this.pop({ type: "error", offset: this.offset, message, source: source2 }); + this.offset += source2.length; } else if (type === "scalar") { this.atNewLine = false; this.atScalar = true; @@ -35416,17 +35416,17 @@ var Parser = class { this.atNewLine = true; this.indent = 0; if (this.onNewLine) - this.onNewLine(this.offset + source.length); + this.onNewLine(this.offset + source2.length); break; case "space": - if (this.atNewLine && source[0] === " ") - this.indent += source.length; + if (this.atNewLine && source2[0] === " ") + this.indent += source2.length; break; case "explicit-key-ind": case "map-value-ind": case "seq-item-ind": if (this.atNewLine) - this.indent += source.length; + this.indent += source2.length; break; case "doc-mode": case "flow-error-end": @@ -35434,7 +35434,7 @@ var Parser = class { default: this.atNewLine = false; } - this.offset += source.length; + this.offset += source2.length; } } /** Call at end of input to push out any remaining constructions */ @@ -36133,26 +36133,26 @@ function parseOptions(options) { const lineCounter = options.lineCounter || prettyErrors && new LineCounter() || null; return { lineCounter, prettyErrors }; } -function parseAllDocuments(source, options = {}) { +function parseAllDocuments(source2, options = {}) { const { lineCounter, prettyErrors } = parseOptions(options); const parser = new Parser(lineCounter?.addNewLine); const composer = new Composer(options); - const docs = Array.from(composer.compose(parser.parse(source))); + const docs = Array.from(composer.compose(parser.parse(source2))); if (prettyErrors && lineCounter) for (const doc of docs) { - doc.errors.forEach(prettifyError(source, lineCounter)); - doc.warnings.forEach(prettifyError(source, lineCounter)); + doc.errors.forEach(prettifyError(source2, lineCounter)); + doc.warnings.forEach(prettifyError(source2, lineCounter)); } if (docs.length > 0) return docs; return Object.assign([], { empty: true }, composer.streamInfo()); } -function parseDocument(source, options = {}) { +function parseDocument(source2, options = {}) { const { lineCounter, prettyErrors } = parseOptions(options); const parser = new Parser(lineCounter?.addNewLine); const composer = new Composer(options); let doc = null; - for (const _doc of composer.compose(parser.parse(source), true, source.length)) { + for (const _doc of composer.compose(parser.parse(source2), true, source2.length)) { if (!doc) doc = _doc; else if (doc.options.logLevel !== "silent") { @@ -36161,8 +36161,8 @@ function parseDocument(source, options = {}) { } } if (prettyErrors && lineCounter) { - doc.errors.forEach(prettifyError(source, lineCounter)); - doc.warnings.forEach(prettifyError(source, lineCounter)); + doc.errors.forEach(prettifyError(source2, lineCounter)); + doc.warnings.forEach(prettifyError(source2, lineCounter)); } return doc; } @@ -37281,24 +37281,24 @@ function createEditTool() { var PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]; var HEADER_BYTES = 32; var BLOCK_BYTES = 64 * 1024; -async function detectSupportedImageMimeTypeOf(source) { - const header = await source.read(0, HEADER_BYTES); +async function detectSupportedImageMimeTypeOf(source2) { + const header = await source2.read(0, HEADER_BYTES); if (!startsWith(header, PNG_SIGNATURE)) return detectSupportedImageMimeType(header); - return isPng(header) && !await isAnimatedPngOf(source) ? "image/png" : void 0; + return isPng(header) && !await isAnimatedPngOf(source2) ? "image/png" : void 0; } -async function isAnimatedPngOf(source) { +async function isAnimatedPngOf(source2) { let block = new Uint8Array(0); let blockStart = 0; const bytesAt = async (offset2, length) => { if (offset2 < blockStart || offset2 + length > blockStart + block.length) { blockStart = offset2; - block = await source.read(offset2, BLOCK_BYTES); + block = await source2.read(offset2, BLOCK_BYTES); } return block.subarray(offset2 - blockStart, offset2 - blockStart + length); }; let offset = PNG_SIGNATURE.length; - while (offset + 8 <= source.size) { + while (offset + 8 <= source2.size) { const chunkHeader = await bytesAt(offset, 8); const chunkLength = readUint32BE(chunkHeader, 0); if (startsWithAscii(chunkHeader, 4, "acTL")) @@ -37306,7 +37306,7 @@ async function isAnimatedPngOf(source) { if (startsWithAscii(chunkHeader, 4, "IDAT")) return false; const nextOffset = offset + 8 + chunkLength + 4; - if (nextOffset <= offset || nextOffset > source.size) + if (nextOffset <= offset || nextOffset > source2.size) return false; offset = nextOffset; } @@ -38050,6 +38050,7 @@ var ops2 = Deno.core.ops; var encoder4 = new TextEncoder(); var decoder4 = new TextDecoder(); var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) }); +var ExtensionFiles = defineDoc({ kind: "sausage.extensions.files", version: 1, scope: "session", history: "latest", checkpointWhen: () => false, initial: () => ({ hash: null, files: [] }) }); var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false }); async function file(kind, path, bytes2 = []) { const result = await ops2.op_file(kind, path, bytes2); @@ -38122,28 +38123,54 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b let state = { ...await harness2.snapshot(ExtensionState, context) ?? { good: null, current: null, error: null } }; let settings = {}; let active = true; - const commands = new AbortController(); - const loaderGit = async (...args) => { - for (let attempt = 0; ; attempt++) { - try { - const result = await exec(["git", ...args], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(result.stderr || `git exited ${result.code}`); - return result.stdout.trim(); - } catch (error) { - const message = `git ${args.join(" ")}: ${String(error)}`; - state.error = message; - ops2.op_extension_status(JSON.stringify(state)); - if (attempt === 0 && !commands.signal.aborted && String(error).includes("command timed out")) continue; - throw new Error(message); + let installed = false; + let queue2 = Promise.resolve(); + const publish2 = async (copy2) => { + await harness2.commit(async (tx) => { + Object.assign(await tx.doc(ExtensionState), state); + if (copy2) Object.assign(await tx.doc(ExtensionFiles), copy2); + }, context); + ops2.op_extension_status(JSON.stringify(state)); + }; + const readCopy = async () => { + const files = []; + const walk = async (relative) => { + const path = `${repo}/extensions${relative}`; + const entries2 = await fs.list(path).catch((error) => { + if (error.code === "not_found") return []; + throw error; + }); + entries2.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + for (const entry2 of entries2) { + const name = `${relative}/${entry2.name}`; + if (entry2.kind === "directory") await walk(name); + else files.push([name, await file("read", `${repo}/extensions${name}`)]); } + }; + await walk(""); + const digest = await crypto.subtle.digest("SHA-256", encoder4.encode(JSON.stringify(files))); + const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + return { hash, files }; + }; + const mkdir = async (path) => { + try { + await fs.mkdir(path); + } catch (error) { + if (error.code !== "already_exists") throw error; } }; - let queue2 = Promise.resolve(); - const publish2 = async () => { - await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context); - ops2.op_extension_status(JSON.stringify(state)); - await fs.write("/src/sausage/.git/sausage-last-good.tmp", state.good ?? ""); - await fs.rename("/src/sausage/.git/sausage-last-good.tmp", "/src/sausage/.git/sausage-last-good"); + const snapshot = async ({ hash, files }) => { + const base = `/run/sausage/extensions/${hash}`; + for (const path of ["/run/sausage", "/run/sausage/extensions", base, `${base}/extensions`]) await mkdir(path); + for (const [name, bytes2] of files) { + let parent = `${base}/extensions`; + for (const part of name.slice(1).split("/").slice(0, -1)) { + parent += `/${part}`; + await mkdir(parent); + } + await file("write", `${base}/extensions${name}`, bytes2); + } + return base; }; const dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -38218,42 +38245,18 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b }; return { host, open, close }; }; - const load = async (sha) => { + const load = async (copy2) => { const next = []; const staged = /* @__PURE__ */ new Map(); try { - const snapshot = `/run/sausage/extensions/${sha}`; - for (const path of ["/run/sausage", "/run/sausage/extensions"]) { - try { - await fs.mkdir(path); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - } - try { - await fs.stat(snapshot); - } catch { - const staging = `${snapshot}.tmp`, archive = `${snapshot}.tar`; - try { - await fs.mkdir(staging); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - if (await loaderGit("ls-tree", "--name-only", sha, "extensions")) { - await loaderGit("archive", "--format=tar", `--output=${archive}`, sha, "extensions"); - const result = await exec(["tar", "-xf", archive, "-C", staging], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(`tar: ${result.stderr || result.code}`); - await fs.remove(archive); - } - await fs.rename(staging, snapshot); - } + const pathRoot = await snapshot(copy2); const modules = []; - for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { + for (const entry2 of await fs.list(`${pathRoot}/extensions`).catch((error) => { if (error.code === "not_found") return []; throw error; })) { if (entry2.kind !== "directory") continue; - const path = `${snapshot}/extensions/${entry2.name}/server.ts`; + const path = `${pathRoot}/extensions/${entry2.name}/server.ts`; try { await fs.stat(path); } catch (error) { @@ -38276,7 +38279,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } const probe = createRegistry(); for (const extension of [...builtins, ...next.map((f) => f.extension)]) probe.install(extension); - for (const old2 of facets) registry2.uninstall(old2.extension); + const names2 = new Set(next.map((f) => f.extension.name)); + for (const old2 of facets) if (!names2.has(old2.extension.name)) registry2.uninstall(old2.extension); for (const extension of [...builtins, ...next.map((f) => f.extension)]) registry2.install(extension); const old = facets; facets = next; @@ -38289,7 +38293,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b facet.host.notice(`onOpen: ${String(error)}`); } } - state.good = sha; + installed = true; + state.good = copy2.hash; return null; } catch (error) { await dispose(next); @@ -38297,14 +38302,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } }; const reload = async () => { - let tip; - try { - tip = await loaderGit("rev-parse", "refs/heads/main"); - } catch (error) { - state.error = String(error); - ops2.op_extension_status(JSON.stringify(state)); - return; - } + let good; try { settings = JSON.parse(await fs.read(`${repo}/settings.json`)); } catch (error) { @@ -38316,24 +38314,39 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b `); } } - if (state.current === tip && facets.length) return; - state.current = tip; - state.error = await load(tip); - if (state.error && !facets.length && state.good && state.good !== tip) { - const failed = state.error; - const fallback = await load(state.good); - state.error = fallback ? `${failed} -Last good: ${fallback}` : failed; + try { + const copy2 = await readCopy(); + if (state.current === copy2.hash && installed) return; + state.current = copy2.hash; + state.error = await load(copy2); + if (!state.error) good = copy2; + } catch (error) { + state.error = String(error); } - await publish2(); + if (state.error && !installed) { + const saved = await harness2.snapshot(ExtensionFiles, context); + if (saved?.hash) { + const failed = state.error; + const fallback = await load(saved); + state.error = fallback ? `${failed} +Last good: ${fallback}` : failed; + } + } + await publish2(good); }; const enqueue2 = () => queue2 = queue2.then(reload); - const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]); + const rid = ops2.op_watch([`${repo}/extensions`, `${repo}/settings.json`]); + let timer; await enqueue2(); const pending = (async () => { while (active) { - await ops2.op_watch_next(rid); - if (active) await enqueue2(); + const event = await ops2.op_watch_next(rid); + if (!event || !active) break; + clearTimeout(timer); + timer = setTimeout(() => { + timer = void 0; + void enqueue2(); + }, 75); } })(); return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() { @@ -38348,7 +38361,7 @@ Last good: ${fallback}` : failed; return action(args, { host: facet.host, signal, conversationId, source: "ui" }); }, close: async () => { active = false; - commands.abort(); + clearTimeout(timer); Deno.core.close(rid); await pending; await queue2; @@ -38376,10 +38389,7 @@ var harness = await Harness.open(storage, { models, registry, settings: { get ex return registry.snapshot().installed(); } } }, BACKGROUND_CONTEXT); var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "faux", modelId: "faux-1" } } }); -globalThis.shared = { gate: Promise.withResolvers(), started: false, wait: false }; -var markerWrite = fs.write; -var markerBlocked = Promise.withResolvers(); -var markerRelease = Promise.withResolvers(); +globalThis.shared = { gate: Promise.withResolvers(), started: false, wait: false, loads: [] }; var loader = await loadExtensions(harness, registry, storage, models, BACKGROUND_CONTEXT); harness.resume(); var turn = async () => { @@ -38387,86 +38397,61 @@ var turn = async () => { const submission2 = await root.submit({ type: "input", content: "call demo" }, BACKGROUND_CONTEXT); await submission2.wait(BACKGROUND_CONTEXT); const entries2 = (await root.context(BACKGROUND_CONTEXT)).entries; - const result = entries2.filter((entry2) => entry2.kind === "pi.tool-result").at(-1); - return JSON.stringify(result); + return JSON.stringify(entries2.filter((entry2) => entry2.kind === "pi.tool-result").at(-1)); }; -try { - if (globalThis.extensionRestart) { - assert(loader.state().error, "Broken tip must report its error"); - assert(await fs.read("/src/sausage/.git/sausage-last-good") === globalThis.extensionGood, "Last good must survive"); - assert((await turn()).includes("result C"), "Restart must use C"); - } else { - const env = await exec(["env"], { cwd: "/", env: [["EXTENSION_OPTION", "yes"]], conversations: ["1"], timeout: 3 }); - assert(env.code === 0 && env.stdout.includes("EXTENSION_OPTION=yes"), "Exec environment"); - await fs.write("/src/sausage/temporary", "one"); - await fs.append("/src/sausage/temporary", "two"); - await fs.rename("/src/sausage/temporary", "/src/sausage/renamed"); - assert(await fs.read("/src/sausage/renamed") === "onetwo", "File mutation"); - assert((await fs.lstat("/src/sausage/renamed")).kind === "file", "lstat"); - await fs.remove("/src/sausage/renamed"); - const source = (version) => `import {defineExtension,defineTool} from '@earendil-works/pi-durable'; +var source = (version, paired = false) => `import {defineExtension,defineTool} from '@earendil-works/pi-durable'; import {obj} from '@earendil-works/pi-schema'; -export default host=>{if(defineTool!==globalThis.__sausage.modules['@earendil-works/pi-durable'].defineTool)throw new Error('Duplicate durable instance');host.implement({name:'demo.service'},{value:()=> '${version}'}); +${paired ? "import {value} from './value.ts';" : ""} +export default host=>{globalThis.shared.loads.push('${version}'); +${paired ? `if(value!=='${version}')throw new Error('Mixed file set');` : ""} +if(defineTool!==globalThis.__sausage.modules['@earendil-works/pi-durable'].defineTool)throw new Error('Duplicate durable instance'); +host.implement({name:'demo.service'},{value:()=> '${version}'}); globalThis.shared.handle=host.resource('handle',()=>host.need({name:'demo.service'})); return defineExtension({name:'demo',tools:[defineTool({name:'demo',description:'Demo',parameters:obj({}),execute:async()=>{if(globalThis.shared.wait){globalThis.shared.started=true;await globalThis.shared.gate.promise;}return {output:[{type:'text',text:'result ${version}'}]};}})]});};`; +try { + if (globalThis.extensionPhase !== "edits") { + const broken = globalThis.extensionPhase === "broken"; + assert(!!loader.state().error === broken, `Startup status: ${JSON.stringify(loader.state())}`); + if (broken) assert(loader.state().current !== loader.state().good, "Broken current hash must differ from last good"); + else assert(loader.state().current === loader.state().good, "Offline edit must become current"); + assert((await turn()).includes(`result ${globalThis.extensionExpected}`), "Startup must use the expected file copy"); + assert(!!JSON.parse(await fs.read("/proc/extensions")).error === broken, "Startup error must appear in proc"); + } else { await fs.mkdir("/src/sausage/extensions"); await fs.mkdir("/src/sausage/extensions/demo"); await fs.write("/src/sausage/extensions/demo/server.ts", source("A")); - await git("/src/sausage", "add", "."); - await git("/src/sausage", "commit", "-m", "A"); - const a = await git("/src/sausage", "rev-parse", "HEAD"); - await until2(() => loader.state().good === a); - assert((await turn()).includes("result A"), "Existing conversation must see A"); - const held2 = globalThis.shared.handle; - assert(held2.value() === "A", "A service"); - const retainedMethod = held2.value; + await until2(() => globalThis.shared.handle?.value() === "A"); + const a = loader.state().good; + assert(a?.length === 64, "Version must be a SHA-256 content hash"); + assert((await turn()).includes("result A"), "Existing conversation must see the file edit"); + const held2 = globalThis.shared.handle, retainedMethod = held2.value; await fs.write("/src/sausage/extensions/demo/server.ts", "syntax !!! error"); - await git("/src/sausage", "add", "."); - await git("/src/sausage", "commit", "-m", "B"); - const b = await git("/src/sausage", "rev-parse", "HEAD"); - await until2(() => loader.state().current === b && !!loader.state().error); - assert(loader.state().good === a, "Broken B must keep A"); - assert(JSON.parse(await fs.read("/proc/extensions")).error, "Status must report B"); + await until2(() => loader.state().current !== a && !!loader.state().error); + assert(loader.state().good === a, "Broken edit must keep A"); + assert(JSON.parse(await fs.read("/proc/extensions")).error, "Status must report the broken edit"); assert(held2.value() === "A", "A service remains"); - assert((await turn()).includes("result A"), "A tool remains after broken B"); + assert((await turn()).includes("result A"), "A tool remains after the broken edit"); globalThis.shared.wait = true; const running = turn(); await until2(() => globalThis.shared.started); - await fs.write("/src/sausage/extensions/demo/server.ts", source("C")); - await git("/src/sausage", "add", "."); - await git("/src/sausage", "commit", "-m", "C"); - const c = await git("/src/sausage", "rev-parse", "HEAD"); - await until2(() => loader.state().good === c); - assert(held2.value() === "C", "A need handle must resolve C"); + await Promise.all([ + fs.write("/src/sausage/extensions/demo/server.ts", source("C", true)), + fs.write("/src/sausage/extensions/demo/value.ts", "export const value='C';"), + fs.write("/src/sausage/extensions/demo/other.ts", "export const unused=true;") + ]); + await until2(() => held2.value() === "C"); + assert(JSON.stringify(globalThis.shared.loads) === '["A","C"]', "A burst must load exactly one complete set"); assert(retainedMethod() === "C", "A retained method must resolve C"); globalThis.shared.wait = false; globalThis.shared.gate.resolve(); assert((await running).includes("result A"), "Old call must finish on A"); assert((await turn()).includes("result C"), "Next call must use C"); + const c = loader.state().good; await loader.reload(); - fs.write = async (path, text) => { - if (path.includes("sausage-last-good")) { - fs.write = markerWrite; - await markerWrite(path, ""); - markerBlocked.resolve(); - await markerRelease.promise; - } - return markerWrite(path, text); - }; - await git("/src/sausage", "update-ref", "refs/heads/main", b); - await until2(() => loader.state().current === b && !!loader.state().error); - await markerBlocked.promise; - const reverted = await exec(["extensions-revert"], { cwd: "/" }); - markerRelease.resolve(); - assert(reverted.code === 0, `Revert command: ${JSON.stringify(reverted)}`); - await until2(() => loader.state().current === c && !loader.state().error); - await git("/src/sausage", "update-ref", "refs/heads/main", b); - await until2(() => loader.state().current === b && !!loader.state().error); - await fs.write("/src/sausage/good.txt", c); + assert(loader.state().good === c && globalThis.shared.loads.length === 2, "Unchanged files must not reload"); + await fs.write("/src/sausage/offline-source.txt", source("D")); } } finally { - markerRelease.resolve(); - fs.write = markerWrite; globalThis.shared.gate.resolve(); await loader.close(); await harness.close(BACKGROUND_CONTEXT); diff --git a/host/js/extensions-test.ts b/host/js/extensions-test.ts index 0f20dfd..453f4f5 100644 --- a/host/js/extensions-test.ts +++ b/host/js/extensions-test.ts @@ -1,18 +1,17 @@ -// A world git repo exercises commit reloads, old calls, registry defaults, and recovery. +// File edits replace complete sets. Running calls and host-side recovery retain valid code. import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'; import { createModels } from '@earendil-works/pi-ai/models'; import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux'; import { Harness, createRegistry } from '@earendil-works/pi-durable'; import { openStorage } from './sqlite'; -import { loadExtensions, fs, git, exec } from './extensions'; +import { loadExtensions, fs } from './extensions'; const assert = (value:any,message:string)=>{if(!value)throw new Error(message);}; const until = async (test:()=>Promise|boolean)=>{const deadline=Date.now()+15000;while(!await test()){if(Date.now()>deadline)throw new Error('Reload deadline');await new Promise(r=>setTimeout(r,0));}}; const models=createModels(), faux=fauxProvider({tokensPerSecond:100000});models.setProvider(faux.provider); const registry=createRegistry(), storage=await openStorage(globalThis.storagePath); const harness=await Harness.open(storage,{models,registry,settings:{get extensions(){return registry.snapshot().installed();}}},context); const root=await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'}}}); -globalThis.shared={gate:Promise.withResolvers(),started:false,wait:false}; -const markerWrite=fs.write, markerBlocked=Promise.withResolvers(), markerRelease=Promise.withResolvers(); +globalThis.shared={gate:Promise.withResolvers(),started:false,wait:false,loads:[]}; const loader=await loadExtensions(harness,registry,storage,models,context); harness.resume(); const turn = async()=> { @@ -20,72 +19,56 @@ const turn = async()=> { const submission=await root.submit({type:'input',content:'call demo'},context); await submission.wait(context); const entries=(await root.context(context)).entries; - const result=entries.filter((entry:any)=>entry.kind==='pi.tool-result').at(-1); - return JSON.stringify(result); + return JSON.stringify(entries.filter((entry:any)=>entry.kind==='pi.tool-result').at(-1)); }; -try { - if(globalThis.extensionRestart) { - assert(loader.state().error,'Broken tip must report its error'); - assert(await fs.read('/src/sausage/.git/sausage-last-good')===globalThis.extensionGood,'Last good must survive'); - assert((await turn()).includes('result C'),'Restart must use C'); - } else { - const env = await exec(['env'],{cwd:'/',env:[['EXTENSION_OPTION','yes']],conversations:['1'],timeout:3}); - assert(env.code===0 && env.stdout.includes('EXTENSION_OPTION=yes'),'Exec environment'); - await fs.write('/src/sausage/temporary','one');await fs.append('/src/sausage/temporary','two'); - await fs.rename('/src/sausage/temporary','/src/sausage/renamed'); - assert(await fs.read('/src/sausage/renamed')==='onetwo','File mutation'); - assert((await fs.lstat('/src/sausage/renamed')).kind==='file','lstat');await fs.remove('/src/sausage/renamed'); - const source=(version:string)=>`import {defineExtension,defineTool} from '@earendil-works/pi-durable'; +const source=(version:string,paired=false)=>`import {defineExtension,defineTool} from '@earendil-works/pi-durable'; import {obj} from '@earendil-works/pi-schema'; -export default host=>{if(defineTool!==globalThis.__sausage.modules['@earendil-works/pi-durable'].defineTool)throw new Error('Duplicate durable instance');host.implement({name:'demo.service'},{value:()=> '${version}'}); +${paired?"import {value} from './value.ts';":""} +export default host=>{globalThis.shared.loads.push('${version}'); +${paired?`if(value!=='${version}')throw new Error('Mixed file set');`:''} +if(defineTool!==globalThis.__sausage.modules['@earendil-works/pi-durable'].defineTool)throw new Error('Duplicate durable instance'); +host.implement({name:'demo.service'},{value:()=> '${version}'}); globalThis.shared.handle=host.resource('handle',()=>host.need({name:'demo.service'})); return defineExtension({name:'demo',tools:[defineTool({name:'demo',description:'Demo',parameters:obj({}),execute:async()=>{if(globalThis.shared.wait){globalThis.shared.started=true;await globalThis.shared.gate.promise;}return {output:[{type:'text',text:'result ${version}'}]};}})]});};`; +try { + if(globalThis.extensionPhase!=='edits') { + const broken=globalThis.extensionPhase==='broken'; + assert(!!loader.state().error===broken,`Startup status: ${JSON.stringify(loader.state())}`); + if(broken)assert(loader.state().current!==loader.state().good,'Broken current hash must differ from last good'); + else assert(loader.state().current===loader.state().good,'Offline edit must become current'); + assert((await turn()).includes(`result ${globalThis.extensionExpected}`),'Startup must use the expected file copy'); + assert(!!JSON.parse(await fs.read('/proc/extensions')).error===broken,'Startup error must appear in proc'); + } else { await fs.mkdir('/src/sausage/extensions');await fs.mkdir('/src/sausage/extensions/demo'); await fs.write('/src/sausage/extensions/demo/server.ts',source('A')); - await git('/src/sausage','add','.');await git('/src/sausage','commit','-m','A'); - const a=await git('/src/sausage','rev-parse','HEAD'); - await until(()=>loader.state().good===a); - assert((await turn()).includes('result A'),'Existing conversation must see A'); - const held=globalThis.shared.handle; - assert(held.value()==='A','A service'); - const retainedMethod=held.value; + await until(()=>globalThis.shared.handle?.value()==='A'); + const a=loader.state().good; + assert(a?.length===64,'Version must be a SHA-256 content hash'); + assert((await turn()).includes('result A'),'Existing conversation must see the file edit'); + const held=globalThis.shared.handle, retainedMethod=held.value; await fs.write('/src/sausage/extensions/demo/server.ts','syntax !!! error'); - await git('/src/sausage','add','.');await git('/src/sausage','commit','-m','B'); - const b=await git('/src/sausage','rev-parse','HEAD'); - await until(()=>loader.state().current===b && !!loader.state().error); - assert(loader.state().good===a,'Broken B must keep A'); - assert(JSON.parse(await fs.read('/proc/extensions')).error,'Status must report B'); + await until(()=>loader.state().current!==a && !!loader.state().error); + assert(loader.state().good===a,'Broken edit must keep A'); + assert(JSON.parse(await fs.read('/proc/extensions')).error,'Status must report the broken edit'); assert(held.value()==='A','A service remains'); - assert((await turn()).includes('result A'),'A tool remains after broken B'); + assert((await turn()).includes('result A'),'A tool remains after the broken edit'); globalThis.shared.wait=true; const running=turn();await until(()=>globalThis.shared.started); - await fs.write('/src/sausage/extensions/demo/server.ts',source('C')); - await git('/src/sausage','add','.');await git('/src/sausage','commit','-m','C'); - const c=await git('/src/sausage','rev-parse','HEAD'); - await until(()=>loader.state().good===c); - assert(held.value()==='C','A need handle must resolve C'); + // The server and its new dependency must install once, as one valid set. + await Promise.all([ + fs.write('/src/sausage/extensions/demo/server.ts',source('C',true)), + fs.write('/src/sausage/extensions/demo/value.ts',"export const value='C';"), + fs.write('/src/sausage/extensions/demo/other.ts','export const unused=true;'), + ]); + await until(()=>held.value()==='C'); + assert(JSON.stringify(globalThis.shared.loads)==='["A","C"]','A burst must load exactly one complete set'); assert(retainedMethod()==='C','A retained method must resolve C'); globalThis.shared.wait=false;globalThis.shared.gate.resolve(); assert((await running).includes('result A'),'Old call must finish on A'); assert((await turn()).includes('result C'),'Next call must use C'); + const c=loader.state().good; await loader.reload(); - // Suspend a marker write after truncation, as the asynchronous VFS write can do under load. - fs.write=async(path,text)=>{ - if(path.includes('sausage-last-good')) { - fs.write=markerWrite; - await markerWrite(path,'');markerBlocked.resolve();await markerRelease.promise; - } - return markerWrite(path,text); - }; - await git('/src/sausage','update-ref','refs/heads/main',b); - await until(()=>loader.state().current===b && !!loader.state().error); - await markerBlocked.promise; - const reverted=await exec(['extensions-revert'],{cwd:'/'}); - markerRelease.resolve(); - assert(reverted.code===0,`Revert command: ${JSON.stringify(reverted)}`); - await until(()=>loader.state().current===c && !loader.state().error); - await git('/src/sausage','update-ref','refs/heads/main',b); - await until(()=>loader.state().current===b && !!loader.state().error); - await fs.write('/src/sausage/good.txt',c); + assert(loader.state().good===c && globalThis.shared.loads.length===2,'Unchanged files must not reload'); + await fs.write('/src/sausage/offline-source.txt',source('D')); } -} finally {markerRelease.resolve();fs.write=markerWrite;globalThis.shared.gate.resolve();await loader.close();await harness.close(context);} +} finally {globalThis.shared.gate.resolve();await loader.close();await harness.close(context);} diff --git a/host/js/extensions.ts b/host/js/extensions.ts index 69caa65..cb2cfc6 100644 --- a/host/js/extensions.ts +++ b/host/js/extensions.ts @@ -13,6 +13,8 @@ const ops = Deno.core.ops; const encoder = new TextEncoder(); const decoder = new TextDecoder(); export const ExtensionState = durable.defineDoc({ kind: 'sausage.extensions', version: 1, scope: 'session', checkpointWhen: () => false, initial: () => ({good:null, current:null, error:null}) }); +// The host database retains the last valid bytes independently of editable world files. +const ExtensionFiles = durable.defineDoc({kind:'sausage.extensions.files',version:1,scope:'session',history:'latest',checkpointWhen:()=>false,initial:()=>({hash:null,files:[]})}); export const Metadata = durable.defineDoc({ kind: 'sausage.metadata', version: 1, scope: 'conversation', history:'latest', fork:'current', initial:()=>({label:'',title:''}), checkpointWhen:()=>false }); export type Host = events.EventHost & { harness(): durable.Harness; storage: Pick; @@ -84,29 +86,43 @@ export async function loadExtensions(harness:any, registry:any, storage:any, mod let state = {...(await harness.snapshot(ExtensionState,context) ?? {good:null,current:null,error:null})}; let settings:any = {}; let active = true; - const commands = new AbortController(); - // Loader commands have no wall-clock deadline. close() cancels them, even under machine load. - const loaderGit = async (...args:string[]) => { - for (let attempt=0;;attempt++) { - try { - const result = await exec(['git',...args],{cwd:repo,signal:commands.signal}); - if(result.code)throw new Error(result.stderr || `git exited ${result.code}`); - return result.stdout.trim(); - } catch(error) { - const message = `git ${args.join(' ')}: ${String(error)}`; - state.error=message; - ops.op_extension_status(JSON.stringify(state)); - if(attempt===0 && !commands.signal.aborted && String(error).includes('command timed out'))continue; - throw new Error(message); - } - } - }; + let installed=false; let queue = Promise.resolve(); - const publish = async () => { - await harness.commit(async (tx:any)=>Object.assign(await tx.doc(ExtensionState),state),context); + type Copy = {hash:string;files:[string,number[]][]}; + const publish = async (copy?:Copy) => { + await harness.commit(async (tx:any)=>{ + Object.assign(await tx.doc(ExtensionState),state); + if(copy)Object.assign(await tx.doc(ExtensionFiles),copy); + },context); ops.op_extension_status(JSON.stringify(state)); - await fs.write('/src/sausage/.git/sausage-last-good.tmp',state.good ?? ''); - await fs.rename('/src/sausage/.git/sausage-last-good.tmp','/src/sausage/.git/sausage-last-good'); + }; + const readCopy = async ():Promise => { + const files:[string,number[]][]=[]; + const walk = async (relative:string) => { + const path=`${repo}/extensions${relative}`; + const entries=await fs.list(path).catch(error=>{if(error.code==='not_found')return [];throw error;}); + entries.sort((a:any,b:any)=>a.nameb.name?1:0); + for(const entry of entries) { + const name=`${relative}/${entry.name}`; + if(entry.kind==='directory')await walk(name); + else files.push([name,await file('read',`${repo}/extensions${name}`)]); + } + }; + await walk(''); + const digest=await crypto.subtle.digest('SHA-256',encoder.encode(JSON.stringify(files))); + const hash=Array.from(new Uint8Array(digest),byte=>byte.toString(16).padStart(2,'0')).join(''); + return {hash,files}; + }; + const mkdir = async (path:string) => {try {await fs.mkdir(path);} catch(error) {if((error as any).code!=='already_exists')throw error;}}; + const snapshot = async ({hash,files}:Copy) => { + const base=`/run/sausage/extensions/${hash}`; + for(const path of ['/run/sausage','/run/sausage/extensions',base,`${base}/extensions`])await mkdir(path); + for(const [name,bytes] of files) { + let parent=`${base}/extensions`; + for(const part of name.slice(1).split('/').slice(0,-1)) {parent+=`/${part}`;await mkdir(parent);} + await file('write',`${base}/extensions${name}`,bytes); + } + return base; }; const dispose = async (entries:any[]) => { for(const facet of entries) for(const cleanup of facet.close) { try {await cleanup();} catch(error) {ops.op_output(`Extension cleanup: ${String(error)}\n`);} } }; const scope = (name:string, staged:Map) => { @@ -147,28 +163,15 @@ export async function loadExtensions(harness:any, registry:any, storage:any, mod }; return {host,open,close}; }; - const load = async (sha:string) => { + const load = async (copy:Copy) => { const next:any[] = []; const staged = new Map(); try { - const snapshot = `/run/sausage/extensions/${sha}`; - for(const path of ['/run/sausage','/run/sausage/extensions']) {try {await fs.mkdir(path);} catch(error) {if((error as any).code!=='already_exists')throw error;} } - try {await fs.stat(snapshot);} catch { - // Only publish a complete subtree. A cancelled extraction can leave its staging directory. - const staging=`${snapshot}.tmp`, archive=`${snapshot}.tar`; - try {await fs.mkdir(staging);} catch(error) {if((error as any).code!=='already_exists')throw error;} - if(await loaderGit('ls-tree','--name-only',sha,'extensions')) { - await loaderGit('archive','--format=tar',`--output=${archive}`,sha,'extensions'); - const result=await exec(['tar','-xf',archive,'-C',staging],{cwd:repo,signal:commands.signal}); - if(result.code)throw new Error(`tar: ${result.stderr || result.code}`); - await fs.remove(archive); - } - await fs.rename(staging,snapshot); - } + const pathRoot = await snapshot(copy); const modules:any[] = []; - for(const entry of await fs.list(`${snapshot}/extensions`).catch(error=>{if(error.code==='not_found')return [];throw error;})) { + for(const entry of await fs.list(`${pathRoot}/extensions`).catch(error=>{if(error.code==='not_found')return [];throw error;})) { if(entry.kind!=='directory')continue; - const path = `${snapshot}/extensions/${entry.name}/server.ts`; + const path = `${pathRoot}/extensions/${entry.name}/server.ts`; try {await fs.stat(path);} catch(error) {if((error as any).code==='not_found')continue;throw error;} const module = await import(/* @vite-ignore */ `file://${path}`); modules.push({name:entry.name,module}); @@ -180,36 +183,50 @@ export async function loadExtensions(harness:any, registry:any, storage:any, mod facet.extension = typeof module.default==='function'?await module.default(facet.host):module.default; if(!facet.extension || facet.extension.name!==name)throw new Error(`Extension name must match ${name}`); } - // Validate the complete commit before the live registry changes. + // Validate the complete file set before the live registry changes. const probe = durable.createRegistry(); for(const extension of [...builtins,...next.map(f=>f.extension)])probe.install(extension); - for(const old of facets)registry.uninstall(old.extension); + const names=new Set(next.map(f=>f.extension.name)); + for(const old of facets)if(!names.has(old.extension.name))registry.uninstall(old.extension); for(const extension of [...builtins,...next.map(f=>f.extension)])registry.install(extension); const old = facets; facets=next; providers=staged; await dispose(old); for(const facet of next)for(const cb of facet.open) {try {await cb();} catch(error) {facet.host.notice(`onOpen: ${String(error)}`);} } - state.good=sha; + installed=true; + state.good=copy.hash; return null; } catch(error) {await dispose(next); return String(error);} }; const reload = async () => { - let tip:string; - try {tip=await loaderGit('rev-parse','refs/heads/main');} catch(error) {state.error=String(error);ops.op_extension_status(JSON.stringify(state));return;} + let good:Copy|undefined; try {settings=JSON.parse(await fs.read(`${repo}/settings.json`));} catch(error) {if((error as any).code==='not_found')settings={};else {state.notice=`Settings: ${String(error)}`;ops.op_extension_status(JSON.stringify(state));ops.op_output(`${state.notice}\n`);}} - if(state.current===tip && facets.length)return; - state.current=tip; - state.error=await load(tip); - if(state.error && !facets.length && state.good && state.good!==tip) { - const failed=state.error; - const fallback=await load(state.good); - state.error=fallback?`${failed}\nLast good: ${fallback}`:failed; + try { + const copy=await readCopy(); + if(state.current===copy.hash && installed)return; + state.current=copy.hash; + state.error=await load(copy); + if(!state.error)good=copy; + } catch(error) {state.error=String(error);} + if(state.error && !installed) { + const saved=await harness.snapshot(ExtensionFiles,context); + if(saved?.hash) { + const failed=state.error; + const fallback=await load(saved); + state.error=fallback?`${failed}\nLast good: ${fallback}`:failed; + } } - await publish(); + await publish(good); }; const enqueue = () => queue=queue.then(reload); - const rid = ops.op_watch([`${repo}/.git/refs/heads/main`,`${repo}/.git/packed-refs`,`${repo}/settings.json`]); + const rid = ops.op_watch([`${repo}/extensions`,`${repo}/settings.json`]); + let timer:ReturnType|undefined; await enqueue(); - const pending = (async()=> {while(active){await ops.op_watch_next(rid);if(active)await enqueue();}})(); + const pending = (async()=> {while(active){ + const event=await ops.op_watch_next(rid); + if(!event || !active)break; + clearTimeout(timer); + timer=setTimeout(()=>{timer=undefined;void enqueue();},75); + }})(); return {reload:enqueue, state:()=>({...state}), async mainAgent() { if (!providers.has('models') || !providers.has('subagents')) return undefined; const {main_agent_name:name}=await providers.get('models').discover(repo); @@ -220,5 +237,5 @@ export async function loadExtensions(harness:any, registry:any, storage:any, mod const action = facet?.actions?.[key]; if (!action) throw Object.assign(new Error('Unknown user action'),{status:404}); return action(args,{host:facet.host,signal,conversationId,source:'ui'}); - }, close:async()=>{active=false;commands.abort();Deno.core.close(rid);await pending;await queue;await dispose(facets);}}; + }, close:async()=>{active=false;clearTimeout(timer);Deno.core.close(rid);await pending;await queue;await dispose(facets);}}; } diff --git a/host/js/goal-questions-test-bundle.js b/host/js/goal-questions-test-bundle.js index fa2ea18..79393e8 100644 --- a/host/js/goal-questions-test-bundle.js +++ b/host/js/goal-questions-test-bundle.js @@ -39914,28 +39914,54 @@ async function loadExtensions(harness, registry, storage, models, context, built let state = { ...await harness.snapshot(ExtensionState, context) ?? { good: null, current: null, error: null } }; let settings = {}; let active = true; - const commands = new AbortController(); - const loaderGit = async (...args) => { - for (let attempt = 0; ; attempt++) { - try { - const result = await exec(["git", ...args], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(result.stderr || `git exited ${result.code}`); - return result.stdout.trim(); - } catch (error) { - const message = `git ${args.join(" ")}: ${String(error)}`; - state.error = message; - ops2.op_extension_status(JSON.stringify(state)); - if (attempt === 0 && !commands.signal.aborted && String(error).includes("command timed out")) continue; - throw new Error(message); + let installed = false; + let queue2 = Promise.resolve(); + const publish2 = async (copy2) => { + await harness.commit(async (tx) => { + Object.assign(await tx.doc(ExtensionState), state); + if (copy2) Object.assign(await tx.doc(ExtensionFiles), copy2); + }, context); + ops2.op_extension_status(JSON.stringify(state)); + }; + const readCopy = async () => { + const files = []; + const walk = async (relative) => { + const path = `${repo}/extensions${relative}`; + const entries2 = await fs.list(path).catch((error) => { + if (error.code === "not_found") return []; + throw error; + }); + entries2.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + for (const entry2 of entries2) { + const name = `${relative}/${entry2.name}`; + if (entry2.kind === "directory") await walk(name); + else files.push([name, await file("read", `${repo}/extensions${name}`)]); } + }; + await walk(""); + const digest = await crypto.subtle.digest("SHA-256", encoder4.encode(JSON.stringify(files))); + const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + return { hash, files }; + }; + const mkdir = async (path) => { + try { + await fs.mkdir(path); + } catch (error) { + if (error.code !== "already_exists") throw error; } }; - let queue2 = Promise.resolve(); - const publish2 = async () => { - await harness.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context); - ops2.op_extension_status(JSON.stringify(state)); - await fs.write("/src/sausage/.git/sausage-last-good.tmp", state.good ?? ""); - await fs.rename("/src/sausage/.git/sausage-last-good.tmp", "/src/sausage/.git/sausage-last-good"); + const snapshot = async ({ hash, files }) => { + const base = `/run/sausage/extensions/${hash}`; + for (const path of ["/run/sausage", "/run/sausage/extensions", base, `${base}/extensions`]) await mkdir(path); + for (const [name, bytes2] of files) { + let parent = `${base}/extensions`; + for (const part of name.slice(1).split("/").slice(0, -1)) { + parent += `/${part}`; + await mkdir(parent); + } + await file("write", `${base}/extensions${name}`, bytes2); + } + return base; }; const dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -40010,42 +40036,18 @@ async function loadExtensions(harness, registry, storage, models, context, built }; return { host, open, close }; }; - const load = async (sha) => { + const load = async (copy2) => { const next2 = []; const staged = /* @__PURE__ */ new Map(); try { - const snapshot = `/run/sausage/extensions/${sha}`; - for (const path of ["/run/sausage", "/run/sausage/extensions"]) { - try { - await fs.mkdir(path); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - } - try { - await fs.stat(snapshot); - } catch { - const staging = `${snapshot}.tmp`, archive = `${snapshot}.tar`; - try { - await fs.mkdir(staging); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - if (await loaderGit("ls-tree", "--name-only", sha, "extensions")) { - await loaderGit("archive", "--format=tar", `--output=${archive}`, sha, "extensions"); - const result = await exec(["tar", "-xf", archive, "-C", staging], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(`tar: ${result.stderr || result.code}`); - await fs.remove(archive); - } - await fs.rename(staging, snapshot); - } + const pathRoot = await snapshot(copy2); const modules = []; - for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { + for (const entry2 of await fs.list(`${pathRoot}/extensions`).catch((error) => { if (error.code === "not_found") return []; throw error; })) { if (entry2.kind !== "directory") continue; - const path = `${snapshot}/extensions/${entry2.name}/server.ts`; + const path = `${pathRoot}/extensions/${entry2.name}/server.ts`; try { await fs.stat(path); } catch (error) { @@ -40068,7 +40070,8 @@ async function loadExtensions(harness, registry, storage, models, context, built } const probe = createRegistry(); for (const extension of [...builtins, ...next2.map((f) => f.extension)]) probe.install(extension); - for (const old2 of facets) registry.uninstall(old2.extension); + const names2 = new Set(next2.map((f) => f.extension.name)); + for (const old2 of facets) if (!names2.has(old2.extension.name)) registry.uninstall(old2.extension); for (const extension of [...builtins, ...next2.map((f) => f.extension)]) registry.install(extension); const old = facets; facets = next2; @@ -40081,7 +40084,8 @@ async function loadExtensions(harness, registry, storage, models, context, built facet.host.notice(`onOpen: ${String(error)}`); } } - state.good = sha; + installed = true; + state.good = copy2.hash; return null; } catch (error) { await dispose(next2); @@ -40089,14 +40093,7 @@ async function loadExtensions(harness, registry, storage, models, context, built } }; const reload = async () => { - let tip; - try { - tip = await loaderGit("rev-parse", "refs/heads/main"); - } catch (error) { - state.error = String(error); - ops2.op_extension_status(JSON.stringify(state)); - return; - } + let good; try { settings = JSON.parse(await fs.read(`${repo}/settings.json`)); } catch (error) { @@ -40108,24 +40105,39 @@ async function loadExtensions(harness, registry, storage, models, context, built `); } } - if (state.current === tip && facets.length) return; - state.current = tip; - state.error = await load(tip); - if (state.error && !facets.length && state.good && state.good !== tip) { - const failed = state.error; - const fallback = await load(state.good); - state.error = fallback ? `${failed} -Last good: ${fallback}` : failed; + try { + const copy2 = await readCopy(); + if (state.current === copy2.hash && installed) return; + state.current = copy2.hash; + state.error = await load(copy2); + if (!state.error) good = copy2; + } catch (error) { + state.error = String(error); } - await publish2(); + if (state.error && !installed) { + const saved = await harness.snapshot(ExtensionFiles, context); + if (saved?.hash) { + const failed = state.error; + const fallback = await load(saved); + state.error = fallback ? `${failed} +Last good: ${fallback}` : failed; + } + } + await publish2(good); }; const enqueue2 = () => queue2 = queue2.then(reload); - const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]); + const rid = ops2.op_watch([`${repo}/extensions`, `${repo}/settings.json`]); + let timer; await enqueue2(); const pending = (async () => { while (active) { - await ops2.op_watch_next(rid); - if (active) await enqueue2(); + const event = await ops2.op_watch_next(rid); + if (!event || !active) break; + clearTimeout(timer); + timer = setTimeout(() => { + timer = void 0; + void enqueue2(); + }, 75); } })(); return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() { @@ -40140,14 +40152,14 @@ Last good: ${fallback}` : failed; return action(args, { host: facet.host, signal, conversationId, source: "ui" }); }, close: async () => { active = false; - commands.abort(); + clearTimeout(timer); Deno.core.close(rid); await pending; await queue2; await dispose(facets); } }; } -var ops2, encoder4, decoder4, ExtensionState, Metadata, fs; +var ops2, encoder4, decoder4, ExtensionState, ExtensionFiles, Metadata, fs; var init_extensions = __esm({ "extensions.ts"() { init_dist4(); @@ -40164,6 +40176,7 @@ var init_extensions = __esm({ encoder4 = new TextEncoder(); decoder4 = new TextDecoder(); ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) }); + ExtensionFiles = defineDoc({ kind: "sausage.extensions.files", version: 1, scope: "session", history: "latest", checkpointWhen: () => false, initial: () => ({ hash: null, files: [] }) }); Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false }); fs = { read: async (path) => decoder4.decode(new Uint8Array(await file("read", path))), diff --git a/host/js/harness-api-test-bundle.js b/host/js/harness-api-test-bundle.js index e751c90..1c2e0e1 100644 --- a/host/js/harness-api-test-bundle.js +++ b/host/js/harness-api-test-bundle.js @@ -38050,6 +38050,7 @@ var ops2 = Deno.core.ops; var encoder4 = new TextEncoder(); var decoder4 = new TextDecoder(); var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) }); +var ExtensionFiles = defineDoc({ kind: "sausage.extensions.files", version: 1, scope: "session", history: "latest", checkpointWhen: () => false, initial: () => ({ hash: null, files: [] }) }); var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false }); async function file(kind, path, bytes2 = []) { const result = await ops2.op_file(kind, path, bytes2); @@ -38122,28 +38123,54 @@ async function loadExtensions(harness2, registry, storage, models, context, buil let state = { ...await harness2.snapshot(ExtensionState, context) ?? { good: null, current: null, error: null } }; let settings = {}; let active = true; - const commands = new AbortController(); - const loaderGit = async (...args) => { - for (let attempt = 0; ; attempt++) { - try { - const result = await exec(["git", ...args], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(result.stderr || `git exited ${result.code}`); - return result.stdout.trim(); - } catch (error) { - const message = `git ${args.join(" ")}: ${String(error)}`; - state.error = message; - ops2.op_extension_status(JSON.stringify(state)); - if (attempt === 0 && !commands.signal.aborted && String(error).includes("command timed out")) continue; - throw new Error(message); + let installed = false; + let queue2 = Promise.resolve(); + const publish2 = async (copy2) => { + await harness2.commit(async (tx) => { + Object.assign(await tx.doc(ExtensionState), state); + if (copy2) Object.assign(await tx.doc(ExtensionFiles), copy2); + }, context); + ops2.op_extension_status(JSON.stringify(state)); + }; + const readCopy = async () => { + const files = []; + const walk = async (relative) => { + const path = `${repo}/extensions${relative}`; + const entries2 = await fs.list(path).catch((error) => { + if (error.code === "not_found") return []; + throw error; + }); + entries2.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + for (const entry2 of entries2) { + const name = `${relative}/${entry2.name}`; + if (entry2.kind === "directory") await walk(name); + else files.push([name, await file("read", `${repo}/extensions${name}`)]); } + }; + await walk(""); + const digest = await crypto.subtle.digest("SHA-256", encoder4.encode(JSON.stringify(files))); + const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + return { hash, files }; + }; + const mkdir = async (path) => { + try { + await fs.mkdir(path); + } catch (error) { + if (error.code !== "already_exists") throw error; } }; - let queue2 = Promise.resolve(); - const publish2 = async () => { - await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context); - ops2.op_extension_status(JSON.stringify(state)); - await fs.write("/src/sausage/.git/sausage-last-good.tmp", state.good ?? ""); - await fs.rename("/src/sausage/.git/sausage-last-good.tmp", "/src/sausage/.git/sausage-last-good"); + const snapshot = async ({ hash, files }) => { + const base = `/run/sausage/extensions/${hash}`; + for (const path of ["/run/sausage", "/run/sausage/extensions", base, `${base}/extensions`]) await mkdir(path); + for (const [name, bytes2] of files) { + let parent = `${base}/extensions`; + for (const part of name.slice(1).split("/").slice(0, -1)) { + parent += `/${part}`; + await mkdir(parent); + } + await file("write", `${base}/extensions${name}`, bytes2); + } + return base; }; const dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -38218,42 +38245,18 @@ async function loadExtensions(harness2, registry, storage, models, context, buil }; return { host, open, close }; }; - const load = async (sha) => { + const load = async (copy2) => { const next2 = []; const staged = /* @__PURE__ */ new Map(); try { - const snapshot = `/run/sausage/extensions/${sha}`; - for (const path of ["/run/sausage", "/run/sausage/extensions"]) { - try { - await fs.mkdir(path); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - } - try { - await fs.stat(snapshot); - } catch { - const staging = `${snapshot}.tmp`, archive = `${snapshot}.tar`; - try { - await fs.mkdir(staging); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - if (await loaderGit("ls-tree", "--name-only", sha, "extensions")) { - await loaderGit("archive", "--format=tar", `--output=${archive}`, sha, "extensions"); - const result = await exec(["tar", "-xf", archive, "-C", staging], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(`tar: ${result.stderr || result.code}`); - await fs.remove(archive); - } - await fs.rename(staging, snapshot); - } + const pathRoot = await snapshot(copy2); const modules = []; - for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { + for (const entry2 of await fs.list(`${pathRoot}/extensions`).catch((error) => { if (error.code === "not_found") return []; throw error; })) { if (entry2.kind !== "directory") continue; - const path = `${snapshot}/extensions/${entry2.name}/server.ts`; + const path = `${pathRoot}/extensions/${entry2.name}/server.ts`; try { await fs.stat(path); } catch (error) { @@ -38276,7 +38279,8 @@ async function loadExtensions(harness2, registry, storage, models, context, buil } const probe = createRegistry(); for (const extension of [...builtins, ...next2.map((f2) => f2.extension)]) probe.install(extension); - for (const old2 of facets) registry.uninstall(old2.extension); + const names2 = new Set(next2.map((f2) => f2.extension.name)); + for (const old2 of facets) if (!names2.has(old2.extension.name)) registry.uninstall(old2.extension); for (const extension of [...builtins, ...next2.map((f2) => f2.extension)]) registry.install(extension); const old = facets; facets = next2; @@ -38289,7 +38293,8 @@ async function loadExtensions(harness2, registry, storage, models, context, buil facet.host.notice(`onOpen: ${String(error)}`); } } - state.good = sha; + installed = true; + state.good = copy2.hash; return null; } catch (error) { await dispose(next2); @@ -38297,14 +38302,7 @@ async function loadExtensions(harness2, registry, storage, models, context, buil } }; const reload = async () => { - let tip; - try { - tip = await loaderGit("rev-parse", "refs/heads/main"); - } catch (error) { - state.error = String(error); - ops2.op_extension_status(JSON.stringify(state)); - return; - } + let good; try { settings = JSON.parse(await fs.read(`${repo}/settings.json`)); } catch (error) { @@ -38316,24 +38314,39 @@ async function loadExtensions(harness2, registry, storage, models, context, buil `); } } - if (state.current === tip && facets.length) return; - state.current = tip; - state.error = await load(tip); - if (state.error && !facets.length && state.good && state.good !== tip) { - const failed = state.error; - const fallback = await load(state.good); - state.error = fallback ? `${failed} -Last good: ${fallback}` : failed; + try { + const copy2 = await readCopy(); + if (state.current === copy2.hash && installed) return; + state.current = copy2.hash; + state.error = await load(copy2); + if (!state.error) good = copy2; + } catch (error) { + state.error = String(error); } - await publish2(); + if (state.error && !installed) { + const saved = await harness2.snapshot(ExtensionFiles, context); + if (saved?.hash) { + const failed = state.error; + const fallback = await load(saved); + state.error = fallback ? `${failed} +Last good: ${fallback}` : failed; + } + } + await publish2(good); }; const enqueue2 = () => queue2 = queue2.then(reload); - const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]); + const rid = ops2.op_watch([`${repo}/extensions`, `${repo}/settings.json`]); + let timer; await enqueue2(); const pending = (async () => { while (active) { - await ops2.op_watch_next(rid); - if (active) await enqueue2(); + const event = await ops2.op_watch_next(rid); + if (!event || !active) break; + clearTimeout(timer); + timer = setTimeout(() => { + timer = void 0; + void enqueue2(); + }, 75); } })(); return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() { @@ -38348,7 +38361,7 @@ Last good: ${fallback}` : failed; return action(args, { host: facet.host, signal, conversationId, source: "ui" }); }, close: async () => { active = false; - commands.abort(); + clearTimeout(timer); Deno.core.close(rid); await pending; await queue2; diff --git a/host/js/messages-test-bundle.js b/host/js/messages-test-bundle.js index 74b54db..c3eea60 100644 --- a/host/js/messages-test-bundle.js +++ b/host/js/messages-test-bundle.js @@ -37110,6 +37110,7 @@ var ops2 = Deno.core.ops; var encoder4 = new TextEncoder(); var decoder4 = new TextDecoder(); var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) }); +var ExtensionFiles = defineDoc({ kind: "sausage.extensions.files", version: 1, scope: "session", history: "latest", checkpointWhen: () => false, initial: () => ({ hash: null, files: [] }) }); var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false }); async function file(kind, path, bytes2 = []) { const result = await ops2.op_file(kind, path, bytes2); @@ -37182,28 +37183,54 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b let state = { ...await harness2.snapshot(ExtensionState, context) ?? { good: null, current: null, error: null } }; let settings = {}; let active = true; - const commands = new AbortController(); - const loaderGit = async (...args) => { - for (let attempt = 0; ; attempt++) { - try { - const result = await exec(["git", ...args], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(result.stderr || `git exited ${result.code}`); - return result.stdout.trim(); - } catch (error) { - const message = `git ${args.join(" ")}: ${String(error)}`; - state.error = message; - ops2.op_extension_status(JSON.stringify(state)); - if (attempt === 0 && !commands.signal.aborted && String(error).includes("command timed out")) continue; - throw new Error(message); + let installed = false; + let queue2 = Promise.resolve(); + const publish2 = async (copy2) => { + await harness2.commit(async (tx) => { + Object.assign(await tx.doc(ExtensionState), state); + if (copy2) Object.assign(await tx.doc(ExtensionFiles), copy2); + }, context); + ops2.op_extension_status(JSON.stringify(state)); + }; + const readCopy = async () => { + const files = []; + const walk = async (relative) => { + const path = `${repo}/extensions${relative}`; + const entries3 = await fs.list(path).catch((error) => { + if (error.code === "not_found") return []; + throw error; + }); + entries3.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + for (const entry2 of entries3) { + const name = `${relative}/${entry2.name}`; + if (entry2.kind === "directory") await walk(name); + else files.push([name, await file("read", `${repo}/extensions${name}`)]); } + }; + await walk(""); + const digest = await crypto.subtle.digest("SHA-256", encoder4.encode(JSON.stringify(files))); + const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + return { hash, files }; + }; + const mkdir = async (path) => { + try { + await fs.mkdir(path); + } catch (error) { + if (error.code !== "already_exists") throw error; } }; - let queue2 = Promise.resolve(); - const publish2 = async () => { - await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context); - ops2.op_extension_status(JSON.stringify(state)); - await fs.write("/src/sausage/.git/sausage-last-good.tmp", state.good ?? ""); - await fs.rename("/src/sausage/.git/sausage-last-good.tmp", "/src/sausage/.git/sausage-last-good"); + const snapshot2 = async ({ hash, files }) => { + const base2 = `/run/sausage/extensions/${hash}`; + for (const path of ["/run/sausage", "/run/sausage/extensions", base2, `${base2}/extensions`]) await mkdir(path); + for (const [name, bytes2] of files) { + let parent = `${base2}/extensions`; + for (const part of name.slice(1).split("/").slice(0, -1)) { + parent += `/${part}`; + await mkdir(parent); + } + await file("write", `${base2}/extensions${name}`, bytes2); + } + return base2; }; const dispose = async (entries3) => { for (const facet of entries3) for (const cleanup of facet.close) { @@ -37278,42 +37305,18 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b }; return { host, open, close }; }; - const load = async (sha) => { + const load = async (copy2) => { const next = []; const staged = /* @__PURE__ */ new Map(); try { - const snapshot2 = `/run/sausage/extensions/${sha}`; - for (const path of ["/run/sausage", "/run/sausage/extensions"]) { - try { - await fs.mkdir(path); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - } - try { - await fs.stat(snapshot2); - } catch { - const staging = `${snapshot2}.tmp`, archive = `${snapshot2}.tar`; - try { - await fs.mkdir(staging); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - if (await loaderGit("ls-tree", "--name-only", sha, "extensions")) { - await loaderGit("archive", "--format=tar", `--output=${archive}`, sha, "extensions"); - const result = await exec(["tar", "-xf", archive, "-C", staging], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(`tar: ${result.stderr || result.code}`); - await fs.remove(archive); - } - await fs.rename(staging, snapshot2); - } + const pathRoot = await snapshot2(copy2); const modules = []; - for (const entry2 of await fs.list(`${snapshot2}/extensions`).catch((error) => { + for (const entry2 of await fs.list(`${pathRoot}/extensions`).catch((error) => { if (error.code === "not_found") return []; throw error; })) { if (entry2.kind !== "directory") continue; - const path = `${snapshot2}/extensions/${entry2.name}/server.ts`; + const path = `${pathRoot}/extensions/${entry2.name}/server.ts`; try { await fs.stat(path); } catch (error) { @@ -37336,7 +37339,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } const probe = createRegistry(); for (const extension of [...builtins, ...next.map((f) => f.extension)]) probe.install(extension); - for (const old2 of facets) registry2.uninstall(old2.extension); + const names2 = new Set(next.map((f) => f.extension.name)); + for (const old2 of facets) if (!names2.has(old2.extension.name)) registry2.uninstall(old2.extension); for (const extension of [...builtins, ...next.map((f) => f.extension)]) registry2.install(extension); const old = facets; facets = next; @@ -37349,7 +37353,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b facet.host.notice(`onOpen: ${String(error)}`); } } - state.good = sha; + installed = true; + state.good = copy2.hash; return null; } catch (error) { await dispose(next); @@ -37357,14 +37362,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } }; const reload = async () => { - let tip; - try { - tip = await loaderGit("rev-parse", "refs/heads/main"); - } catch (error) { - state.error = String(error); - ops2.op_extension_status(JSON.stringify(state)); - return; - } + let good; try { settings = JSON.parse(await fs.read(`${repo}/settings.json`)); } catch (error) { @@ -37376,24 +37374,39 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b `); } } - if (state.current === tip && facets.length) return; - state.current = tip; - state.error = await load(tip); - if (state.error && !facets.length && state.good && state.good !== tip) { - const failed = state.error; - const fallback = await load(state.good); - state.error = fallback ? `${failed} -Last good: ${fallback}` : failed; + try { + const copy2 = await readCopy(); + if (state.current === copy2.hash && installed) return; + state.current = copy2.hash; + state.error = await load(copy2); + if (!state.error) good = copy2; + } catch (error) { + state.error = String(error); } - await publish2(); + if (state.error && !installed) { + const saved = await harness2.snapshot(ExtensionFiles, context); + if (saved?.hash) { + const failed = state.error; + const fallback = await load(saved); + state.error = fallback ? `${failed} +Last good: ${fallback}` : failed; + } + } + await publish2(good); }; const enqueue2 = () => queue2 = queue2.then(reload); - const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]); + const rid = ops2.op_watch([`${repo}/extensions`, `${repo}/settings.json`]); + let timer; await enqueue2(); const pending = (async () => { while (active) { - await ops2.op_watch_next(rid); - if (active) await enqueue2(); + const event = await ops2.op_watch_next(rid); + if (!event || !active) break; + clearTimeout(timer); + timer = setTimeout(() => { + timer = void 0; + void enqueue2(); + }, 75); } })(); return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() { @@ -37408,7 +37421,7 @@ Last good: ${fallback}` : failed; return action(args, { host: facet.host, signal, conversationId, source: "ui" }); }, close: async () => { active = false; - commands.abort(); + clearTimeout(timer); Deno.core.close(rid); await pending; await queue2; diff --git a/host/js/p3-test-bundle.js b/host/js/p3-test-bundle.js index a91318e..7037acb 100644 --- a/host/js/p3-test-bundle.js +++ b/host/js/p3-test-bundle.js @@ -37110,6 +37110,7 @@ var ops2 = Deno.core.ops; var encoder4 = new TextEncoder(); var decoder4 = new TextDecoder(); var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) }); +var ExtensionFiles = defineDoc({ kind: "sausage.extensions.files", version: 1, scope: "session", history: "latest", checkpointWhen: () => false, initial: () => ({ hash: null, files: [] }) }); var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false }); async function file(kind, path, bytes2 = []) { const result = await ops2.op_file(kind, path, bytes2); @@ -37182,28 +37183,54 @@ async function loadExtensions(harness, registry, storage, models, context, built let state = { ...await harness.snapshot(ExtensionState, context) ?? { good: null, current: null, error: null } }; let settings = {}; let active = true; - const commands = new AbortController(); - const loaderGit = async (...args) => { - for (let attempt = 0; ; attempt++) { - try { - const result = await exec(["git", ...args], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(result.stderr || `git exited ${result.code}`); - return result.stdout.trim(); - } catch (error) { - const message = `git ${args.join(" ")}: ${String(error)}`; - state.error = message; - ops2.op_extension_status(JSON.stringify(state)); - if (attempt === 0 && !commands.signal.aborted && String(error).includes("command timed out")) continue; - throw new Error(message); + let installed = false; + let queue2 = Promise.resolve(); + const publish2 = async (copy2) => { + await harness.commit(async (tx) => { + Object.assign(await tx.doc(ExtensionState), state); + if (copy2) Object.assign(await tx.doc(ExtensionFiles), copy2); + }, context); + ops2.op_extension_status(JSON.stringify(state)); + }; + const readCopy = async () => { + const files = []; + const walk = async (relative) => { + const path = `${repo}/extensions${relative}`; + const entries2 = await fs.list(path).catch((error) => { + if (error.code === "not_found") return []; + throw error; + }); + entries2.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + for (const entry2 of entries2) { + const name = `${relative}/${entry2.name}`; + if (entry2.kind === "directory") await walk(name); + else files.push([name, await file("read", `${repo}/extensions${name}`)]); } + }; + await walk(""); + const digest = await crypto.subtle.digest("SHA-256", encoder4.encode(JSON.stringify(files))); + const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + return { hash, files }; + }; + const mkdir = async (path) => { + try { + await fs.mkdir(path); + } catch (error) { + if (error.code !== "already_exists") throw error; } }; - let queue2 = Promise.resolve(); - const publish2 = async () => { - await harness.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context); - ops2.op_extension_status(JSON.stringify(state)); - await fs.write("/src/sausage/.git/sausage-last-good.tmp", state.good ?? ""); - await fs.rename("/src/sausage/.git/sausage-last-good.tmp", "/src/sausage/.git/sausage-last-good"); + const snapshot = async ({ hash, files }) => { + const base = `/run/sausage/extensions/${hash}`; + for (const path of ["/run/sausage", "/run/sausage/extensions", base, `${base}/extensions`]) await mkdir(path); + for (const [name, bytes2] of files) { + let parent = `${base}/extensions`; + for (const part of name.slice(1).split("/").slice(0, -1)) { + parent += `/${part}`; + await mkdir(parent); + } + await file("write", `${base}/extensions${name}`, bytes2); + } + return base; }; const dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -37278,42 +37305,18 @@ async function loadExtensions(harness, registry, storage, models, context, built }; return { host, open, close }; }; - const load = async (sha) => { + const load = async (copy2) => { const next = []; const staged = /* @__PURE__ */ new Map(); try { - const snapshot = `/run/sausage/extensions/${sha}`; - for (const path of ["/run/sausage", "/run/sausage/extensions"]) { - try { - await fs.mkdir(path); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - } - try { - await fs.stat(snapshot); - } catch { - const staging = `${snapshot}.tmp`, archive = `${snapshot}.tar`; - try { - await fs.mkdir(staging); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - if (await loaderGit("ls-tree", "--name-only", sha, "extensions")) { - await loaderGit("archive", "--format=tar", `--output=${archive}`, sha, "extensions"); - const result = await exec(["tar", "-xf", archive, "-C", staging], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(`tar: ${result.stderr || result.code}`); - await fs.remove(archive); - } - await fs.rename(staging, snapshot); - } + const pathRoot = await snapshot(copy2); const modules = []; - for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { + for (const entry2 of await fs.list(`${pathRoot}/extensions`).catch((error) => { if (error.code === "not_found") return []; throw error; })) { if (entry2.kind !== "directory") continue; - const path = `${snapshot}/extensions/${entry2.name}/server.ts`; + const path = `${pathRoot}/extensions/${entry2.name}/server.ts`; try { await fs.stat(path); } catch (error) { @@ -37336,7 +37339,8 @@ async function loadExtensions(harness, registry, storage, models, context, built } const probe = createRegistry(); for (const extension of [...builtins, ...next.map((f) => f.extension)]) probe.install(extension); - for (const old2 of facets) registry.uninstall(old2.extension); + const names2 = new Set(next.map((f) => f.extension.name)); + for (const old2 of facets) if (!names2.has(old2.extension.name)) registry.uninstall(old2.extension); for (const extension of [...builtins, ...next.map((f) => f.extension)]) registry.install(extension); const old = facets; facets = next; @@ -37349,7 +37353,8 @@ async function loadExtensions(harness, registry, storage, models, context, built facet.host.notice(`onOpen: ${String(error)}`); } } - state.good = sha; + installed = true; + state.good = copy2.hash; return null; } catch (error) { await dispose(next); @@ -37357,14 +37362,7 @@ async function loadExtensions(harness, registry, storage, models, context, built } }; const reload = async () => { - let tip; - try { - tip = await loaderGit("rev-parse", "refs/heads/main"); - } catch (error) { - state.error = String(error); - ops2.op_extension_status(JSON.stringify(state)); - return; - } + let good; try { settings = JSON.parse(await fs.read(`${repo}/settings.json`)); } catch (error) { @@ -37376,24 +37374,39 @@ async function loadExtensions(harness, registry, storage, models, context, built `); } } - if (state.current === tip && facets.length) return; - state.current = tip; - state.error = await load(tip); - if (state.error && !facets.length && state.good && state.good !== tip) { - const failed = state.error; - const fallback = await load(state.good); - state.error = fallback ? `${failed} -Last good: ${fallback}` : failed; + try { + const copy2 = await readCopy(); + if (state.current === copy2.hash && installed) return; + state.current = copy2.hash; + state.error = await load(copy2); + if (!state.error) good = copy2; + } catch (error) { + state.error = String(error); } - await publish2(); + if (state.error && !installed) { + const saved = await harness.snapshot(ExtensionFiles, context); + if (saved?.hash) { + const failed = state.error; + const fallback = await load(saved); + state.error = fallback ? `${failed} +Last good: ${fallback}` : failed; + } + } + await publish2(good); }; const enqueue2 = () => queue2 = queue2.then(reload); - const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]); + const rid = ops2.op_watch([`${repo}/extensions`, `${repo}/settings.json`]); + let timer; await enqueue2(); const pending = (async () => { while (active) { - await ops2.op_watch_next(rid); - if (active) await enqueue2(); + const event = await ops2.op_watch_next(rid); + if (!event || !active) break; + clearTimeout(timer); + timer = setTimeout(() => { + timer = void 0; + void enqueue2(); + }, 75); } })(); return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() { @@ -37408,7 +37421,7 @@ Last good: ${fallback}` : failed; return action(args, { host: facet.host, signal, conversationId, source: "ui" }); }, close: async () => { active = false; - commands.abort(); + clearTimeout(timer); Deno.core.close(rid); await pending; await queue2; diff --git a/host/js/plan-todos-test-bundle.js b/host/js/plan-todos-test-bundle.js index a2f2cda..2fb2044 100644 --- a/host/js/plan-todos-test-bundle.js +++ b/host/js/plan-todos-test-bundle.js @@ -38050,6 +38050,7 @@ var ops2 = Deno.core.ops; var encoder4 = new TextEncoder(); var decoder4 = new TextDecoder(); var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) }); +var ExtensionFiles = defineDoc({ kind: "sausage.extensions.files", version: 1, scope: "session", history: "latest", checkpointWhen: () => false, initial: () => ({ hash: null, files: [] }) }); var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false }); async function file(kind, path, bytes2 = []) { const result = await ops2.op_file(kind, path, bytes2); @@ -38122,28 +38123,54 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b let state = { ...await harness2.snapshot(ExtensionState, context) ?? { good: null, current: null, error: null } }; let settings = {}; let active = true; - const commands = new AbortController(); - const loaderGit = async (...args) => { - for (let attempt = 0; ; attempt++) { - try { - const result = await exec(["git", ...args], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(result.stderr || `git exited ${result.code}`); - return result.stdout.trim(); - } catch (error) { - const message = `git ${args.join(" ")}: ${String(error)}`; - state.error = message; - ops2.op_extension_status(JSON.stringify(state)); - if (attempt === 0 && !commands.signal.aborted && String(error).includes("command timed out")) continue; - throw new Error(message); + let installed = false; + let queue2 = Promise.resolve(); + const publish2 = async (copy2) => { + await harness2.commit(async (tx) => { + Object.assign(await tx.doc(ExtensionState), state); + if (copy2) Object.assign(await tx.doc(ExtensionFiles), copy2); + }, context); + ops2.op_extension_status(JSON.stringify(state)); + }; + const readCopy = async () => { + const files = []; + const walk = async (relative) => { + const path = `${repo}/extensions${relative}`; + const entries2 = await fs.list(path).catch((error) => { + if (error.code === "not_found") return []; + throw error; + }); + entries2.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + for (const entry2 of entries2) { + const name = `${relative}/${entry2.name}`; + if (entry2.kind === "directory") await walk(name); + else files.push([name, await file("read", `${repo}/extensions${name}`)]); } + }; + await walk(""); + const digest = await crypto.subtle.digest("SHA-256", encoder4.encode(JSON.stringify(files))); + const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + return { hash, files }; + }; + const mkdir = async (path) => { + try { + await fs.mkdir(path); + } catch (error) { + if (error.code !== "already_exists") throw error; } }; - let queue2 = Promise.resolve(); - const publish2 = async () => { - await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context); - ops2.op_extension_status(JSON.stringify(state)); - await fs.write("/src/sausage/.git/sausage-last-good.tmp", state.good ?? ""); - await fs.rename("/src/sausage/.git/sausage-last-good.tmp", "/src/sausage/.git/sausage-last-good"); + const snapshot = async ({ hash, files }) => { + const base2 = `/run/sausage/extensions/${hash}`; + for (const path of ["/run/sausage", "/run/sausage/extensions", base2, `${base2}/extensions`]) await mkdir(path); + for (const [name, bytes2] of files) { + let parent = `${base2}/extensions`; + for (const part of name.slice(1).split("/").slice(0, -1)) { + parent += `/${part}`; + await mkdir(parent); + } + await file("write", `${base2}/extensions${name}`, bytes2); + } + return base2; }; const dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -38218,42 +38245,18 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b }; return { host, open, close }; }; - const load = async (sha) => { + const load = async (copy2) => { const next = []; const staged = /* @__PURE__ */ new Map(); try { - const snapshot = `/run/sausage/extensions/${sha}`; - for (const path of ["/run/sausage", "/run/sausage/extensions"]) { - try { - await fs.mkdir(path); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - } - try { - await fs.stat(snapshot); - } catch { - const staging = `${snapshot}.tmp`, archive = `${snapshot}.tar`; - try { - await fs.mkdir(staging); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - if (await loaderGit("ls-tree", "--name-only", sha, "extensions")) { - await loaderGit("archive", "--format=tar", `--output=${archive}`, sha, "extensions"); - const result = await exec(["tar", "-xf", archive, "-C", staging], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(`tar: ${result.stderr || result.code}`); - await fs.remove(archive); - } - await fs.rename(staging, snapshot); - } + const pathRoot = await snapshot(copy2); const modules = []; - for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { + for (const entry2 of await fs.list(`${pathRoot}/extensions`).catch((error) => { if (error.code === "not_found") return []; throw error; })) { if (entry2.kind !== "directory") continue; - const path = `${snapshot}/extensions/${entry2.name}/server.ts`; + const path = `${pathRoot}/extensions/${entry2.name}/server.ts`; try { await fs.stat(path); } catch (error) { @@ -38276,7 +38279,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } const probe = createRegistry(); for (const extension of [...builtins, ...next.map((f) => f.extension)]) probe.install(extension); - for (const old2 of facets) registry2.uninstall(old2.extension); + const names2 = new Set(next.map((f) => f.extension.name)); + for (const old2 of facets) if (!names2.has(old2.extension.name)) registry2.uninstall(old2.extension); for (const extension of [...builtins, ...next.map((f) => f.extension)]) registry2.install(extension); const old = facets; facets = next; @@ -38289,7 +38293,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b facet.host.notice(`onOpen: ${String(error)}`); } } - state.good = sha; + installed = true; + state.good = copy2.hash; return null; } catch (error) { await dispose(next); @@ -38297,14 +38302,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } }; const reload = async () => { - let tip; - try { - tip = await loaderGit("rev-parse", "refs/heads/main"); - } catch (error) { - state.error = String(error); - ops2.op_extension_status(JSON.stringify(state)); - return; - } + let good; try { settings = JSON.parse(await fs.read(`${repo}/settings.json`)); } catch (error) { @@ -38316,24 +38314,39 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b `); } } - if (state.current === tip && facets.length) return; - state.current = tip; - state.error = await load(tip); - if (state.error && !facets.length && state.good && state.good !== tip) { - const failed = state.error; - const fallback = await load(state.good); - state.error = fallback ? `${failed} -Last good: ${fallback}` : failed; + try { + const copy2 = await readCopy(); + if (state.current === copy2.hash && installed) return; + state.current = copy2.hash; + state.error = await load(copy2); + if (!state.error) good = copy2; + } catch (error) { + state.error = String(error); } - await publish2(); + if (state.error && !installed) { + const saved = await harness2.snapshot(ExtensionFiles, context); + if (saved?.hash) { + const failed = state.error; + const fallback = await load(saved); + state.error = fallback ? `${failed} +Last good: ${fallback}` : failed; + } + } + await publish2(good); }; const enqueue2 = () => queue2 = queue2.then(reload); - const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]); + const rid = ops2.op_watch([`${repo}/extensions`, `${repo}/settings.json`]); + let timer; await enqueue2(); const pending = (async () => { while (active) { - await ops2.op_watch_next(rid); - if (active) await enqueue2(); + const event = await ops2.op_watch_next(rid); + if (!event || !active) break; + clearTimeout(timer); + timer = setTimeout(() => { + timer = void 0; + void enqueue2(); + }, 75); } })(); return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() { @@ -38348,7 +38361,7 @@ Last good: ${fallback}` : failed; return action(args, { host: facet.host, signal, conversationId, source: "ui" }); }, close: async () => { active = false; - commands.abort(); + clearTimeout(timer); Deno.core.close(rid); await pending; await queue2; diff --git a/host/js/provider-models-test-bundle.js b/host/js/provider-models-test-bundle.js index 27ce82f..d4b2b0e 100644 --- a/host/js/provider-models-test-bundle.js +++ b/host/js/provider-models-test-bundle.js @@ -67033,6 +67033,7 @@ var ops2 = Deno.core.ops; var encoder3 = new TextEncoder(); var decoder2 = new TextDecoder(); var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) }); +var ExtensionFiles = defineDoc({ kind: "sausage.extensions.files", version: 1, scope: "session", history: "latest", checkpointWhen: () => false, initial: () => ({ hash: null, files: [] }) }); var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false }); async function file(kind, path5, bytes2 = []) { const result = await ops2.op_file(kind, path5, bytes2); diff --git a/host/js/subagents-test-bundle.js b/host/js/subagents-test-bundle.js index 38c20c2..947ee68 100644 --- a/host/js/subagents-test-bundle.js +++ b/host/js/subagents-test-bundle.js @@ -37110,6 +37110,7 @@ var ops2 = Deno.core.ops; var encoder4 = new TextEncoder(); var decoder4 = new TextDecoder(); var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) }); +var ExtensionFiles = defineDoc({ kind: "sausage.extensions.files", version: 1, scope: "session", history: "latest", checkpointWhen: () => false, initial: () => ({ hash: null, files: [] }) }); var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false }); async function file(kind, path2, bytes2 = []) { const result = await ops2.op_file(kind, path2, bytes2); @@ -37182,28 +37183,54 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b let state = { ...await harness2.snapshot(ExtensionState, context) ?? { good: null, current: null, error: null } }; let settings = {}; let active = true; - const commands = new AbortController(); - const loaderGit = async (...args) => { - for (let attempt = 0; ; attempt++) { - try { - const result = await exec(["git", ...args], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(result.stderr || `git exited ${result.code}`); - return result.stdout.trim(); - } catch (error) { - const message = `git ${args.join(" ")}: ${String(error)}`; - state.error = message; - ops2.op_extension_status(JSON.stringify(state)); - if (attempt === 0 && !commands.signal.aborted && String(error).includes("command timed out")) continue; - throw new Error(message); + let installed = false; + let queue2 = Promise.resolve(); + const publish2 = async (copy2) => { + await harness2.commit(async (tx) => { + Object.assign(await tx.doc(ExtensionState), state); + if (copy2) Object.assign(await tx.doc(ExtensionFiles), copy2); + }, context); + ops2.op_extension_status(JSON.stringify(state)); + }; + const readCopy = async () => { + const files = []; + const walk = async (relative) => { + const path2 = `${repo}/extensions${relative}`; + const entries2 = await fs.list(path2).catch((error) => { + if (error.code === "not_found") return []; + throw error; + }); + entries2.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + for (const entry2 of entries2) { + const name = `${relative}/${entry2.name}`; + if (entry2.kind === "directory") await walk(name); + else files.push([name, await file("read", `${repo}/extensions${name}`)]); } + }; + await walk(""); + const digest = await crypto.subtle.digest("SHA-256", encoder4.encode(JSON.stringify(files))); + const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + return { hash, files }; + }; + const mkdir = async (path2) => { + try { + await fs.mkdir(path2); + } catch (error) { + if (error.code !== "already_exists") throw error; } }; - let queue2 = Promise.resolve(); - const publish2 = async () => { - await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context); - ops2.op_extension_status(JSON.stringify(state)); - await fs.write("/src/sausage/.git/sausage-last-good.tmp", state.good ?? ""); - await fs.rename("/src/sausage/.git/sausage-last-good.tmp", "/src/sausage/.git/sausage-last-good"); + const snapshot2 = async ({ hash, files }) => { + const base = `/run/sausage/extensions/${hash}`; + for (const path2 of ["/run/sausage", "/run/sausage/extensions", base, `${base}/extensions`]) await mkdir(path2); + for (const [name, bytes2] of files) { + let parent = `${base}/extensions`; + for (const part of name.slice(1).split("/").slice(0, -1)) { + parent += `/${part}`; + await mkdir(parent); + } + await file("write", `${base}/extensions${name}`, bytes2); + } + return base; }; const dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -37278,42 +37305,18 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b }; return { host, open, close }; }; - const load = async (sha) => { + const load = async (copy2) => { const next = []; const staged = /* @__PURE__ */ new Map(); try { - const snapshot2 = `/run/sausage/extensions/${sha}`; - for (const path2 of ["/run/sausage", "/run/sausage/extensions"]) { - try { - await fs.mkdir(path2); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - } - try { - await fs.stat(snapshot2); - } catch { - const staging = `${snapshot2}.tmp`, archive = `${snapshot2}.tar`; - try { - await fs.mkdir(staging); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - if (await loaderGit("ls-tree", "--name-only", sha, "extensions")) { - await loaderGit("archive", "--format=tar", `--output=${archive}`, sha, "extensions"); - const result = await exec(["tar", "-xf", archive, "-C", staging], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(`tar: ${result.stderr || result.code}`); - await fs.remove(archive); - } - await fs.rename(staging, snapshot2); - } + const pathRoot = await snapshot2(copy2); const modules = []; - for (const entry2 of await fs.list(`${snapshot2}/extensions`).catch((error) => { + for (const entry2 of await fs.list(`${pathRoot}/extensions`).catch((error) => { if (error.code === "not_found") return []; throw error; })) { if (entry2.kind !== "directory") continue; - const path2 = `${snapshot2}/extensions/${entry2.name}/server.ts`; + const path2 = `${pathRoot}/extensions/${entry2.name}/server.ts`; try { await fs.stat(path2); } catch (error) { @@ -37336,7 +37339,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } const probe = createRegistry(); for (const extension of [...builtins, ...next.map((f) => f.extension)]) probe.install(extension); - for (const old2 of facets) registry2.uninstall(old2.extension); + const names2 = new Set(next.map((f) => f.extension.name)); + for (const old2 of facets) if (!names2.has(old2.extension.name)) registry2.uninstall(old2.extension); for (const extension of [...builtins, ...next.map((f) => f.extension)]) registry2.install(extension); const old = facets; facets = next; @@ -37349,7 +37353,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b facet.host.notice(`onOpen: ${String(error)}`); } } - state.good = sha; + installed = true; + state.good = copy2.hash; return null; } catch (error) { await dispose(next); @@ -37357,14 +37362,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } }; const reload = async () => { - let tip; - try { - tip = await loaderGit("rev-parse", "refs/heads/main"); - } catch (error) { - state.error = String(error); - ops2.op_extension_status(JSON.stringify(state)); - return; - } + let good; try { settings = JSON.parse(await fs.read(`${repo}/settings.json`)); } catch (error) { @@ -37376,24 +37374,39 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b `); } } - if (state.current === tip && facets.length) return; - state.current = tip; - state.error = await load(tip); - if (state.error && !facets.length && state.good && state.good !== tip) { - const failed = state.error; - const fallback = await load(state.good); - state.error = fallback ? `${failed} -Last good: ${fallback}` : failed; + try { + const copy2 = await readCopy(); + if (state.current === copy2.hash && installed) return; + state.current = copy2.hash; + state.error = await load(copy2); + if (!state.error) good = copy2; + } catch (error) { + state.error = String(error); } - await publish2(); + if (state.error && !installed) { + const saved = await harness2.snapshot(ExtensionFiles, context); + if (saved?.hash) { + const failed = state.error; + const fallback = await load(saved); + state.error = fallback ? `${failed} +Last good: ${fallback}` : failed; + } + } + await publish2(good); }; const enqueue2 = () => queue2 = queue2.then(reload); - const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]); + const rid = ops2.op_watch([`${repo}/extensions`, `${repo}/settings.json`]); + let timer; await enqueue2(); const pending = (async () => { while (active) { - await ops2.op_watch_next(rid); - if (active) await enqueue2(); + const event = await ops2.op_watch_next(rid); + if (!event || !active) break; + clearTimeout(timer); + timer = setTimeout(() => { + timer = void 0; + void enqueue2(); + }, 75); } })(); return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() { @@ -37408,7 +37421,7 @@ Last good: ${fallback}` : failed; return action(args, { host: facet.host, signal, conversationId, source: "ui" }); }, close: async () => { active = false; - commands.abort(); + clearTimeout(timer); Deno.core.close(rid); await pending; await queue2; diff --git a/host/js/undo-harness-test-bundle.js b/host/js/undo-harness-test-bundle.js index 0152a38..9f9dc24 100644 --- a/host/js/undo-harness-test-bundle.js +++ b/host/js/undo-harness-test-bundle.js @@ -24632,6 +24632,7 @@ var ops2 = Deno.core.ops; var encoder3 = new TextEncoder(); var decoder2 = new TextDecoder(); var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) }); +var ExtensionFiles = defineDoc({ kind: "sausage.extensions.files", version: 1, scope: "session", history: "latest", checkpointWhen: () => false, initial: () => ({ hash: null, files: [] }) }); var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false }); async function exec(command, options) { options.signal?.throwIfAborted(); diff --git a/host/js/watchdog-test-bundle.js b/host/js/watchdog-test-bundle.js index 91037ce..a3ab5e6 100644 --- a/host/js/watchdog-test-bundle.js +++ b/host/js/watchdog-test-bundle.js @@ -37110,6 +37110,7 @@ var ops2 = Deno.core.ops; var encoder4 = new TextEncoder(); var decoder4 = new TextDecoder(); var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) }); +var ExtensionFiles = defineDoc({ kind: "sausage.extensions.files", version: 1, scope: "session", history: "latest", checkpointWhen: () => false, initial: () => ({ hash: null, files: [] }) }); var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false }); async function file(kind, path, bytes2 = []) { const result = await ops2.op_file(kind, path, bytes2); @@ -37182,28 +37183,54 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b let state = { ...await harness2.snapshot(ExtensionState, context) ?? { good: null, current: null, error: null } }; let settings = {}; let active = true; - const commands = new AbortController(); - const loaderGit = async (...args) => { - for (let attempt = 0; ; attempt++) { - try { - const result = await exec(["git", ...args], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(result.stderr || `git exited ${result.code}`); - return result.stdout.trim(); - } catch (error) { - const message = `git ${args.join(" ")}: ${String(error)}`; - state.error = message; - ops2.op_extension_status(JSON.stringify(state)); - if (attempt === 0 && !commands.signal.aborted && String(error).includes("command timed out")) continue; - throw new Error(message); + let installed = false; + let queue2 = Promise.resolve(); + const publish2 = async (copy2) => { + await harness2.commit(async (tx) => { + Object.assign(await tx.doc(ExtensionState), state); + if (copy2) Object.assign(await tx.doc(ExtensionFiles), copy2); + }, context); + ops2.op_extension_status(JSON.stringify(state)); + }; + const readCopy = async () => { + const files = []; + const walk = async (relative) => { + const path = `${repo}/extensions${relative}`; + const entries2 = await fs.list(path).catch((error) => { + if (error.code === "not_found") return []; + throw error; + }); + entries2.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + for (const entry2 of entries2) { + const name = `${relative}/${entry2.name}`; + if (entry2.kind === "directory") await walk(name); + else files.push([name, await file("read", `${repo}/extensions${name}`)]); } + }; + await walk(""); + const digest = await crypto.subtle.digest("SHA-256", encoder4.encode(JSON.stringify(files))); + const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + return { hash, files }; + }; + const mkdir = async (path) => { + try { + await fs.mkdir(path); + } catch (error) { + if (error.code !== "already_exists") throw error; } }; - let queue2 = Promise.resolve(); - const publish2 = async () => { - await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context); - ops2.op_extension_status(JSON.stringify(state)); - await fs.write("/src/sausage/.git/sausage-last-good.tmp", state.good ?? ""); - await fs.rename("/src/sausage/.git/sausage-last-good.tmp", "/src/sausage/.git/sausage-last-good"); + const snapshot = async ({ hash, files }) => { + const base2 = `/run/sausage/extensions/${hash}`; + for (const path of ["/run/sausage", "/run/sausage/extensions", base2, `${base2}/extensions`]) await mkdir(path); + for (const [name, bytes2] of files) { + let parent = `${base2}/extensions`; + for (const part of name.slice(1).split("/").slice(0, -1)) { + parent += `/${part}`; + await mkdir(parent); + } + await file("write", `${base2}/extensions${name}`, bytes2); + } + return base2; }; const dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -37278,42 +37305,18 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b }; return { host, open, close }; }; - const load = async (sha2) => { + const load = async (copy2) => { const next = []; const staged = /* @__PURE__ */ new Map(); try { - const snapshot = `/run/sausage/extensions/${sha2}`; - for (const path of ["/run/sausage", "/run/sausage/extensions"]) { - try { - await fs.mkdir(path); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - } - try { - await fs.stat(snapshot); - } catch { - const staging = `${snapshot}.tmp`, archive = `${snapshot}.tar`; - try { - await fs.mkdir(staging); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - if (await loaderGit("ls-tree", "--name-only", sha2, "extensions")) { - await loaderGit("archive", "--format=tar", `--output=${archive}`, sha2, "extensions"); - const result = await exec(["tar", "-xf", archive, "-C", staging], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(`tar: ${result.stderr || result.code}`); - await fs.remove(archive); - } - await fs.rename(staging, snapshot); - } + const pathRoot = await snapshot(copy2); const modules = []; - for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { + for (const entry2 of await fs.list(`${pathRoot}/extensions`).catch((error) => { if (error.code === "not_found") return []; throw error; })) { if (entry2.kind !== "directory") continue; - const path = `${snapshot}/extensions/${entry2.name}/server.ts`; + const path = `${pathRoot}/extensions/${entry2.name}/server.ts`; try { await fs.stat(path); } catch (error) { @@ -37336,7 +37339,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } const probe = createRegistry(); for (const extension of [...builtins, ...next.map((f) => f.extension)]) probe.install(extension); - for (const old2 of facets) registry2.uninstall(old2.extension); + const names2 = new Set(next.map((f) => f.extension.name)); + for (const old2 of facets) if (!names2.has(old2.extension.name)) registry2.uninstall(old2.extension); for (const extension of [...builtins, ...next.map((f) => f.extension)]) registry2.install(extension); const old = facets; facets = next; @@ -37349,7 +37353,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b facet.host.notice(`onOpen: ${String(error)}`); } } - state.good = sha2; + installed = true; + state.good = copy2.hash; return null; } catch (error) { await dispose(next); @@ -37357,14 +37362,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } }; const reload = async () => { - let tip; - try { - tip = await loaderGit("rev-parse", "refs/heads/main"); - } catch (error) { - state.error = String(error); - ops2.op_extension_status(JSON.stringify(state)); - return; - } + let good; try { settings = JSON.parse(await fs.read(`${repo}/settings.json`)); } catch (error) { @@ -37376,24 +37374,39 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b `); } } - if (state.current === tip && facets.length) return; - state.current = tip; - state.error = await load(tip); - if (state.error && !facets.length && state.good && state.good !== tip) { - const failed = state.error; - const fallback = await load(state.good); - state.error = fallback ? `${failed} -Last good: ${fallback}` : failed; + try { + const copy2 = await readCopy(); + if (state.current === copy2.hash && installed) return; + state.current = copy2.hash; + state.error = await load(copy2); + if (!state.error) good = copy2; + } catch (error) { + state.error = String(error); } - await publish2(); + if (state.error && !installed) { + const saved = await harness2.snapshot(ExtensionFiles, context); + if (saved?.hash) { + const failed = state.error; + const fallback = await load(saved); + state.error = fallback ? `${failed} +Last good: ${fallback}` : failed; + } + } + await publish2(good); }; const enqueue2 = () => queue2 = queue2.then(reload); - const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]); + const rid = ops2.op_watch([`${repo}/extensions`, `${repo}/settings.json`]); + let timer; await enqueue2(); const pending = (async () => { while (active) { - await ops2.op_watch_next(rid); - if (active) await enqueue2(); + const event = await ops2.op_watch_next(rid); + if (!event || !active) break; + clearTimeout(timer); + timer = setTimeout(() => { + timer = void 0; + void enqueue2(); + }, 75); } })(); return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() { @@ -37408,7 +37421,7 @@ Last good: ${fallback}` : failed; return action(args, { host: facet.host, signal, conversationId, source: "ui" }); }, close: async () => { active = false; - commands.abort(); + clearTimeout(timer); Deno.core.close(rid); await pending; await queue2; diff --git a/host/js/workspaces-test-bundle.js b/host/js/workspaces-test-bundle.js index a3c4766..00a3009 100644 --- a/host/js/workspaces-test-bundle.js +++ b/host/js/workspaces-test-bundle.js @@ -37110,6 +37110,7 @@ var ops2 = Deno.core.ops; var encoder4 = new TextEncoder(); var decoder4 = new TextDecoder(); var ExtensionState = defineDoc({ kind: "sausage.extensions", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ good: null, current: null, error: null }) }); +var ExtensionFiles = defineDoc({ kind: "sausage.extensions.files", version: 1, scope: "session", history: "latest", checkpointWhen: () => false, initial: () => ({ hash: null, files: [] }) }); var Metadata = defineDoc({ kind: "sausage.metadata", version: 1, scope: "conversation", history: "latest", fork: "current", initial: () => ({ label: "", title: "" }), checkpointWhen: () => false }); async function file(kind, path, bytes2 = []) { const result = await ops2.op_file(kind, path, bytes2); @@ -37182,28 +37183,54 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b let state = { ...await harness2.snapshot(ExtensionState, context) ?? { good: null, current: null, error: null } }; let settings = {}; let active = true; - const commands = new AbortController(); - const loaderGit = async (...args) => { - for (let attempt = 0; ; attempt++) { - try { - const result = await exec(["git", ...args], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(result.stderr || `git exited ${result.code}`); - return result.stdout.trim(); - } catch (error) { - const message = `git ${args.join(" ")}: ${String(error)}`; - state.error = message; - ops2.op_extension_status(JSON.stringify(state)); - if (attempt === 0 && !commands.signal.aborted && String(error).includes("command timed out")) continue; - throw new Error(message); + let installed = false; + let queue2 = Promise.resolve(); + const publish2 = async (copy2) => { + await harness2.commit(async (tx) => { + Object.assign(await tx.doc(ExtensionState), state); + if (copy2) Object.assign(await tx.doc(ExtensionFiles), copy2); + }, context); + ops2.op_extension_status(JSON.stringify(state)); + }; + const readCopy = async () => { + const files = []; + const walk = async (relative) => { + const path = `${repo}/extensions${relative}`; + const entries2 = await fs.list(path).catch((error) => { + if (error.code === "not_found") return []; + throw error; + }); + entries2.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + for (const entry2 of entries2) { + const name = `${relative}/${entry2.name}`; + if (entry2.kind === "directory") await walk(name); + else files.push([name, await file("read", `${repo}/extensions${name}`)]); } + }; + await walk(""); + const digest = await crypto.subtle.digest("SHA-256", encoder4.encode(JSON.stringify(files))); + const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + return { hash, files }; + }; + const mkdir = async (path) => { + try { + await fs.mkdir(path); + } catch (error) { + if (error.code !== "already_exists") throw error; } }; - let queue2 = Promise.resolve(); - const publish2 = async () => { - await harness2.commit(async (tx) => Object.assign(await tx.doc(ExtensionState), state), context); - ops2.op_extension_status(JSON.stringify(state)); - await fs.write("/src/sausage/.git/sausage-last-good.tmp", state.good ?? ""); - await fs.rename("/src/sausage/.git/sausage-last-good.tmp", "/src/sausage/.git/sausage-last-good"); + const snapshot = async ({ hash, files }) => { + const base = `/run/sausage/extensions/${hash}`; + for (const path of ["/run/sausage", "/run/sausage/extensions", base, `${base}/extensions`]) await mkdir(path); + for (const [name, bytes2] of files) { + let parent = `${base}/extensions`; + for (const part of name.slice(1).split("/").slice(0, -1)) { + parent += `/${part}`; + await mkdir(parent); + } + await file("write", `${base}/extensions${name}`, bytes2); + } + return base; }; const dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -37278,42 +37305,18 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b }; return { host: host2, open, close }; }; - const load = async (sha) => { + const load = async (copy2) => { const next = []; const staged = /* @__PURE__ */ new Map(); try { - const snapshot = `/run/sausage/extensions/${sha}`; - for (const path of ["/run/sausage", "/run/sausage/extensions"]) { - try { - await fs.mkdir(path); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - } - try { - await fs.stat(snapshot); - } catch { - const staging = `${snapshot}.tmp`, archive = `${snapshot}.tar`; - try { - await fs.mkdir(staging); - } catch (error) { - if (error.code !== "already_exists") throw error; - } - if (await loaderGit("ls-tree", "--name-only", sha, "extensions")) { - await loaderGit("archive", "--format=tar", `--output=${archive}`, sha, "extensions"); - const result = await exec(["tar", "-xf", archive, "-C", staging], { cwd: repo, signal: commands.signal }); - if (result.code) throw new Error(`tar: ${result.stderr || result.code}`); - await fs.remove(archive); - } - await fs.rename(staging, snapshot); - } + const pathRoot = await snapshot(copy2); const modules = []; - for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { + for (const entry2 of await fs.list(`${pathRoot}/extensions`).catch((error) => { if (error.code === "not_found") return []; throw error; })) { if (entry2.kind !== "directory") continue; - const path = `${snapshot}/extensions/${entry2.name}/server.ts`; + const path = `${pathRoot}/extensions/${entry2.name}/server.ts`; try { await fs.stat(path); } catch (error) { @@ -37336,7 +37339,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } const probe2 = createRegistry(); for (const extension of [...builtins, ...next.map((f) => f.extension)]) probe2.install(extension); - for (const old2 of facets) registry2.uninstall(old2.extension); + const names2 = new Set(next.map((f) => f.extension.name)); + for (const old2 of facets) if (!names2.has(old2.extension.name)) registry2.uninstall(old2.extension); for (const extension of [...builtins, ...next.map((f) => f.extension)]) registry2.install(extension); const old = facets; facets = next; @@ -37349,7 +37353,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b facet.host.notice(`onOpen: ${String(error)}`); } } - state.good = sha; + installed = true; + state.good = copy2.hash; return null; } catch (error) { await dispose(next); @@ -37357,14 +37362,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b } }; const reload = async () => { - let tip; - try { - tip = await loaderGit("rev-parse", "refs/heads/main"); - } catch (error) { - state.error = String(error); - ops2.op_extension_status(JSON.stringify(state)); - return; - } + let good; try { settings = JSON.parse(await fs.read(`${repo}/settings.json`)); } catch (error) { @@ -37376,24 +37374,39 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b `); } } - if (state.current === tip && facets.length) return; - state.current = tip; - state.error = await load(tip); - if (state.error && !facets.length && state.good && state.good !== tip) { - const failed = state.error; - const fallback = await load(state.good); - state.error = fallback ? `${failed} -Last good: ${fallback}` : failed; + try { + const copy2 = await readCopy(); + if (state.current === copy2.hash && installed) return; + state.current = copy2.hash; + state.error = await load(copy2); + if (!state.error) good = copy2; + } catch (error) { + state.error = String(error); } - await publish2(); + if (state.error && !installed) { + const saved = await harness2.snapshot(ExtensionFiles, context); + if (saved?.hash) { + const failed = state.error; + const fallback = await load(saved); + state.error = fallback ? `${failed} +Last good: ${fallback}` : failed; + } + } + await publish2(good); }; const enqueue2 = () => queue2 = queue2.then(reload); - const rid = ops2.op_watch([`${repo}/.git/refs/heads/main`, `${repo}/.git/packed-refs`, `${repo}/settings.json`]); + const rid = ops2.op_watch([`${repo}/extensions`, `${repo}/settings.json`]); + let timer; await enqueue2(); const pending = (async () => { while (active) { - await ops2.op_watch_next(rid); - if (active) await enqueue2(); + const event = await ops2.op_watch_next(rid); + if (!event || !active) break; + clearTimeout(timer); + timer = setTimeout(() => { + timer = void 0; + void enqueue2(); + }, 75); } })(); return { reload: enqueue2, state: () => ({ ...state }), async mainAgent() { @@ -37408,7 +37421,7 @@ Last good: ${fallback}` : failed; return action(args, { host: facet.host, signal, conversationId, source: "ui" }); }, close: async () => { active = false; - commands.abort(); + clearTimeout(timer); Deno.core.close(rid); await pending; await queue2; diff --git a/host/src/world.rs b/host/src/world.rs index 5ca5023..ffd3d14 100644 --- a/host/src/world.rs +++ b/host/src/world.rs @@ -44,7 +44,7 @@ fn copy_tree(source: &Path, destination: &Path) -> std::io::Result<()> { } impl World { - /// Initialize a fresh world and commit its plain bundled source on main. + /// Initialize a fresh world with plain bundled source files, without a Git repository. pub async fn init(dir: &Path, bundle: &Path) -> anyhow::Result { Self::init_with_progress(dir, bundle, |_| {}).await } @@ -60,44 +60,8 @@ impl World { "Create world folder failed: World already exists: {}", dir.display() ); - let world = Self::open_with_progress(dir, Some(bundle), &mut progress) - .context("Create world folder and seed files failed")?; - progress("Create the first commit in /src/sausage"); - let exec = crate::process::exec(Self { - root: world.root.clone(), - host_dir: world.host_dir.clone(), - processes: world.processes.clone(), - }) - .context("Create source repository failed")?; - for args in [ - vec!["init", "-b", "main"], - vec!["add", "."], - vec!["commit", "--no-gpg-sign", "-m", "Initial world source"], - ] { - let argv = [ - vec!["/bin/git.wasm".to_owned()], - args.into_iter().map(str::to_owned).collect(), - ] - .concat(); - let mut output = Vec::new(); - let code = exec - .exec( - crate::exec::Command::Argv(argv), - crate::exec::Options { - cwd: "/src/sausage".into(), - ..Default::default() - }, - &mut |_, bytes| output.extend_from_slice(bytes), - ) - .await - .context("Create source repository failed")?; - anyhow::ensure!( - code == 0, - "Cannot initialize source repository: {}", - String::from_utf8_lossy(&output) - ); - } - Ok(world) + Self::open_with_progress(dir, Some(bundle), &mut progress) + .context("Create world folder and seed files failed") } /// Open (and create if missing) the world directory `dir`, including `/proc` and `/run` mounts. diff --git a/host/tests/it/core_ext.rs b/host/tests/it/core_ext.rs index e09deb9..b90c80c 100644 --- a/host/tests/it/core_ext.rs +++ b/host/tests/it/core_ext.rs @@ -45,15 +45,9 @@ fn write(root: &Path, path: &str, text: &str) -> anyhow::Result<()> { Ok(()) } #[tokio::test(flavor = "current_thread")] -async fn init_seeds_config_tools_and_one_source_commit() -> anyhow::Result<()> { +async fn init_seeds_config_tools_and_plain_source() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; let bundle = dir.path().join("bundle"); - let target = crate::common::build_git(); - std::fs::create_dir_all(bundle.join("bin"))?; - std::fs::copy( - target.join("wasm32-wasip2/release/git.wasm"), - bundle.join("bin/git.wasm"), - )?; write(&bundle, "bin/unused.wasm", "not a component")?; write(&bundle, "src/sausage/README.md", "World source")?; write( @@ -84,14 +78,13 @@ async fn init_seeds_config_tools_and_one_source_commit() -> anyhow::Result<()> { lines, [ format!("Create world folder: {}", destination.display()), - "Copy tools: 2".into(), + "Copy tools: 1".into(), "Create /src/sausage".into(), "Seed /etc and /srv/www".into(), format!( "Write config: {}", destination.join("world/etc/sausage/config.yaml").display() ), - "Create the first commit in /src/sausage".into(), "Next steps:".into(), format!( " Edit {} to configure providers and API keys.", @@ -106,7 +99,7 @@ async fn init_seeds_config_tools_and_one_source_commit() -> anyhow::Result<()> { "World page" ); let world = World::open(&destination)?; - assert!(destination.join("world/bin/git").exists()); + assert!(destination.join("world/bin/unused").exists()); let config = world .root .open("/etc/sausage/config.yaml", Default::default()) @@ -116,21 +109,11 @@ async fn init_seeds_config_tools_and_one_source_commit() -> anyhow::Result<()> { assert!(!config.contains("fixture-key")); assert_eq!(config, include_str!("../../../config/config.yaml")); let source = destination.join("world/src/sausage"); - for (args, expected) in [ - (vec!["symbolic-ref", "HEAD"], "refs/heads/main"), - (vec!["rev-list", "--count", "HEAD"], "1"), - ] { - let result = std::process::Command::new("git") - .args(args) - .current_dir(&source) - .output()?; - assert!( - result.status.success(), - "{}", - String::from_utf8_lossy(&result.stderr) - ); - assert_eq!(String::from_utf8(result.stdout)?.trim(), expected); - } + assert!(!source.join(".git").exists()); + assert_eq!( + std::fs::read_to_string(source.join("README.md"))?, + "World source" + ); assert!( World::init(&destination, &bundle) .await @@ -148,11 +131,11 @@ fn init_errors_name_the_failed_step() -> anyhow::Result<()> { let bundle = dir.path().join("bundle"); for (name, expected) in [ ("missing", "Copy tools failed"), - ("invalid", "Create source repository failed"), + ("invalid", "Create /src/sausage failed"), ] { if name == "invalid" { write(&bundle, "bin/git.wasm", "not a component")?; - write(&bundle, "src/sausage/README.md", "Source")?; + write(&bundle, "src", "not a directory")?; } let output = std::process::Command::new(env!("CARGO_BIN_EXE_sausage")) .arg("init") @@ -212,7 +195,10 @@ async fn core_extensions() -> anyhow::Result<()> { "etc/sausage/config.yaml", include_str!("../../../config/config.yaml"), )?; - copy_tree(&repo.join("config/roster"), &disk.join("etc/sausage/roster"))?; + copy_tree( + &repo.join("config/roster"), + &disk.join("etc/sausage/roster"), + )?; write( &source, "skills/builtin/SKILL.md", diff --git a/host/tests/it/extensions.rs b/host/tests/it/extensions.rs index 3d53ddf..efa3b90 100644 --- a/host/tests/it/extensions.rs +++ b/host/tests/it/extensions.rs @@ -1,272 +1,46 @@ -//! Commit snapshots and last-good recovery with world git and a faux model. +//! File reloads, immutable calls, debounce, offline edits, and host-side recovery without subprocesses. use sausage::{ exec::{Command, Exec, Options, Stream}, world::World, }; -use std::{path::Path, sync::Arc}; +use std::sync::Arc; const BUNDLE: &str = include_str!("../../js/extensions-test-bundle.js"); -struct Author(Arc); +struct NoExec; #[async_trait::async_trait] -impl Exec for Author { +impl Exec for NoExec { async fn exec( &self, command: Command, - mut options: Options, - output: &mut (dyn for<'a> FnMut(Stream, &'a [u8]) + Send), + _: Options, + _: &mut (dyn for<'a> FnMut(Stream, &'a [u8]) + Send), ) -> anyhow::Result { - for (key, value) in [ - ("GIT_AUTHOR_NAME", "Test"), - ("GIT_AUTHOR_EMAIL", "test@example.com"), - ("GIT_COMMITTER_NAME", "Test"), - ("GIT_COMMITTER_EMAIL", "test@example.com"), - ] { - options.env.push((key.into(), value.into())); - } - let label = format!("{command:?}"); - let budget = options.timeout; - let started = std::time::Instant::now(); - let result = self.0.exec(command, options, output).await; - eprintln!( - "Extension {label}: deadline {budget:?}, elapsed {:?}, result {result:?}", - started.elapsed() - ); - result + anyhow::bail!("Extension loading must not spawn commands: {command:?}") } } #[tokio::test(flavor = "current_thread")] -async fn commits_and_restart() -> anyhow::Result<()> { - let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland"); - let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap(); - if !userland.join("cwd-init/init.o").is_file() { - crate::common::cwd_init(); - } - assert!( - std::process::Command::new("cargo") - .args([ - "build", - "--release", - "-p", - "git", - "-p", - "sausage-exec-tools", - "-p", - "sausage-archive-tools", - "--manifest-path" - ]) - .arg(userland.join("../Cargo.toml")) - .args(["--target", "wasm32-wasip2", "--target-dir"]) - .arg(target) - .args(["-j", "2", "--locked"]) - .current_dir(&userland) - .status()? - .success() - ); +async fn edits_and_restart() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; - let world = World::open(dir.path())?; - let disk = dir.path().join("world"); - std::fs::create_dir_all(disk.join("bin"))?; - std::fs::copy( - target.join("wasm32-wasip2/release/git.wasm"), - disk.join("bin/git.wasm"), - )?; - for name in ["extensions-revert", "env", "tar"] { - std::fs::copy( - target.join(format!("wasm32-wasip2/release/{name}.wasm")), - disk.join(format!("bin/{name}.wasm")), - )?; - } - let repo = disk.join("src/sausage"); + let repo = dir.path().join("world/src/sausage"); std::fs::create_dir_all(&repo)?; - assert!( - std::process::Command::new("git") - .args(["init", "-b", "main"]) - .arg(&repo) - .status()? - .success() - ); - assert!( - std::process::Command::new("git") - .args([ - "-c", - "user.name=Test", - "-c", - "user.email=test@example.com", - "commit", - "--no-gpg-sign", - "--allow-empty", - "-m", - "empty" - ]) - .current_dir(&repo) - .status()? - .success() - ); - let exec: Arc = Arc::new(Author(sausage::process::exec(World { - root: world.root.clone(), - host_dir: world.host_dir.clone(), - processes: world.processes.clone(), - })?)); - sausage::harness::run_module(world, exec, BUNDLE).await?; - let good = std::fs::read_to_string(repo.join("good.txt"))?; - let world = World::open(dir.path())?; - let exec: Arc = Arc::new(Author(sausage::process::exec(World { - root: world.root.clone(), - host_dir: world.host_dir.clone(), - processes: world.processes.clone(), - })?)); - sausage::harness::run_module( - world, - exec, - &format!("globalThis.extensionRestart=true;globalThis.extensionGood={good:?};\n{BUNDLE}"), - ) - .await?; - Ok(()) -} - -/// Model loader commands without delays, and require cancellation instead of a wall-clock deadline. -struct LoaderCommand { - root: Arc, - failures: usize, - attempts: std::sync::atomic::AtomicUsize, - timeout: bool, -} -#[async_trait::async_trait] -impl Exec for LoaderCommand { - async fn exec( - &self, - command: Command, - options: Options, - output: &mut (dyn for<'a> FnMut(Stream, &'a [u8]) + Send), - ) -> anyhow::Result { - let Command::Argv(args) = command else { - anyhow::bail!("unexpected shell") - }; - assert!( - options.timeout.is_none(), - "Loader work must not have a wall-clock deadline" - ); - if args[0] == "tar" { - return Ok(0); + for (phase, expected) in [ + ("edits", "C"), + ("broken", "C"), + ("offline", "D"), + ("broken", "D"), + ] { + if phase == "offline" { + std::fs::copy( + repo.join("offline-source.txt"), + repo.join("extensions/demo/server.ts"), + )?; + } else if phase == "broken" { + std::fs::write(repo.join("extensions/demo/server.ts"), "syntax !!! error")?; } - match args[1].as_str() { - "rev-parse" => output(Stream::Stdout, b"new\n"), - "ls-tree" => output(Stream::Stdout, b"extensions\n"), - "archive" => { - let attempt = self - .attempts - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if attempt < self.failures && self.timeout { - anyhow::bail!("command timed out"); - } - if attempt < self.failures { - anyhow::bail!("checkout failed"); - } - if attempt > 0 { - let status = self - .root - .open("/proc/extensions", Default::default()) - .await?; - let status = String::from_utf8(status.read(0, 65536).await?)?; - assert!( - status.contains("command timed out"), - "Retry must publish its error: {status}" - ); - } - let archive = args[3].strip_prefix("--output=").unwrap(); - let file = self - .root - .open( - archive, - sausage_vfs::Open { - write: true, - create: true, - ..Default::default() - }, - ) - .await?; - file.write(0, vec![]).await?; - let snapshot = format!("{}.tmp", archive.strip_suffix(".tar").unwrap()); - for path in [ - format!("{snapshot}/extensions"), - format!("{snapshot}/extensions/demo"), - ] { - self.root.mkdir(&path).await?; - } - let file = self - .root - .open( - &format!("{snapshot}/extensions/demo/server.ts"), - sausage_vfs::Open { - write: true, - create: true, - ..Default::default() - }, - ) - .await?; - file.write(0, b"export default {name:'demo'};".to_vec()) - .await?; - } - _ => anyhow::bail!("unexpected command {args:?}"), - } - Ok(0) + let world = World::open(dir.path())?; + sausage::harness::run_module(world, Arc::new(NoExec), &format!( + "globalThis.extensionPhase={phase:?};globalThis.extensionExpected={expected:?};\n{BUNDLE}" + )).await?; + assert!(!repo.join(".git").exists()); } -} -async fn loader_command(failures: usize, timeout: bool) -> anyhow::Result<()> { - let dir = tempfile::tempdir()?; - let world = World::open(dir.path())?; - std::fs::create_dir_all(dir.path().join("world/src/sausage/.git"))?; - let exec = Arc::new(LoaderCommand { - root: world.root.clone(), - failures, - timeout, - attempts: std::sync::atomic::AtomicUsize::new(0), - }); - let (library, _) = include_str!("../../js/bundle.js") - .split_once("// main.ts") - .unwrap(); - let expected = if failures > 1 || !timeout && failures > 0 { - "old" - } else { - "new" - }; - let source = format!( - r#"{library} - const testRegistry = createRegistry(); - const testStorage = await openStorage(globalThis.storagePath); - const testHarness = await Harness.open(testStorage, {{models:createModels(),registry:testRegistry}}, BACKGROUND_CONTEXT); - await testHarness.root(BACKGROUND_CONTEXT); - await testHarness.commit(async tx => Object.assign(await tx.doc(ExtensionState), {{good:'old',current:'old',error:null}}), BACKGROUND_CONTEXT); - for (const path of ['/run/sausage','/run/sausage/extensions','/run/sausage/extensions/old','/run/sausage/extensions/old/extensions','/run/sausage/extensions/old/extensions/demo']) await fs2.mkdir(path); - await fs2.write('/run/sausage/extensions/old/extensions/demo/server.ts', "export default {{name:'demo'}};"); - const testLoader = await loadExtensions(testHarness,testRegistry,testStorage,createModels(),BACKGROUND_CONTEXT); - const testState = testLoader.state(); - if(testState.good !== '{expected}') throw new Error(JSON.stringify(testState)); - if((testState.error !== null) !== ({expected:?} === 'old')) throw new Error(JSON.stringify(testState)); - if(testState.error && !testState.error.includes('git archive')) throw new Error(testState.error); - await testLoader.close(); - await testHarness.close(BACKGROUND_CONTEXT); - "# - ); - sausage::harness::run_module(world, exec.clone(), &source).await?; - assert_eq!( - exec.attempts.load(std::sync::atomic::Ordering::SeqCst), - if timeout && failures > 0 { 2 } else { 1 } - ); Ok(()) } -#[tokio::test(flavor = "current_thread")] -async fn slow_loader_command() -> anyhow::Result<()> { - loader_command(0, true).await -} -#[tokio::test(flavor = "current_thread")] -async fn loader_timeout_retries_once() -> anyhow::Result<()> { - loader_command(1, true).await -} -#[tokio::test(flavor = "current_thread")] -async fn loader_timeout_keeps_last_good() -> anyhow::Result<()> { - loader_command(2, true).await -} -#[tokio::test(flavor = "current_thread")] -async fn loader_real_failure_is_not_retried() -> anyhow::Result<()> { - loader_command(1, false).await -} diff --git a/host/tests/it/p1.rs b/host/tests/it/p1.rs index e189df8..2d503f0 100644 --- a/host/tests/it/p1.rs +++ b/host/tests/it/p1.rs @@ -1,4 +1,4 @@ -//! P1 integration: bundle contents, source commits, extension loading, and real shell calls. +//! P1 integration: plain bundled source, extension file snapshots, and real shell calls. use sausage::{harness::run_module, world::World}; use sausage_vfs::Open; use std::path::PathBuf; @@ -46,21 +46,9 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { ] { assert!(snapshot.join(path).is_file(), "missing bundled {path}"); } - let git = |path: &std::path::Path, args: &[&str]| -> anyhow::Result { - let output = std::process::Command::new("git") - .arg("-C") - .arg(path) - .args(args) - .output()?; - anyhow::ensure!( - output.status.success(), - "git {args:?}: {}", - String::from_utf8_lossy(&output.stderr) - ); - Ok(String::from_utf8(output.stdout)?.trim().to_owned()) - }; - let message = git(&snapshot, &["log", "-1", "--format=%s"])?; - assert_eq!(message, "Initial world source"); + assert!(!snapshot.join(".git").exists()); + assert!(!dir.path().join("world/bin/extensions-revert").exists()); + assert!(!dir.path().join("world/bin/extensions-revert.wasm").exists()); for path in ["config", "skills", "extensions"] { let difference = std::process::Command::new("git") .args(["diff", "--no-index", "--exit-code", "--"]) @@ -189,38 +177,6 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { assert_eq!(out, native.stdout, "{script}"); } } - let mut history = Vec::new(); - assert_eq!( - exec.exec( - Command::Shell("cd /src/sausage && git log --oneline".into()), - Options::default(), - &mut |stream, bytes| { - if matches!(stream, Stream::Stdout) { - history.extend_from_slice(bytes); - } - }, - ) - .await?, - 0, - ); - let history = String::from_utf8(history)?; - assert_eq!(history.lines().count(), 1); - assert!(history.contains("Initial world source")); - let mut status = Vec::new(); - assert_eq!( - exec.exec( - Command::Shell("cd /src/sausage && git status --short".into()), - Options::default(), - &mut |stream, bytes| { - if matches!(stream, Stream::Stdout) { - status.extend_from_slice(bytes); - } - }, - ) - .await?, - 0, - ); - assert!(status.is_empty(), "{}", String::from_utf8_lossy(&status)); let source = format!( "globalThis.testP1 = true;\n{}", include_str!("../../js/test-bundle.js") @@ -355,9 +311,13 @@ async fn bundled_extensions_load() -> anyhow::Result<()> { run_module(world, std::sync::Arc::new(LoaderTiming(exec)), &source).await?; let status = root.open("/proc/extensions", Open::default()).await?; let status: serde_json::Value = serde_json::from_slice(&status.read(0, 65536).await?)?; - let commit = - std::fs::read_to_string(dir.path().join("world/src/sausage/.git/refs/heads/main"))?; - assert_eq!(status["good"].as_str(), Some(commit.trim()), "{status}"); + let hash = status["good"].as_str().expect("extension content hash"); + assert_eq!(hash.len(), 64, "{status}"); + assert!( + hash.bytes().all(|byte| byte.is_ascii_hexdigit()), + "{status}" + ); + assert_eq!(status["good"], status["current"], "{status}"); assert!(status["error"].is_null(), "{status}"); Ok(()) } diff --git a/userland/exec-tools/Cargo.toml b/userland/exec-tools/Cargo.toml index 4458d8a..b95371b 100644 --- a/userland/exec-tools/Cargo.toml +++ b/userland/exec-tools/Cargo.toml @@ -15,9 +15,5 @@ path = "src/which.rs" name = "timeout" path = "src/timeout.rs" -[[bin]] -name = "extensions-revert" -path = "src/revert.rs" - [dependencies] sausage-spawn = { path = "../spawn" } diff --git a/userland/exec-tools/src/revert.rs b/userland/exec-tools/src/revert.rs deleted file mode 100644 index 5fe5254..0000000 --- a/userland/exec-tools/src/revert.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! Move the world's extension branch to its last successful commit. -fn run() -> std::io::Result { - let good = std::fs::read_to_string("/src/sausage/.git/sausage-last-good")?; - if good.trim().is_empty() { - return Err(std::io::Error::other("No successful extension commit")); - } - sausage_spawn::Command::new("git") - .args([ - "-C", - "/src/sausage", - "update-ref", - "refs/heads/main", - good.trim(), - ]) - .status() -} -fn main() { - std::process::exit(match run() { - Ok(code) => code, - Err(error) => { - eprintln!("extensions-revert: {error}"); - 1 - } - }); -}