Reload extension file snapshots without Git
This commit is contained in:
+1
-1
@@ -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/<id>.
|
||||
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/<name>.md. Project definitions live in <repo>/.sausage/roster/*.md.
|
||||
You can read and edit the config and the roster files. Changes apply at the next run.
|
||||
|
||||
+11
-15
@@ -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).
|
||||
|
||||
|
||||
+25
-29
@@ -1,29 +1,35 @@
|
||||
# World extensions
|
||||
|
||||
The harness loads `/src/sausage` from its `main` branch.
|
||||
Each extension occupies `extensions/<name>/server.ts` in that repository.
|
||||
The harness loads files under `/src/sausage/extensions/`.
|
||||
Each extension occupies `<name>/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/<sha>`.
|
||||
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/<hash>/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, `<dir>/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.
|
||||
|
||||
@@ -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))),
|
||||
|
||||
+83
-70
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+403
-418
File diff suppressed because it is too large
Load Diff
+40
-57
@@ -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>|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);}
|
||||
|
||||
+71
-54
@@ -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<durable.Storage, 'scanSubmissions'>;
|
||||
@@ -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<Copy> => {
|
||||
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.name<b.name?-1:a.name>b.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<string,any>) => {
|
||||
@@ -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<string,any>();
|
||||
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<typeof setTimeout>|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);}};
|
||||
}
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+83
-70
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+3
-39
@@ -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> {
|
||||
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.
|
||||
|
||||
+14
-28
@@ -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",
|
||||
|
||||
+27
-253
@@ -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<dyn Exec>);
|
||||
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<i32> {
|
||||
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<dyn Exec> = 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<dyn Exec> = 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<dyn sausage_vfs::Layer>,
|
||||
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<i32> {
|
||||
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
|
||||
}
|
||||
|
||||
+11
-51
@@ -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<String> {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
//! Move the world's extension branch to its last successful commit.
|
||||
fn run() -> std::io::Result<i32> {
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user