diff --git a/host/js/automations-test-bundle.js b/host/js/automations-test-bundle.js index 9524614..4222933 100644 --- a/host/js/automations-test-bundle.js +++ b/host/js/automations-test-bundle.js @@ -32712,7 +32712,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b const loaderGit = async (...args) => { for (let attempt = 0; ; attempt++) { try { - const result = await exec(["git", ...args], { cwd: repo, timeout: 120, signal: commands.signal }); + 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) { @@ -32728,7 +32728,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b 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", state.good ?? ""); + 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 dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -32818,8 +32819,19 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b try { await fs.stat(snapshot); } catch { - await loaderGit("worktree", "prune"); - await loaderGit("worktree", "add", "--detach", snapshot, sha); + 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 modules = []; for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { diff --git a/host/js/bundle.js b/host/js/bundle.js index bc45b85..b6ca2fb 100644 --- a/host/js/bundle.js +++ b/host/js/bundle.js @@ -70720,7 +70720,7 @@ async function loadExtensions(harness2, registry3, storage2, models2, context, b const loaderGit = async (...args) => { for (let attempt = 0; ; attempt++) { try { - const result = await exec(["git", ...args], { cwd: repo, timeout: 120, signal: commands.signal }); + 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) { @@ -70736,7 +70736,8 @@ async function loadExtensions(harness2, registry3, storage2, models2, context, b 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", state2.good ?? ""); + 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 dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -70826,8 +70827,19 @@ async function loadExtensions(harness2, registry3, storage2, models2, context, b try { await fs2.stat(snapshot); } catch { - await loaderGit("worktree", "prune"); - await loaderGit("worktree", "add", "--detach", snapshot, sha); + 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 modules = []; for (const entry2 of await fs2.list(`${snapshot}/extensions`).catch((error) => { diff --git a/host/js/core-ext-test-bundle.js b/host/js/core-ext-test-bundle.js index c30a607..c29fe05 100644 --- a/host/js/core-ext-test-bundle.js +++ b/host/js/core-ext-test-bundle.js @@ -31486,7 +31486,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b const loaderGit = async (...args) => { for (let attempt = 0; ; attempt++) { try { - const result = await exec(["git", ...args], { cwd: repo, timeout: 120, signal: commands.signal }); + 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) { @@ -31502,7 +31502,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b 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", state.good ?? ""); + 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 dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -31592,8 +31593,19 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b try { await fs.stat(snapshot); } catch { - await loaderGit("worktree", "prune"); - await loaderGit("worktree", "add", "--detach", snapshot, sha); + 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 modules = []; for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { diff --git a/host/js/dream-test-bundle.js b/host/js/dream-test-bundle.js index 4186152..56d5ff0 100644 --- a/host/js/dream-test-bundle.js +++ b/host/js/dream-test-bundle.js @@ -30546,7 +30546,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b const loaderGit = async (...args) => { for (let attempt = 0; ; attempt++) { try { - const result = await exec(["git", ...args], { cwd: repo, timeout: 120, signal: commands.signal }); + 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) { @@ -30562,7 +30562,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b 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", state.good ?? ""); + 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 dispose = async (entries3) => { for (const facet of entries3) for (const cleanup of facet.close) { @@ -30652,8 +30653,19 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b try { await fs.stat(snapshot); } catch { - await loaderGit("worktree", "prune"); - await loaderGit("worktree", "add", "--detach", snapshot, sha); + 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 modules = []; for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { diff --git a/host/js/events-test-bundle.js b/host/js/events-test-bundle.js index 8f64bef..620c57e 100644 --- a/host/js/events-test-bundle.js +++ b/host/js/events-test-bundle.js @@ -31486,7 +31486,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b const loaderGit = async (...args) => { for (let attempt = 0; ; attempt++) { try { - const result = await exec(["git", ...args], { cwd: repo, timeout: 120, signal: commands.signal }); + 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) { @@ -31502,7 +31502,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b 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", state.good ?? ""); + 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 dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -31592,8 +31593,19 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b try { await fs.stat(snapshot); } catch { - await loaderGit("worktree", "prune"); - await loaderGit("worktree", "add", "--detach", snapshot, sha); + 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 modules = []; for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { diff --git a/host/js/extensions-test-bundle.js b/host/js/extensions-test-bundle.js index 9cc4374..fb57624 100644 --- a/host/js/extensions-test-bundle.js +++ b/host/js/extensions-test-bundle.js @@ -31486,7 +31486,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b const loaderGit = async (...args) => { for (let attempt = 0; ; attempt++) { try { - const result = await exec(["git", ...args], { cwd: repo, timeout: 120, signal: commands.signal }); + 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) { @@ -31502,7 +31502,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b 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", state.good ?? ""); + 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 dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -31592,8 +31593,19 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b try { await fs.stat(snapshot); } catch { - await loaderGit("worktree", "prune"); - await loaderGit("worktree", "add", "--detach", snapshot, sha); + 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 modules = []; for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { @@ -31720,6 +31732,9 @@ var harness = await Harness.open(storage, { models, registry, settings: { get ex } } }, BACKGROUND_CONTEXT); var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "faux", modelId: "faux-1" } } }); globalThis.shared = { gate: Promise.withResolvers(), started: false, wait: false }; +var markerWrite = fs.write; +var markerBlocked = Promise.withResolvers(); +var markerRelease = Promise.withResolvers(); var loader = await loadExtensions(harness, registry, storage, models, BACKGROUND_CONTEXT); harness.resume(); var turn = async () => { @@ -31783,15 +31798,30 @@ return defineExtension({name:'demo',tools:[defineTool({name:'demo',description:' 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"); + await loader.reload(); + fs.write = async (path, text) => { + if (path.includes("sausage-last-good")) { + fs.write = markerWrite; + await markerWrite(path, ""); + markerBlocked.resolve(); + await markerRelease.promise; + } + return markerWrite(path, text); + }; await git("/src/sausage", "update-ref", "refs/heads/main", b); await until2(() => loader.state().current === b && !!loader.state().error); - assert((await exec(["extensions-revert"], { cwd: "/" })).code === 0, "Revert command"); + await markerBlocked.promise; + const reverted = await exec(["extensions-revert"], { cwd: "/" }); + markerRelease.resolve(); + assert(reverted.code === 0, `Revert command: ${JSON.stringify(reverted)}`); await until2(() => loader.state().current === c && !loader.state().error); await git("/src/sausage", "update-ref", "refs/heads/main", b); await until2(() => loader.state().current === b && !!loader.state().error); await fs.write("/src/sausage/good.txt", c); } } finally { + markerRelease.resolve(); + fs.write = markerWrite; globalThis.shared.gate.resolve(); await loader.close(); await harness.close(BACKGROUND_CONTEXT); diff --git a/host/js/extensions-test.ts b/host/js/extensions-test.ts index 75ff8d9..0f20dfd 100644 --- a/host/js/extensions-test.ts +++ b/host/js/extensions-test.ts @@ -12,6 +12,7 @@ const registry=createRegistry(), storage=await openStorage(globalThis.storagePat 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(); const loader=await loadExtensions(harness,registry,storage,models,context); harness.resume(); const turn = async()=> { @@ -67,12 +68,24 @@ return defineExtension({name:'demo',tools:[defineTool({name:'demo',description:' 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'); + 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); - assert((await exec(['extensions-revert'],{cwd:'/'})).code===0,'Revert command'); + 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); } -} finally {globalThis.shared.gate.resolve();await loader.close();await harness.close(context);} +} finally {markerRelease.resolve();fs.write=markerWrite;globalThis.shared.gate.resolve();await loader.close();await harness.close(context);} diff --git a/host/js/extensions.ts b/host/js/extensions.ts index 980d4a5..493a087 100644 --- a/host/js/extensions.ts +++ b/host/js/extensions.ts @@ -84,11 +84,11 @@ export async function loadExtensions(harness:any, registry:any, storage:any, mod let settings:any = {}; let active = true; const commands = new AbortController(); - // Snapshot checkout copies the source tree. The tool helper's 30 seconds is too short under load. + // 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,timeout:120,signal:commands.signal}); + 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) { @@ -104,7 +104,8 @@ export async function loadExtensions(harness:any, registry:any, storage:any, mod const publish = async () => { await harness.commit(async (tx:any)=>Object.assign(await tx.doc(ExtensionState),state),context); ops.op_extension_status(JSON.stringify(state)); - await fs.write('/src/sausage/.git/sausage-last-good',state.good ?? ''); + 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 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) => { @@ -151,7 +152,18 @@ export async function loadExtensions(harness:any, registry:any, storage:any, mod 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 {await loaderGit('worktree','prune');await loaderGit('worktree','add','--detach',snapshot,sha);} + 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 modules:any[] = []; for(const entry of await fs.list(`${snapshot}/extensions`).catch(error=>{if(error.code==='not_found')return [];throw error;})) { if(entry.kind!=='directory')continue; diff --git a/host/js/goal-questions-test-bundle.js b/host/js/goal-questions-test-bundle.js index c919fb7..c9a1fa4 100644 --- a/host/js/goal-questions-test-bundle.js +++ b/host/js/goal-questions-test-bundle.js @@ -32712,7 +32712,7 @@ async function loadExtensions(harness, registry, storage, models, context, built const loaderGit = async (...args) => { for (let attempt = 0; ; attempt++) { try { - const result = await exec(["git", ...args], { cwd: repo, timeout: 120, signal: commands.signal }); + 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) { @@ -32728,7 +32728,8 @@ async function loadExtensions(harness, registry, storage, models, context, built 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", state.good ?? ""); + 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 dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -32818,8 +32819,19 @@ async function loadExtensions(harness, registry, storage, models, context, built try { await fs.stat(snapshot); } catch { - await loaderGit("worktree", "prune"); - await loaderGit("worktree", "add", "--detach", snapshot, sha); + 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 modules = []; for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { diff --git a/host/js/harness-api-test-bundle.js b/host/js/harness-api-test-bundle.js index d88b46f..cbd9409 100644 --- a/host/js/harness-api-test-bundle.js +++ b/host/js/harness-api-test-bundle.js @@ -31486,7 +31486,7 @@ async function loadExtensions(harness2, registry, storage, models, context, buil const loaderGit = async (...args) => { for (let attempt = 0; ; attempt++) { try { - const result = await exec(["git", ...args], { cwd: repo, timeout: 120, signal: commands.signal }); + 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) { @@ -31502,7 +31502,8 @@ async function loadExtensions(harness2, registry, storage, models, context, buil 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", state.good ?? ""); + 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 dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -31592,8 +31593,19 @@ async function loadExtensions(harness2, registry, storage, models, context, buil try { await fs.stat(snapshot); } catch { - await loaderGit("worktree", "prune"); - await loaderGit("worktree", "add", "--detach", snapshot, sha); + 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 modules = []; for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { diff --git a/host/js/messages-test-bundle.js b/host/js/messages-test-bundle.js index 802de89..3223328 100644 --- a/host/js/messages-test-bundle.js +++ b/host/js/messages-test-bundle.js @@ -30546,7 +30546,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b const loaderGit = async (...args) => { for (let attempt = 0; ; attempt++) { try { - const result = await exec(["git", ...args], { cwd: repo, timeout: 120, signal: commands.signal }); + 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) { @@ -30562,7 +30562,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b 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", state.good ?? ""); + 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 dispose = async (entries3) => { for (const facet of entries3) for (const cleanup of facet.close) { @@ -30652,8 +30653,19 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b try { await fs.stat(snapshot2); } catch { - await loaderGit("worktree", "prune"); - await loaderGit("worktree", "add", "--detach", snapshot2, sha); + 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 modules = []; for (const entry2 of await fs.list(`${snapshot2}/extensions`).catch((error) => { diff --git a/host/js/p3-test-bundle.js b/host/js/p3-test-bundle.js index 0ca6783..108ebbc 100644 --- a/host/js/p3-test-bundle.js +++ b/host/js/p3-test-bundle.js @@ -30546,7 +30546,7 @@ async function loadExtensions(harness, registry, storage, models, context, built const loaderGit = async (...args) => { for (let attempt = 0; ; attempt++) { try { - const result = await exec(["git", ...args], { cwd: repo, timeout: 120, signal: commands.signal }); + 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) { @@ -30562,7 +30562,8 @@ async function loadExtensions(harness, registry, storage, models, context, built 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", state.good ?? ""); + 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 dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -30652,8 +30653,19 @@ async function loadExtensions(harness, registry, storage, models, context, built try { await fs.stat(snapshot); } catch { - await loaderGit("worktree", "prune"); - await loaderGit("worktree", "add", "--detach", snapshot, sha); + 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 modules = []; for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { diff --git a/host/js/plan-todos-test-bundle.js b/host/js/plan-todos-test-bundle.js index f6c110b..3477795 100644 --- a/host/js/plan-todos-test-bundle.js +++ b/host/js/plan-todos-test-bundle.js @@ -31486,7 +31486,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b const loaderGit = async (...args) => { for (let attempt = 0; ; attempt++) { try { - const result = await exec(["git", ...args], { cwd: repo, timeout: 120, signal: commands.signal }); + 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) { @@ -31502,7 +31502,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b 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", state.good ?? ""); + 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 dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -31592,8 +31593,19 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b try { await fs.stat(snapshot); } catch { - await loaderGit("worktree", "prune"); - await loaderGit("worktree", "add", "--detach", snapshot, sha); + 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 modules = []; for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { diff --git a/host/js/subagents-test-bundle.js b/host/js/subagents-test-bundle.js index c7efcc1..cccd735 100644 --- a/host/js/subagents-test-bundle.js +++ b/host/js/subagents-test-bundle.js @@ -30546,7 +30546,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b const loaderGit = async (...args) => { for (let attempt = 0; ; attempt++) { try { - const result = await exec(["git", ...args], { cwd: repo, timeout: 120, signal: commands.signal }); + 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) { @@ -30562,7 +30562,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b 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", state.good ?? ""); + 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 dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -30652,8 +30653,19 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b try { await fs.stat(snapshot2); } catch { - await loaderGit("worktree", "prune"); - await loaderGit("worktree", "add", "--detach", snapshot2, sha); + 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 modules = []; for (const entry2 of await fs.list(`${snapshot2}/extensions`).catch((error) => { diff --git a/host/js/watchdog-test-bundle.js b/host/js/watchdog-test-bundle.js index e559e68..3cab240 100644 --- a/host/js/watchdog-test-bundle.js +++ b/host/js/watchdog-test-bundle.js @@ -30546,7 +30546,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b const loaderGit = async (...args) => { for (let attempt = 0; ; attempt++) { try { - const result = await exec(["git", ...args], { cwd: repo, timeout: 120, signal: commands.signal }); + 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) { @@ -30562,7 +30562,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b 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", state.good ?? ""); + 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 dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -30652,8 +30653,19 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b try { await fs.stat(snapshot); } catch { - await loaderGit("worktree", "prune"); - await loaderGit("worktree", "add", "--detach", snapshot, sha2); + 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 modules = []; for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { diff --git a/host/js/workspaces-test-bundle.js b/host/js/workspaces-test-bundle.js index 1d1d18d..c0e9198 100644 --- a/host/js/workspaces-test-bundle.js +++ b/host/js/workspaces-test-bundle.js @@ -30546,7 +30546,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b const loaderGit = async (...args) => { for (let attempt = 0; ; attempt++) { try { - const result = await exec(["git", ...args], { cwd: repo, timeout: 120, signal: commands.signal }); + 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) { @@ -30562,7 +30562,8 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b 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", state.good ?? ""); + 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 dispose = async (entries2) => { for (const facet of entries2) for (const cleanup of facet.close) { @@ -30652,8 +30653,19 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b try { await fs.stat(snapshot); } catch { - await loaderGit("worktree", "prune"); - await loaderGit("worktree", "add", "--detach", snapshot, sha); + 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 modules = []; for (const entry2 of await fs.list(`${snapshot}/extensions`).catch((error) => { diff --git a/host/tests/it/automations.rs b/host/tests/it/automations.rs index c01b96d..a342639 100644 --- a/host/tests/it/automations.rs +++ b/host/tests/it/automations.rs @@ -36,6 +36,8 @@ fn setup(directory: &Path) -> Result<()> { "git", "-p", "curl", + "-p", + "sausage-archive-tools", "--target", "wasm32-wasip2", "--target-dir" @@ -58,6 +60,7 @@ fn setup(directory: &Path) -> Result<()> { std::fs::create_dir_all(disk.join("bin"))?; for (source, name) in [ ("git.wasm", "git.wasm"), + ("tar.wasm", "tar.wasm"), ("curl.wasm", "curl.wasm"), ("brush.wasm", "sh.wasm"), ] { diff --git a/host/tests/it/common.rs b/host/tests/it/common.rs index c47c486..d84ff92 100644 --- a/host/tests/it/common.rs +++ b/host/tests/it/common.rs @@ -141,7 +141,15 @@ pub fn build_git() -> &'static Path { cwd_init(); assert!( Command::new("cargo") - .args(["build", "--release", "-p", "git", "--manifest-path"]) + .args([ + "build", + "--release", + "-p", + "git", + "-p", + "sausage-archive-tools", + "--manifest-path" + ]) .arg(userland.join("Cargo.toml")) .args(["--target", "wasm32-wasip2", "--target-dir"]) .arg(&target) diff --git a/host/tests/it/core_ext.rs b/host/tests/it/core_ext.rs index 7cd3f23..024b061 100644 --- a/host/tests/it/core_ext.rs +++ b/host/tests/it/core_ext.rs @@ -52,10 +52,12 @@ async fn core_extensions() -> anyhow::Result<()> { 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 binary in ["git", "tar"] { + std::fs::copy( + target.join(format!("wasm32-wasip2/release/{binary}.wasm")), + disk.join(format!("bin/{binary}.wasm")), + )?; + } let source = disk.join("src/sausage"); for name in ["core", "models", "prompt", "conversations"] { copy_tree( diff --git a/host/tests/it/dream.rs b/host/tests/it/dream.rs index 617e998..0e2d147 100644 --- a/host/tests/it/dream.rs +++ b/host/tests/it/dream.rs @@ -38,10 +38,12 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); let target = crate::common::build_git(); std::fs::create_dir_all(disk.join("bin"))?; - std::fs::copy( - target.join("wasm32-wasip2/release/git.wasm"), - disk.join("bin/git.wasm"), - )?; + for binary in ["git", "tar"] { + std::fs::copy( + target.join(format!("wasm32-wasip2/release/{binary}.wasm")), + disk.join(format!("bin/{binary}.wasm")), + )?; + } copy_tree(&source.join("extensions"), &repo.join("extensions"))?; std::fs::create_dir_all(repo.join("extensions/probe"))?; std::fs::write( diff --git a/host/tests/it/events.rs b/host/tests/it/events.rs index 7d40ff3..bb62333 100644 --- a/host/tests/it/events.rs +++ b/host/tests/it/events.rs @@ -95,8 +95,12 @@ async fn world_extensions() -> anyhow::Result<()> { let world = World::open(dir.path())?; let disk = dir.path().join("world"); std::fs::create_dir_all(disk.join("bin"))?; - let git = target.join("wasm32-wasip2/release/git.wasm"); - std::fs::copy(git, disk.join("bin/git.wasm"))?; + for binary in ["git", "tar"] { + std::fs::copy( + target.join(format!("wasm32-wasip2/release/{binary}.wasm")), + disk.join(format!("bin/{binary}.wasm")), + )?; + } let repo = disk.join("src/sausage"); std::fs::create_dir_all(&repo)?; assert!( diff --git a/host/tests/it/extensions.rs b/host/tests/it/extensions.rs index 7f62db9..b883942 100644 --- a/host/tests/it/extensions.rs +++ b/host/tests/it/extensions.rs @@ -22,7 +22,15 @@ impl Exec for Author { ] { options.env.push((key.into(), value.into())); } - self.0.exec(command, options, output).await + 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 } } #[tokio::test(flavor = "current_thread")] @@ -41,6 +49,8 @@ async fn commits_and_restart() -> anyhow::Result<()> { "git", "-p", "sausage-exec-tools", + "-p", + "sausage-archive-tools", "--manifest-path" ]) .arg(userland.join("Cargo.toml")) @@ -59,7 +69,7 @@ async fn commits_and_restart() -> anyhow::Result<()> { target.join("wasm32-wasip2/release/git.wasm"), disk.join("bin/git.wasm"), )?; - for name in ["extensions-revert", "env"] { + for name in ["extensions-revert", "env", "tar"] { std::fs::copy( target.join(format!("wasm32-wasip2/release/{name}.wasm")), disk.join(format!("bin/{name}.wasm")), @@ -113,7 +123,7 @@ async fn commits_and_restart() -> anyhow::Result<()> { Ok(()) } -/// Model command time without a wall-clock delay, including work longer than the old 30 second budget. +/// Model loader commands without delays, and require cancellation instead of a wall-clock deadline. struct LoaderCommand { root: Arc, failures: usize, @@ -131,18 +141,21 @@ impl Exec for LoaderCommand { 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); + } match args[1].as_str() { "rev-parse" => output(Stream::Stdout, b"new\n"), - "worktree" if args[2] == "prune" => {} - "worktree" if args[2] == "add" => { + "ls-tree" => output(Stream::Stdout, b"extensions\n"), + "archive" => { let attempt = self .attempts .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if options - .timeout - .is_some_and(|budget| budget < std::time::Duration::from_secs(31)) - || (attempt < self.failures && self.timeout) - { + if attempt < self.failures && self.timeout { anyhow::bail!("command timed out"); } if attempt < self.failures { @@ -159,9 +172,21 @@ impl Exec for LoaderCommand { "Retry must publish its error: {status}" ); } - let snapshot = &args[4]; + 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 [ - snapshot.clone(), format!("{snapshot}/extensions"), format!("{snapshot}/extensions/demo"), ] { @@ -217,7 +242,7 @@ async fn loader_command(failures: usize, timeout: bool) -> anyhow::Result<()> { 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 worktree add')) throw new Error(testState.error); + if(testState.error && !testState.error.includes('git archive')) throw new Error(testState.error); await testLoader.close(); await testHarness.close(BACKGROUND_CONTEXT); "# diff --git a/host/tests/it/goal_questions.rs b/host/tests/it/goal_questions.rs index 9eff431..1356a79 100644 --- a/host/tests/it/goal_questions.rs +++ b/host/tests/it/goal_questions.rs @@ -13,10 +13,12 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); let target = crate::common::build_git(); std::fs::create_dir_all(disk.join("bin"))?; - std::fs::copy( - target.join("wasm32-wasip2/release/git.wasm"), - disk.join("bin/git.wasm"), - )?; + for binary in ["git", "tar"] { + std::fs::copy( + target.join(format!("wasm32-wasip2/release/{binary}.wasm")), + disk.join(format!("bin/{binary}.wasm")), + )?; + } crate::common::copy_tree(&source.join("extensions"), &repo.join("extensions"))?; std::fs::create_dir_all(repo.join("agents"))?; std::fs::create_dir_all(repo.join("extensions/probe"))?; diff --git a/host/tests/it/harness_api.rs b/host/tests/it/harness_api.rs index 55f8d3e..e5284f3 100644 --- a/host/tests/it/harness_api.rs +++ b/host/tests/it/harness_api.rs @@ -26,6 +26,8 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { "git", "-p", "curl", + "-p", + "sausage-archive-tools", "--manifest-path" ]) .arg(userland.join("Cargo.toml")) @@ -57,10 +59,12 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { target.join("wasm32-wasip2/release/harness-api-client.wasm"), disk.join("bin/harness-api-client.wasm"), )?; - std::fs::copy( - target.join("wasm32-wasip2/release/git.wasm"), - disk.join("bin/git.wasm"), - )?; + for binary in ["git", "tar"] { + std::fs::copy( + target.join(format!("wasm32-wasip2/release/{binary}.wasm")), + disk.join(format!("bin/{binary}.wasm")), + )?; + } std::fs::copy( target.join("wasm32-wasip2/release/curl.wasm"), disk.join("bin/curl.wasm"), diff --git a/host/tests/it/messages.rs b/host/tests/it/messages.rs index a86a2c7..4c6ccd9 100644 --- a/host/tests/it/messages.rs +++ b/host/tests/it/messages.rs @@ -17,10 +17,12 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); let target = crate::common::build_git(); std::fs::create_dir_all(disk.join("bin"))?; - std::fs::copy( - target.join("wasm32-wasip2/release/git.wasm"), - disk.join("bin/git.wasm"), - )?; + for binary in ["git", "tar"] { + std::fs::copy( + target.join(format!("wasm32-wasip2/release/{binary}.wasm")), + disk.join(format!("bin/{binary}.wasm")), + )?; + } for directory in [ "extensions/subagents", "extensions/messages", diff --git a/host/tests/it/p1.rs b/host/tests/it/p1.rs index 27983b2..209596c 100644 --- a/host/tests/it/p1.rs +++ b/host/tests/it/p1.rs @@ -338,6 +338,8 @@ async fn bundled_extensions_load() -> anyhow::Result<()> { const bundleLoader = await loadExtensions(bundleHarness, bundleRegistry, bundleStorage, bundleModels, BACKGROUND_CONTEXT, [CodingTools2, CredentialRequests]); if (bundleLoader.state().error) throw new Error(bundleLoader.state().error); + const snapshotEntries=await fs2.list(`/run/sausage/extensions/${{bundleLoader.state().good}}`); + if(JSON.stringify(snapshotEntries.map(entry=>entry.name)) !== '["extensions"]') throw new Error(JSON.stringify(snapshotEntries)); await bundleLoader.close(); await bundleHarness.close(BACKGROUND_CONTEXT); "# diff --git a/host/tests/it/p3.rs b/host/tests/it/p3.rs index c2cc520..e06bb86 100644 --- a/host/tests/it/p3.rs +++ b/host/tests/it/p3.rs @@ -30,10 +30,12 @@ fn prepare(dir: &Path, all_extensions: bool) -> anyhow::Result<()> { let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); let target = crate::common::build_git(); std::fs::create_dir_all(disk.join("bin"))?; - std::fs::copy( - target.join("wasm32-wasip2/release/git.wasm"), - disk.join("bin/git.wasm"), - )?; + for binary in ["git", "tar"] { + std::fs::copy( + target.join(format!("wasm32-wasip2/release/{binary}.wasm")), + disk.join(format!("bin/{binary}.wasm")), + )?; + } if !target.join("wasm32-wasip2/release/brush.wasm").exists() { anyhow::ensure!( crate::common::build_brush()?.success(), diff --git a/host/tests/it/p4.rs b/host/tests/it/p4.rs index f0021bb..1957f2e 100644 --- a/host/tests/it/p4.rs +++ b/host/tests/it/p4.rs @@ -24,7 +24,9 @@ fn prepare(dir: &Path) -> anyhow::Result { .args(["rev-parse", "HEAD"]) .output()?; ensure!(revision.status.success(), "fixture revision"); - if fs::read(bundle.join("revision")).ok().as_deref() != Some(revision.stdout.as_slice()) { + if fs::read(bundle.join("revision")).ok().as_deref() != Some(revision.stdout.as_slice()) + || !bundle.join("bin/tar.wasm").is_file() + { if !userland.join("cwd-init/init.o").exists() { crate::common::cwd_init(); } @@ -42,6 +44,8 @@ fn prepare(dir: &Path) -> anyhow::Result { "undo", "-p", "sausage-coreutils", + "-p", + "sausage-archive-tools", ]))?; ensure!( crate::common::build_brush()?.success(), @@ -53,6 +57,7 @@ fn prepare(dir: &Path) -> anyhow::Result { ("brush", "sh"), ("brush", "bash"), ("git", "git"), + ("tar", "tar"), ("cargo", "cargo"), ("box", "box"), ("undo", "undo"), diff --git a/host/tests/it/plan_todos.rs b/host/tests/it/plan_todos.rs index 7040859..16ab359 100644 --- a/host/tests/it/plan_todos.rs +++ b/host/tests/it/plan_todos.rs @@ -10,10 +10,12 @@ async fn plan_todos_and_restart() -> anyhow::Result<()> { let disk = dir.path().join("world"); let _world = World::open(dir.path())?; std::fs::create_dir_all(disk.join("bin"))?; - std::fs::copy( - target.join("wasm32-wasip2/release/git.wasm"), - disk.join("bin/git.wasm"), - )?; + for binary in ["git", "tar"] { + std::fs::copy( + target.join(format!("wasm32-wasip2/release/{binary}.wasm")), + disk.join(format!("bin/{binary}.wasm")), + )?; + } let repo = disk.join("src/sausage"); std::fs::create_dir_all(&repo)?; assert!( diff --git a/host/tests/it/subagents.rs b/host/tests/it/subagents.rs index 1fd08ff..b2997de 100644 --- a/host/tests/it/subagents.rs +++ b/host/tests/it/subagents.rs @@ -15,10 +15,12 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); let target = crate::common::build_git(); std::fs::create_dir_all(disk.join("bin"))?; - std::fs::copy( - target.join("wasm32-wasip2/release/git.wasm"), - disk.join("bin/git.wasm"), - )?; + for binary in ["git", "tar"] { + std::fs::copy( + target.join(format!("wasm32-wasip2/release/{binary}.wasm")), + disk.join(format!("bin/{binary}.wasm")), + )?; + } for directory in [ "extensions/subagents", "extensions/models", diff --git a/host/tests/it/watchdog.rs b/host/tests/it/watchdog.rs index 5cc1a6a..7f7cd70 100644 --- a/host/tests/it/watchdog.rs +++ b/host/tests/it/watchdog.rs @@ -17,10 +17,12 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); let target = crate::common::build_git(); std::fs::create_dir_all(disk.join("bin"))?; - std::fs::copy( - target.join("wasm32-wasip2/release/git.wasm"), - disk.join("bin/git.wasm"), - )?; + for binary in ["git", "tar"] { + std::fs::copy( + target.join(format!("wasm32-wasip2/release/{binary}.wasm")), + disk.join(format!("bin/{binary}.wasm")), + )?; + } for directory in [ "extensions/subagents", "extensions/messages", diff --git a/host/tests/it/workspaces.rs b/host/tests/it/workspaces.rs index bdf9a39..627bdc2 100644 --- a/host/tests/it/workspaces.rs +++ b/host/tests/it/workspaces.rs @@ -34,7 +34,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { assert!(crate::common::build_brush()?.success()); } std::fs::create_dir_all(disk.join("bin"))?; - for (binary, name) in [("git", "git"), ("brush", "sh")] { + for (binary, name) in [("git", "git"), ("tar", "tar"), ("brush", "sh")] { std::fs::copy( target.join(format!("wasm32-wasip2/release/{binary}.wasm")), disk.join(format!("bin/{name}.wasm")), diff --git a/userland/Cargo.lock b/userland/Cargo.lock index b1081b2..029bddf 100644 --- a/userland/Cargo.lock +++ b/userland/Cargo.lock @@ -1525,6 +1525,7 @@ dependencies = [ "sausage-ssh", "serde", "serde_json", + "tar", "url", "wasi 0.14.7+wasi-0.2.4", ] diff --git a/userland/git/Cargo.toml b/userland/git/Cargo.toml index f5ed58d..69706ea 100644 --- a/userland/git/Cargo.toml +++ b/userland/git/Cargo.toml @@ -16,3 +16,4 @@ gix-zlib = "0.4" url = "2" wasi = "0.14" sausage-ssh = { path = "../ssh-client" } +tar = { version = "0.4", default-features = false } diff --git a/userland/git/README.md b/userland/git/README.md index 55187e7..d0b3637 100644 --- a/userland/git/README.md +++ b/userland/git/README.md @@ -78,6 +78,9 @@ It also checks these forms: - `show --stat`, `--oneline`, `--name-only`, `show HEAD:path`, and merge commits without a combined diff. - `cat-file -t`, `-p`, `-s`, and `-e` for object inspection. - `ls-tree [-r] [--name-only] [path]` for literal paths. +- `archive [--format=tar] [-o ] [path]` for a tree or literal subtree. + The archive preserves file contents, executable modes, and symbolic links. + `archive-test.nu` compares extracted output with native Git. - `diff HEAD~1`, `diff A..B`, `--name-only`, `--name-status`, and one literal path after `--`. - `diff --check` and `diff --cached --check` for added whitespace errors and conflict markers. - `branch -a`, `-d`, `-D`, `-f`, `-m`, and `--show-current`. diff --git a/userland/git/archive-test.nu b/userland/git/archive-test.nu new file mode 100644 index 0000000..8fbcee8 --- /dev/null +++ b/userland/git/archive-test.nu @@ -0,0 +1,32 @@ +#!/usr/bin/env nu +# Compare the literal subtree archive with native Git, including binary files and links. +def main [component: path] { + let component = ($component | path expand) + let root = (mktemp -d -p /tmp git-archive.XXXXXX) + let repo = ($root | path join repo) + mkdir ($repo | path join extensions/demo) ($root | path join native) ($root | path join wasi) + ^git init -q -b main $repo + 'outside' | save ($repo | path join outside.txt) + 0x[00 ff 7f 80] | save ($repo | path join 'extensions/demo/payload space.bin') + 'executable' | save ($repo | path join extensions/demo/run) + ^chmod +x ($repo | path join extensions/demo/run) + ^ln -s 'payload space.bin' ($repo | path join extensions/demo/link) + ^git -C $repo add . + ^git -C $repo -c user.name=Test -c user.email=test@example.com commit --no-gpg-sign -q -m fixture + ^git -C $repo archive --format=tar $'--output=($root)/native.tar' HEAD extensions + ^wasmtime run -S http -S cli-exit-with-code=y --dir $root $component -C $repo archive --format=tar $'--output=($root)/wasi.tar' HEAD extensions + if $env.LAST_EXIT_CODE != 0 { error make {msg: 'WASI archive failed'} } + for side in [native wasi] { ^tar -xf $'($root)/($side).tar' -C $'($root)/($side)' } + ^diff -r $'($root)/native' $'($root)/wasi' + if $env.LAST_EXIT_CODE != 0 { error make {msg: 'Extracted contents differ'} } + if ($'($root)/wasi/outside.txt' | path exists) { error make {msg: 'Archive includes unrelated files'} } + let native_mode = (^stat -c '%a' $'($root)/native/extensions/demo/run') + let wasi_mode = (^stat -c '%a' $'($root)/wasi/extensions/demo/run') + if $native_mode != $wasi_mode { error make {msg: 'Executable mode differs'} } + if (^readlink $'($root)/wasi/extensions/demo/link') != 'payload space.bin' { error make {msg: 'Symlink differs'} } + for args in [[archive bad-revision extensions] [archive HEAD missing] [archive --format=zip HEAD]] { + let result = (^wasmtime run -S http -S cli-exit-with-code=y --dir $root $component -C $repo ...$args | complete) + if $result.exit_code == 0 { error make {msg: $'Invalid archive succeeded: ($args)'} } + } + print $'Archive comparison passed: ($root)' +} diff --git a/userland/git/src/archive.rs b/userland/git/src/archive.rs new file mode 100644 index 0000000..6eaf861 --- /dev/null +++ b/userland/git/src/archive.rs @@ -0,0 +1,86 @@ +//! Export a committed tree or literal subtree as tar, without an index or worktree. +use crate::{Result, repo::error}; +use std::{io::Write, path::Path}; + +pub(crate) fn archive( + repo: &gix::Repository, + revision: &str, + path: Option<&str>, + output: Option<&Path>, +) -> Result<()> { + if let Some(path) = path { + crate::repo::validate_path(path)?; + } + let root = crate::local::tree(repo, revision)?; + let writer: Box = match output { + Some(path) => { + Box::new(std::fs::File::create(path).map_err(|e| error("archive output", e))?) + } + None => Box::new(std::io::stdout().lock()), + }; + let mut tar = tar::Builder::new(writer); + fn walk( + repo: &gix::Repository, + id: gix::ObjectId, + prefix: &str, + filter: Option<&str>, + tar: &mut tar::Builder>, + ) -> Result { + let tree = repo.find_tree(id).map_err(|e| error("archive tree", e))?; + let mut found = filter.is_none(); + for entry in tree.iter() { + let entry = entry.map_err(|e| error("archive entry", e))?; + let path = format!("{prefix}{}", entry.filename()); + let selected = filter.is_none_or(|f| path == f || path.starts_with(&format!("{f}/"))); + let ancestor = filter.is_some_and(|f| f.starts_with(&format!("{path}/"))); + if !selected && !ancestor { + continue; + } + found |= selected; + if entry.mode().is_tree() { + if selected { + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Directory); + header.set_mode(0o755); + header.set_size(0); + tar.append_data(&mut header, &path, std::io::empty()) + .map_err(|e| error("archive directory", e))?; + } + found |= walk( + repo, + entry.oid().to_owned(), + &format!("{path}/"), + filter, + tar, + )?; + } else if selected && !entry.mode().is_commit() { + let object = repo + .find_blob(entry.oid()) + .map_err(|e| error("archive blob", e))?; + let mut header = tar::Header::new_gnu(); + header.set_mode(entry.mode().kind() as u32 & 0o777); + if entry.mode().is_link() { + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header + .set_link_name( + std::str::from_utf8(&object.data) + .map_err(|e| error("archive link", e))?, + ) + .map_err(|e| error("archive link", e))?; + tar.append_data(&mut header, &path, std::io::empty()) + .map_err(|e| error("archive link", e))?; + } else { + header.set_size(object.data.len() as u64); + tar.append_data(&mut header, &path, object.data.as_slice()) + .map_err(|e| error("archive file", e))?; + } + } + } + Ok(found) + } + if !walk(repo, root, "", path, &mut tar)? { + return Err(error("archive", "path did not match any files")); + } + tar.finish().map_err(|e| error("archive finish", e)) +} diff --git a/userland/git/src/lib.rs b/userland/git/src/lib.rs index 2ec5781..99a6adb 100644 --- a/userland/git/src/lib.rs +++ b/userland/git/src/lib.rs @@ -1,5 +1,6 @@ //! Local Git operations adapted from the v2 gix plugin, without RPC or host imports. #![allow(dead_code)] +mod archive; mod branches; mod changes; mod checkout; @@ -78,6 +79,14 @@ pub struct Cli { } #[derive(Subcommand)] enum Command { + Archive { + #[arg(long, default_value = "tar", value_parser = ["tar"])] + format: String, + #[arg(short = 'o', long)] + output: Option, + revision: String, + path: Option, + }, Show { #[arg(short = 's', long = "no-patch")] no_patch: bool, @@ -1501,6 +1510,14 @@ pub fn run(cli: Cli) -> Result { )?; } }, + Command::Archive { + format: _, + output, + revision, + path, + } => { + archive::archive(&repository, &revision, path.as_deref(), output.as_deref())?; + } Command::CatFile { kind, pretty: _,