Merge branch 'v3' into v3-t63

This commit is contained in:
2026-11-08 02:59:03 +00:00
122 changed files with 63772 additions and 1955 deletions
+14
View File
@@ -56,6 +56,20 @@ nix develop -c cargo build --manifest-path userland/Cargo.toml --release
`nix flake check` still describes v2 and is updated later in P1.
## Tests
Host integration tests share one executable:
```sh
cargo test -p sausage --test it
cargo test -p sausage --test it -- chain::
```
Use the required Nix environment and build slot wrapper for these commands.
Add test modules under `host/tests/it/` and declare them in `it/main.rs`.
Use fully qualified child test filters with `--exact`.
See `host/tests/README.md` for fixture and isolation details.
Tests must stay fast:
- Only a few tests may wait for a timing (grace periods, deadlines).
+20 -2
View File
@@ -7,7 +7,7 @@ import type { ConversationId, ConversationRecord, Cursor, EntryId, JsonObject, M
import type { ModelThinkingLevel } from "@earendil-works/pi-ai"
import { onCommit } from "../messages/compat.ts"
import { Started as BoardStarted } from "../messages/addresses.ts"
import { Standing, Started } from "../subagents/api.ts"
import { Standing, Started, Subagents } from "../subagents/api.ts"
import { Branch, Branched, LineageApi, Rooted, Roots } from "./api.ts"
import { TallyDoc, SubmissionLanded } from "./refs.ts"
import { readMemory, applyMemoryOps, memoryBrief, memoryIndex, memoryText, memorizeTask, Memorized, MEMORY_RULES, MEMORY_SLUG, LINE_CHARS, BODY_CHARS } from "./memory.ts"
@@ -174,6 +174,24 @@ async function liveTask(tx: Tx, id: ConversationId, kind: string) {
return false
}
/** Include background reporters: their deliveries can start another coordinator turn. */
async function waitForBranchIdle(host: ExtensionHost, id: ConversationId, ctx: Context): Promise<void> {
const harness = host.harness()
const conversation = await harness.conversation(id, ctx)
if (!conversation) return
for (;;) {
await conversation.waitForIdle(ctx)
const state = await harness.snapshot(Subagents, id, ctx)
const actions = Object.values(state?.actions ?? {})
for (const action of actions) await harness.waitForTask(action.reporter, ctx)
for (const child of Object.values(state?.agents ?? {}))
await waitForBranchIdle(host, child.conversationId, ctx)
await conversation.waitForIdle(ctx)
const current = await harness.snapshot(Subagents, id, ctx)
if (Object.keys(current?.actions ?? {}).length === actions.length) return
}
}
function lineage(host: ExtensionHost) {
/**
* Delivers a new branch's prompt as its first input; created with the branch, so a crash cannot leave it without
@@ -230,7 +248,7 @@ function lineage(host: ExtensionHost) {
written.resolve()
})
try {
await (await runtime.conversation(target, ctx))?.waitForIdle(ctx)
await waitForBranchIdle(host, target, ctx)
// A fork inherits its source's entries; only an answer written in the target itself counts as its own.
const view = await runtime.context(target, ctx)
said = ''
+3
View File
@@ -8,6 +8,7 @@ They use world files and world git. They do not use native filesystem or process
`branch` requires an absolute `repo` path and a prompt.
It forks a listed tree root and writes `board.started.by` in the same transaction.
The coordinator definition supplies the branch configuration.
The branch start event reports the coordinator's model, not the inherited root model.
Worktree isolation requires the later workspaces port. The default is a shared checkout.
The tool admits the prompt as a reply-enabled message.
@@ -22,6 +23,8 @@ A branch reports to its starter when its work ends.
While a child, landing, or reply remains pending, its final answer becomes a passive note.
One-way updates to the starter also become passive notes.
The starter can answer a report without an endless exchange of routed answers.
The starter's final answer reaches the branch without a faulted routing task.
A static test checks free runtime identifiers in every extension module, including function bodies.
The grant chain contains fork ancestry before starter ancestry.
Each conversation occurs once. A root fork that @ultimate starts includes `[branch, root, ultimate]`.
+1 -1
View File
@@ -22,7 +22,7 @@ export const branchTool = (host: any) => defineTool({
const coordinator = definitions.find((definition: any) => definition.name === 'coordinator')
if (!coordinator) throw new Error('Missing coordinator definition')
const rootAgent = await (await host.harness().conversation(root, ctx))!.agent(ctx)
const branch = await lineage.branch({from: root, repo: input.repo, prompt: input.prompt, title: input.title, deliver: false, by: api.conversationId, receipt: {taskId: api.taskId, callId: api.callId}}, ctx)
const branch = await lineage.branch({from: root, repo: input.repo, prompt: input.prompt, title: input.title, model: coordinator.runs.model, deliver: false, by: api.conversationId, receipt: {taskId: api.taskId, callId: api.callId}}, ctx)
const existing = await workspaces?.get(branch.id, ctx)
const cwd = existing ? (await (await host.harness().conversation(branch.id, ctx)).agent(ctx)).cwd : input.repo
const ancestors = workspaces ? (await workspaces.chain(branch.id, ctx)).slice(1) : []
+1
View File
@@ -14,6 +14,7 @@ import type {
import type { Host as ExtensionHost } from '../../host/js/extensions.ts'
import { Subagents } from '../subagents/api.ts'
import { ConversationsApi } from '../conversations/api.ts'
import { Messages } from './api.ts'
import { terminal } from './log.ts'
+25 -1
View File
@@ -19,7 +19,31 @@ Without that provider, the definition or default family selects the model.
No login fallback runs.
Existing registered models retain their transport.
New logical model references reuse the host's OpenAI-compatible proxy provider and its auth resolver.
New model references reuse a template from their own registered provider and retain its proxy auth resolver.
They never inherit another provider's transport. Unknown providers fail instead of silently using OpenAI.
Models absent from the catalog receive zero prices, not the template's prices.
The extension never reads a credential or registers a direct provider SDK.
## Host proxy transports and prices
`host/js/proxy-models.ts` reads the authenticated `/v1/models` catalog once at host startup.
OpenAI models use pi-ai's Responses transport. Anthropic models use its Messages transport.
The catalog endpoint determines each model's base URL.
For Anthropic, effort capabilities select adaptive thinking and its advertised effort levels.
Both providers use the existing `llm-proxy` key and usage tag. No provider login or direct endpoint fallback runs.
If the catalog fails or exceeds five seconds, a small table supplies these defaults:
| Provider/model | API | Proxy path |
| --- | --- | --- |
| `openai/gpt-6.1-sol` | Responses | `/v1/responses` |
| `anthropic/claude-opus-5-5` | Messages, adaptive thinking | `/apis/anthropic/v1/messages` |
Catalog prices use USD per million tokens, with conversion from `unit_tokens` when necessary.
The host uses the first advertised route's quote, including cache reads and cache writes.
These costs are estimates, not billing totals. Another proxy route can charge different prices.
Unavailable prices remain zero. Catalog changes apply after a host restart.
World family edits still apply at the next run boundary without a restart.
`host/tests/proxy_models.rs` tests the real transports against a local fake proxy.
`host/tests/core_ext.rs` tests these services through the snapshot loader.
+4 -3
View File
@@ -28,14 +28,15 @@ export default function models(host: any) {
const found = discoverAgents(files, '/src/sausage', root, families, refs)
return {families, ...found}
}
const transport = host.models().getProvider('openai')
const template = host.models().getModels('openai')[0]
const register = (ref: any) => {
const store = host.models()
if (store.getModel(ref.provider, ref.modelId)) return
const transport = store.getProvider(ref.provider)
const template = store.getModels(ref.provider)[0]
if (!transport || !template) throw new Error(`No proxy transport for ${ref.provider}/${ref.modelId}`)
const prior = store.getModels(ref.provider)
store.setProvider({...transport, id:ref.provider, getModels:()=>[...prior, {...template, provider:ref.provider, id:ref.modelId, name:ref.modelId}]})
store.setProvider({...transport, getModels:()=>[...prior, {...template, id:ref.modelId, name:ref.modelId,
cost:{input:0,output:0,cacheRead:0,cacheWrite:0}}]})
}
const apply = async (tx: any, id: number) => {
const state = await tx.doc(AgentDoc, id)
+5
View File
@@ -3,6 +3,11 @@ name = "sausage"
version = "0.3.0"
edition = "2024"
description = "A world of WASI programs and durable agents."
autotests = false
[[test]]
name = "it"
path = "tests/it/main.rs"
[[bin]]
name = "sausage"
+17324 -867
View File
File diff suppressed because one or more lines are too long
+14 -1
View File
@@ -21374,13 +21374,23 @@ function environment(cwd = "/", conversation, invocation) {
if (!appended.ok) throw new Error(appended.error.message);
}
};
let rid;
const abort = () => {
if (rid !== void 0) {
Deno.core.close(rid);
rid = void 0;
}
};
try {
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag);
context.abortSignal?.throwIfAborted();
rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag);
context.abortSignal?.addEventListener("abort", abort, { once: true });
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
let result;
while (true) {
const chunk = await ops.op_exec_next(rid);
if (!chunk.stream) {
rid = void 0;
result = chunk;
break;
}
@@ -21394,6 +21404,9 @@ function environment(cwd = "/", conversation, invocation) {
return ok({ exitCode: result.exitCode, ...spillPath ? { spillPath } : {} });
} catch (error) {
return { ok: false, error: Object.assign(new Error(String(error)), { code: "unknown", ...spillPath ? { spillPath } : {} }) };
} finally {
context.abortSignal?.removeEventListener("abort", abort);
abort();
}
},
cleanup: async () => {
+8 -2
View File
@@ -31693,6 +31693,8 @@ var faux = fauxProvider({ tokensPerSecond: 1e5 });
models.setProvider(faux.provider);
var proxyTemplate = { ...models.getModels("faux")[0], provider: "openai", baseUrl: "https://proxy.invalid/v1" };
models.setProvider({ ...faux.provider, id: "openai", getModels: () => [proxyTemplate] });
models.setProvider({ ...faux.provider, id: "anthropic", getModels: () => [{ ...proxyTemplate, provider: "anthropic", baseUrl: "https://proxy.invalid/apis/anthropic" }] });
var proxyAuth = models.getProvider("openai").auth;
var storage = await openStorage(globalThis.storagePath);
var registry = createRegistry();
var harness = await Harness.open(storage, { models, registry, settings: { retry: { enabled: false }, get extensions() {
@@ -31743,10 +31745,14 @@ try {
faux.setResponses([fauxAssistantMessage("second")]);
await (await worker.submit({ type: "input", content: "second" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
assert((await harness.snapshot(AgentDoc, worker.id, BACKGROUND_CONTEXT)).model.modelId === "second", "edited family changes next model without restart");
await fs.write("/src/sausage/agents/models.json", JSON.stringify({ default: { model: "faux/faux-1" }, fast: { model: "logical/new" } }));
await fs.write("/src/sausage/agents/models.json", JSON.stringify({ default: { model: "faux/faux-1" }, fast: { model: "openai/new" } }));
faux.setResponses([fauxAssistantMessage("proxy")]);
await (await worker.submit({ type: "input", content: "proxy" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
assert(models.getModel("logical", "new").baseUrl === proxyTemplate.baseUrl && models.getProvider("logical").auth === models.getProvider("openai").auth, "new model retains proxy transport and auth");
assert(models.getModel("openai", "new").baseUrl === proxyTemplate.baseUrl && models.getModel("openai", "new").cost.input === 0 && models.getProvider("openai").auth === proxyAuth, "new model retains proxy transport and auth");
await fs.write("/src/sausage/agents/models.json", JSON.stringify({ default: { model: "faux/faux-1" }, fast: { model: "anthropic/new" } }));
faux.setResponses([fauxAssistantMessage("anthropic")]);
await (await worker.submit({ type: "input", content: "anthropic" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
assert(models.getModel("anthropic", "new").baseUrl === "https://proxy.invalid/apis/anthropic", "new Anthropic model retains its own provider transport");
globalThis.modelPin = { provider: "faux", modelId: "faux-1" };
faux.setResponses([fauxAssistantMessage("pin")]);
await (await worker.submit({ type: "input", content: "pin" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
+8 -2
View File
@@ -9,6 +9,8 @@ const assert = (ok:any,message:string)=>{if(!ok)throw new Error(message);Deno.co
const models=createModels(), faux=fauxProvider({tokensPerSecond:100000});models.setProvider(faux.provider)
const proxyTemplate={...models.getModels('faux')[0],provider:'openai',baseUrl:'https://proxy.invalid/v1'}
models.setProvider({...faux.provider,id:'openai',getModels:()=>[proxyTemplate]})
models.setProvider({...faux.provider,id:'anthropic',getModels:()=>[{...proxyTemplate,provider:'anthropic',baseUrl:'https://proxy.invalid/apis/anthropic'}]})
const proxyAuth=models.getProvider('openai').auth
const storage=await openStorage(globalThis.storagePath), registry=createRegistry()
const harness=await Harness.open(storage,{models,registry,settings:{retry:{enabled:false},get extensions(){return registry.snapshot().installed()}}},context)
await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'}}})
@@ -56,10 +58,14 @@ try {
faux.setResponses([fauxAssistantMessage('second')])
await (await worker.submit({type:'input',content:'second'},context)).wait(context)
assert((await harness.snapshot(AgentDoc,worker.id,context)).model.modelId==='second','edited family changes next model without restart')
await fs.write('/src/sausage/agents/models.json',JSON.stringify({default:{model:'faux/faux-1'},fast:{model:'logical/new'}}))
await fs.write('/src/sausage/agents/models.json',JSON.stringify({default:{model:'faux/faux-1'},fast:{model:'openai/new'}}))
faux.setResponses([fauxAssistantMessage('proxy')])
await (await worker.submit({type:'input',content:'proxy'},context)).wait(context)
assert(models.getModel('logical','new').baseUrl===proxyTemplate.baseUrl && models.getProvider('logical').auth===models.getProvider('openai').auth,'new model retains proxy transport and auth')
assert(models.getModel('openai','new').baseUrl===proxyTemplate.baseUrl && models.getModel('openai','new').cost.input===0 && models.getProvider('openai').auth===proxyAuth,'new model retains proxy transport and auth')
await fs.write('/src/sausage/agents/models.json',JSON.stringify({default:{model:'faux/faux-1'},fast:{model:'anthropic/new'}}))
faux.setResponses([fauxAssistantMessage('anthropic')])
await (await worker.submit({type:'input',content:'anthropic'},context)).wait(context)
assert(models.getModel('anthropic','new').baseUrl==='https://proxy.invalid/apis/anthropic','new Anthropic model retains its own provider transport')
globalThis.modelPin={provider:'faux',modelId:'faux-1'}
faux.setResponses([fauxAssistantMessage('pin')])
await (await worker.submit({type:'input',content:'pin'},context)).wait(context)
+7 -2
View File
@@ -119,13 +119,17 @@ export function environment(cwd = '/', conversation?: string | number | string[]
if (!appended.ok) throw new Error(appended.error.message);
}
};
let rid: number | undefined;
const abort = () => { if (rid !== undefined) { Deno.core.close(rid); rid = undefined; } };
try {
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, undefined, undefined, conversations, tag);
context.abortSignal?.throwIfAborted();
rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, undefined, undefined, conversations, tag);
context.abortSignal?.addEventListener('abort', abort, { once: true });
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
let result;
while (true) {
const chunk = await ops.op_exec_next(rid);
if (!chunk.stream) { result = chunk; break; }
if (!chunk.stream) { rid = undefined; result = chunk; break; }
await output(decoders[chunk.stream].decode(new Uint8Array(chunk.bytes), { stream: true }), chunk.stream);
}
if (result.error) throw new Error(result.error);
@@ -135,6 +139,7 @@ export function environment(cwd = '/', conversation?: string | number | string[]
}
return ok({ exitCode: result.exitCode, ...(spillPath ? { spillPath } : {}) });
} catch (error) { return { ok: false, error: Object.assign(new Error(String(error)), { code: 'unknown', ...(spillPath ? { spillPath } : {}) }) }; }
finally { context.abortSignal?.removeEventListener('abort', abort); abort(); }
},
cleanup: async () => {},
};
File diff suppressed because it is too large Load Diff
+31 -2
View File
@@ -2,9 +2,11 @@
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, defineDoc, defineExtension, defineTask, GenerationTask, hook, InboxDoc } from '@earendil-works/pi-durable';
import { Harness, createRegistry, defineDoc, defineExtension, defineTask, GenerationTask, hook, InboxDoc, defineTool } from '@earendil-works/pi-durable';
import { openStorage } from './sqlite';
import { serve } from './input';
import { environment } from './environment';
import { obj } from '@earendil-works/pi-schema';
const assert = (value: any, message: string) => { if (!value) throw new Error(message); };
const equal = (a: any, b: any) => assert(JSON.stringify(a) === JSON.stringify(b), `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
const until = async (condition: () => any) => {
@@ -47,11 +49,24 @@ models.setProvider(faux.provider);
const registry = createRegistry();
registry.install(Notes);
registry.install(defineExtension({ name: 'hold-test', tasks: [Park] }));
const Shutdown = defineExtension({name: 'shutdown-test', tools: [defineTool({name: 'long_tool', description: 'Run a long world process', parameters: obj({}), replay: 'safe', execute: async (_args, _api, ctx) => {
const env = environment('/');
const resume = (await env.readTextFile('/resume')).ok;
const work = env.exec(['/bin/sleep.wasm', resume ? '0' : '600'], {}, ctx);
if (!resume) {
await until(async () => (await env.readTextFile('/proc/processes')).value?.includes('600'));
await env.writeFile('/sleep-ready', 'yes');
}
const result = await work;
assert(result.ok, `Sleep failed: ${JSON.stringify(result)}`);
return {output: [{type: 'text', text: 'sleep completed'}]};
}})]});
registry.install(Shutdown);
const storage = await openStorage(globalThis.storagePath);
const harness = await Harness.open(storage, { models, registry, settings: { followUpMode: 'all' } }, context);
const scenario = globalThis.foundationCase;
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' },
extensions: scenario === 'placement' ? [Notes] : [] } });
extensions: scenario === 'placement' ? [Notes] : scenario === 'shutdown' ? [Shutdown] : [] } });
try {
if (scenario === 'placement' || scenario === 'followups') {
faux.setResponses([async () => {
@@ -113,6 +128,20 @@ try {
await until(() => second);
await harness.waitForIdle(context);
equal((await harness.getTask(task, context))?.state.status, 'terminal');
} else if (scenario === 'shutdown') {
faux.setResponses([fauxAssistantMessage([fauxToolCall('long_tool', {})], {stopReason: 'toolUse'}), fauxAssistantMessage('resumed answer')]);
const resumed = (await environment('/').readTextFile('/resume')).ok;
if (!resumed) await root.submit({type: 'input', content: 'sleep now', requestId: 'sleep-request'}, context);
harness.resume();
if (resumed) {
faux.setResponses([fauxAssistantMessage('resumed answer')]);
await harness.waitForIdle(context);
const transcript = JSON.stringify(await root.context(context));
assert(transcript.includes('resumed answer'), 'Shutdown did not resume');
assert(transcript.includes('sleep completed'), 'Shutdown did not replay the interrupted tool');
const submissions = await storage.scanSubmissions({conversationId: root.id}, 100, undefined, context);
equal(submissions.items.length, 1);
} else await serve(harness, context);
} else if (scenario === 'headless') {
faux.setResponses([fauxAssistantMessage('channel answer')]);
await serve(harness, context);
+5
View File
@@ -31971,6 +31971,11 @@ async function serve(harness2, context, afterSubmit, api2) {
};
while (true) {
const input = await Deno.core.ops.op_input();
if (input?.type === "shutdown") {
await harness2.close(context);
await Promise.all(pending);
return;
}
if (input === null || input.type === "stop") {
await harness2.waitForIdle(context);
await Promise.all(pending);
+7 -1
View File
@@ -1,4 +1,4 @@
// Channel submissions target any conversation. EOF and Stop close the harness cleanly.
// EOF and Stop drain submissions. Shutdown cancels invocations and preserves durable tasks for restart.
export async function serve(harness: any, context: any, afterSubmit?: () => Promise<void>, api?: any) {
const pending = new Set<Promise<void>>();
const track = (work: Promise<void>) => {
@@ -7,6 +7,12 @@ export async function serve(harness: any, context: any, afterSubmit?: () => Prom
};
while (true) {
const input = await Deno.core.ops.op_input();
if (input?.type === 'shutdown') {
// Close cancels invocations without terminal outcomes, so durable tasks resume after restart.
await harness.close(context);
await Promise.all(pending);
return;
}
if (input === null || input.type === 'stop') {
await harness.waitForIdle(context);
await Promise.all(pending);
+3 -17
View File
@@ -1,7 +1,6 @@
// Durable root conversation and console. SQLite stays outside the world.
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
import { createModels } from '@earendil-works/pi-ai/models';
import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';
import { proxyModels } from './proxy-models';
import { Harness, AgentDoc, createRegistry } from '@earendil-works/pi-durable';
import { CodingTools } from './environment';
import { openStorage } from './sqlite';
@@ -12,21 +11,8 @@ import { harnessApi } from './harness-api';
import { loadExtensions } from './extensions';
const proxy = Deno.core.ops.op_model_proxy();
if (!proxy?.base_url || !proxy?.api_key) throw new Error('The llm-proxy credential is required');
const models = createModels();
const provider = openaiProvider();
const model = {
id: 'gpt-6.1-sol', name: 'gpt-6.1-sol', provider: 'openai', api: 'openai-responses',
baseUrl: `${proxy.base_url}/v1`, reasoning: true, input: ['text'],
thinkingLevelMap: { off: null, minimal: null },
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 4096,
};
models.setProvider({ ...provider, baseUrl: model.baseUrl, getModels: () => [model],
auth: { apiKey: { name: 'Sausage proxy', resolve: async () => ({
auth: { apiKey: proxy.api_key, baseUrl: model.baseUrl,
headers: { 'Shopify-Usage-Tag': JSON.stringify(['pi', 'sausage']) } },
source: 'llm-proxy',
}) } },
});
const models = await proxyModels(proxy);
const model = models.getModel('openai', 'gpt-6.1-sol');
const registry = createRegistry();
registry.install(CodingTools);
registry.install(CredentialRequests);
+30 -1
View File
@@ -31696,6 +31696,8 @@ var until2 = async (test) => {
var models = createModels();
var faux = fauxProvider({ tokensPerSecond: 1e5 });
models.setProvider(faux.provider);
var template = models.getModels("faux")[0];
models.setProvider({ ...faux.provider, getModels: () => [template, { ...template, id: "coordinator" }] });
var registry = createRegistry();
var storage = await openStorage(globalThis.storagePath);
var harness = await Harness.open(storage, { models, registry, settings: { get extensions() {
@@ -31704,6 +31706,13 @@ var harness = await Harness.open(storage, { models, registry, settings: { get ex
var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "faux", modelId: "faux-1" }, cwd: "/src/sausage" } });
var gate = Promise.withResolvers();
var holding = /* @__PURE__ */ new Set();
var workerGate = Promise.withResolvers();
var workerStarted = false;
var worker = defineExtension({ name: "worker-hold", tools: [defineTool({ name: "worker_hold", description: "Hold a background worker", parameters: obj({}), replay: "safe", execute: async (_args, _api, ctx) => {
workerStarted = true;
await awaitWithContext(workerGate.promise, ctx);
return { output: [{ type: "text", text: "worker released" }] };
} })] });
var held2 = defineExtension({ name: "hold", tools: [defineTool({ name: "hold", description: "Wait for the test gate", parameters: obj({}), replay: "safe", execute: async (_args, api, ctx) => {
holding.add(api.conversationId);
await awaitWithContext(gate.promise, ctx);
@@ -31746,11 +31755,12 @@ var route = (transcript) => {
const input = JSON.parse(text.slice(5));
return fauxAssistantMessage([fauxToolCall(input.name, input.args)], { stopReason: "toolUse" });
}
if (last.role === "user" && text === "HOLD WORKER") return fauxAssistantMessage([fauxToolCall("worker_hold", {})], { stopReason: "toolUse" });
if (last.role === "user" && text === "HOLD") return fauxAssistantMessage([fauxToolCall("hold", {})], { stopReason: "toolUse" });
return fauxAssistantMessage(last.role === "user" && /^(Reply|Message|Note) from/.test(text) ? "Received" : `Answer: ${text}`);
};
faux.setResponses([route]);
var loader = await loadExtensions(harness, registry, storage, models, BACKGROUND_CONTEXT, [held2]);
var loader = await loadExtensions(harness, registry, storage, models, BACKGROUND_CONTEXT, [held2, worker]);
assert(!loader.state().error, `Extension load: ${loader.state().error}`);
var snapshot = await fs.read("/src/sausage/.git/sausage-last-good");
var base = `file:///run/sausage/extensions/${snapshot}/extensions`;
@@ -31814,12 +31824,21 @@ try {
assert((await entries2(branch)).some((e) => e.kind === "board.message" && e.data.text === "first task"), "The first message did not reach the branch");
assert((await harness.commit((tx) => tx.conversation(branch.id), BACKGROUND_CONTEXT)).parent.conversationId === root.id, "Branch must fork its root");
assert((await harness.snapshot(Started2, branch.id, BACKGROUND_CONTEXT)).by === primary.id, "Missing starter");
const starts = (await globalThis.testHost.eventLog.recent()).filter((event) => event.name === "subagents.started" && event.data.id === branch.id);
assert(starts.length === 1 && starts[0].data.model === "faux/coordinator", `Effective branch model: ${JSON.stringify(starts)}`);
assert((await branch.agent(BACKGROUND_CONTEXT)).model.modelId === "coordinator", "The branch must use its advertised model");
assert(JSON.stringify(await conversationChains(harness, BACKGROUND_CONTEXT)(branch.id)) === JSON.stringify([String(branch.id), String(root.id), String(primary.id)]), "Fork and starter grant ancestry");
const before = (await entries2(primary)).length;
await (await branch.submit({ type: "input", content: "later work" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
await until2(async () => (await entries2(primary)).some((e) => e.kind === "board.message" && e.data.from === branch.id && e.data.text === "Answer: later work" && e.data.delivery !== "note"));
await harness.waitForIdle(BACKGROUND_CONTEXT);
assert((await entries2(primary)).length > before, "An idle branch must report");
assert((await entries2(branch)).some((entry2) => entry2.kind === "board.message" && entry2.data.from === primary.id && entry2.data.routed === true), "The final answer must route back to the branch");
await until2(async () => {
const routeTasks = (await harness.commit((tx) => tx.scanTasks({ kind: "board.route" }, 1e3), BACKGROUND_CONTEXT)).items;
assert(routeTasks.every((task) => task.state.status !== "terminal" || task.state.outcome.status === "completed"), `Final-answer routing fault: ${JSON.stringify(routeTasks)}`);
return routeTasks.length > 0 && routeTasks.every((task) => task.state.status === "terminal");
});
await tool(branch, "subagent", { action: "start", agent: "scout", task: "HOLD", background: true });
await until2(() => holding.size > 0);
await until2(async () => (await entries2(primary)).some((e) => e.kind === "board.message" && e.data.from === branch.id && e.data.delivery === "note"));
@@ -31874,13 +31893,23 @@ try {
assert((await entries2(branch)).some((e) => e.kind === "lineage.note" && e.data.source === "main" && e.data.text.includes("Catch-up commit") && e.data.text.includes(`#${branch.id}`)), "Attributed commit catch-up");
assert((await entries2(branch)).some((e) => e.kind === "lineage.note" && e.data.source === "main" && e.data.text.includes("Use memory")), "Tally catch-up must include the new branch");
await harness.waitForIdle(BACKGROUND_CONTEXT);
await tool(branch, "subagent", { action: "start", agent: "coordinator", task: "HOLD WORKER", background: true });
await until2(() => workerStarted);
await branch.waitForIdle(BACKGROUND_CONTEXT);
await tool(primary, "notify", { id: branch.id, wake: false });
await new Promise((resolve2) => setTimeout(resolve2, 50));
assert(!(await entries2(primary)).some((e) => e.kind === "lineage.note" && e.data.source === "finished"), "Idle coordinator with a live worker was called finished");
workerGate.resolve();
await until2(async () => (await entries2(primary)).some((e) => e.kind === "lineage.note" && e.data.source === "finished"));
await harness.waitForIdle(BACKGROUND_CONTEXT);
const finished = (await entries2(primary)).filter((e) => e.kind === "lineage.note" && e.data.source === "finished");
assert(finished.length === 1, "Worker completion must notify once");
assert(finished[0].data.text.includes("worker released"), "Notice must contain the coordinator answer after the worker report");
assert((await harness.snapshot(Roots, root.id, BACKGROUND_CONTEXT)).roots.includes(root.id), "Listed root");
}
} finally {
gate.resolve();
workerGate.resolve();
await loader.close();
await harness.close(BACKGROUND_CONTEXT);
}
+27 -2
View File
@@ -10,10 +10,15 @@ import {openStorage} from './sqlite';
const assert = (value: any, message: string) => {if (!value) throw new Error(message)};
const until = async (test: () => any) => {const deadline = Date.now() + 15000; while (!await test()) {if (Date.now() > deadline) throw new Error('Messages deadline'); await new Promise(r => setTimeout(r, 0))}};
const models = createModels(), faux = fauxProvider({tokensPerSecond: 100000}); models.setProvider(faux.provider);
const template = models.getModels('faux')[0];
models.setProvider({...faux.provider, getModels: () => [template, {...template, id: 'coordinator'}]});
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'}, cwd: '/src/sausage'}});
const gate = Promise.withResolvers<void>(), holding = new Set<number>();
const workerGate = Promise.withResolvers<void>();
let workerStarted = false;
const worker = defineExtension({name: 'worker-hold', tools: [defineTool({name: 'worker_hold', description: 'Hold a background worker', parameters: obj({}), replay: 'safe', execute: async (_args, _api, ctx) => {workerStarted = true; await awaitWithContext(workerGate.promise, ctx); return {output: [{type: 'text', text: 'worker released'}]}}})]});
const held = defineExtension({name: 'hold', tools: [defineTool({name: 'hold', description: 'Wait for the test gate', parameters: obj({}), replay: 'safe', execute: async (_args, api, ctx) => {holding.add(api.conversationId); await awaitWithContext(gate.promise, ctx); return {output: [{type: 'text', text: 'released'}]}}})]});
if (globalThis.messagesMode === 'kill') {
const install = registry.install.bind(registry);
@@ -39,11 +44,12 @@ const route = (transcript: any): any => {
const last = transcript.messages.findLast((m: any) => m.role !== 'system');
const text = typeof last.content === 'string' ? last.content : last.content.filter((p: any) => p.type === 'text').map((p: any) => p.text).join('');
if (last.role === 'user' && text.startsWith('CALL ')) {const input = JSON.parse(text.slice(5)); return fauxAssistantMessage([fauxToolCall(input.name, input.args)], {stopReason: 'toolUse'})}
if (last.role === 'user' && text === 'HOLD WORKER') return fauxAssistantMessage([fauxToolCall('worker_hold', {})], {stopReason: 'toolUse'});
if (last.role === 'user' && text === 'HOLD') return fauxAssistantMessage([fauxToolCall('hold', {})], {stopReason: 'toolUse'});
return fauxAssistantMessage(last.role === 'user' && /^(Reply|Message|Note) from/.test(text) ? 'Received' : `Answer: ${text}`);
};
faux.setResponses([route]);
const loader = await loadExtensions(harness, registry, storage, models, context, [held]);
const loader = await loadExtensions(harness, registry, storage, models, context, [held, worker]);
assert(!loader.state().error, `Extension load: ${loader.state().error}`);
const snapshot = await fs.read('/src/sausage/.git/sausage-last-good');
const base = `file:///run/sausage/extensions/${snapshot}/extensions`;
@@ -99,12 +105,21 @@ try {
assert((await entries(branch)).some((e: any) => e.kind === 'board.message' && e.data.text === 'first task'), 'The first message did not reach the branch');
assert((await harness.commit((tx: any) => tx.conversation(branch.id), context)).parent.conversationId === root.id, 'Branch must fork its root');
assert((await harness.snapshot(Started, branch.id, context)).by === primary.id, 'Missing starter');
const starts = (await globalThis.testHost.eventLog.recent()).filter((event: any) => event.name === 'subagents.started' && event.data.id === branch.id);
assert(starts.length === 1 && starts[0].data.model === 'faux/coordinator', `Effective branch model: ${JSON.stringify(starts)}`);
assert((await branch.agent(context)).model.modelId === 'coordinator', 'The branch must use its advertised model');
assert(JSON.stringify(await conversationChains(harness, context)(branch.id)) === JSON.stringify([String(branch.id), String(root.id), String(primary.id)]), 'Fork and starter grant ancestry');
const before = (await entries(primary)).length;
await (await branch.submit({type: 'input', content: 'later work'}, context)).wait(context);
await until(async () => (await entries(primary)).some((e: any) => e.kind === 'board.message' && e.data.from === branch.id && e.data.text === 'Answer: later work' && e.data.delivery !== 'note'));
await harness.waitForIdle(context);
assert((await entries(primary)).length > before, 'An idle branch must report');
assert((await entries(branch)).some((entry: any) => entry.kind === 'board.message' && entry.data.from === primary.id && entry.data.routed === true), 'The final answer must route back to the branch');
await until(async () => {
const routeTasks = (await harness.commit((tx: any) => tx.scanTasks({kind: 'board.route'}, 1000), context)).items;
assert(routeTasks.every((task: any) => task.state.status !== 'terminal' || task.state.outcome.status === 'completed'), `Final-answer routing fault: ${JSON.stringify(routeTasks)}`);
return routeTasks.length > 0 && routeTasks.every((task: any) => task.state.status === 'terminal');
});
await tool(branch, 'subagent', {action: 'start', agent: 'scout', task: 'HOLD', background: true});
await until(() => holding.size > 0);
await until(async () => (await entries(primary)).some((e: any) => e.kind === 'board.message' && e.data.from === branch.id && e.data.delivery === 'note'));
@@ -151,9 +166,19 @@ try {
assert((await entries(branch)).some((e: any) => e.kind === 'lineage.note' && e.data.source === 'main' && e.data.text.includes('Catch-up commit') && e.data.text.includes(`#${branch.id}`)), 'Attributed commit catch-up');
assert((await entries(branch)).some((e: any) => e.kind === 'lineage.note' && e.data.source === 'main' && e.data.text.includes('Use memory')), 'Tally catch-up must include the new branch');
await harness.waitForIdle(context);
await tool(branch, 'subagent', {action: 'start', agent: 'coordinator', task: 'HOLD WORKER', background: true});
await until(() => workerStarted);
await branch.waitForIdle(context);
await tool(primary, 'notify', {id: branch.id, wake: false});
// Allow the notify invocation to reach its wait. The active worker must prevent delivery.
await new Promise(resolve => setTimeout(resolve, 50));
assert(!(await entries(primary)).some((e: any) => e.kind === 'lineage.note' && e.data.source === 'finished'), 'Idle coordinator with a live worker was called finished');
workerGate.resolve();
await until(async () => (await entries(primary)).some((e: any) => e.kind === 'lineage.note' && e.data.source === 'finished'));
await harness.waitForIdle(context);
const finished = (await entries(primary)).filter((e: any) => e.kind === 'lineage.note' && e.data.source === 'finished');
assert(finished.length === 1, 'Worker completion must notify once');
assert(finished[0].data.text.includes('worker released'), 'Notice must contain the coordinator answer after the worker report');
assert((await harness.snapshot(Roots, root.id, context)).roots.includes(root.id), 'Listed root');
}
} finally {gate.resolve(); await loader.close(); await harness.close(context)}
} finally {gate.resolve(); workerGate.resolve(); await loader.close(); await harness.close(context)}
+14 -1
View File
@@ -21353,13 +21353,23 @@ function environment(cwd = "/", conversation, invocation) {
if (!appended.ok) throw new Error(appended.error.message);
}
};
let rid;
const abort = () => {
if (rid !== void 0) {
Deno.core.close(rid);
rid = void 0;
}
};
try {
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag);
context.abortSignal?.throwIfAborted();
rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag);
context.abortSignal?.addEventListener("abort", abort, { once: true });
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
let result;
while (true) {
const chunk = await ops.op_exec_next(rid);
if (!chunk.stream) {
rid = void 0;
result = chunk;
break;
}
@@ -21373,6 +21383,9 @@ function environment(cwd = "/", conversation, invocation) {
return ok({ exitCode: result.exitCode, ...spillPath ? { spillPath } : {} });
} catch (error) {
return { ok: false, error: Object.assign(new Error(String(error)), { code: "unknown", ...spillPath ? { spillPath } : {} }) };
} finally {
context.abortSignal?.removeEventListener("abort", abort);
abort();
}
},
cleanup: async () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"private": true,
"type": "module",
"scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js && esbuild watchdog-test.ts --bundle --format=esm --platform=browser --outfile=watchdog-test-bundle.js && esbuild workspaces-test.ts --bundle --format=esm --platform=browser --outfile=workspaces-test-bundle.js && esbuild goal-questions-test.ts --bundle --format=esm --platform=browser --outfile=goal-questions-test-bundle.js && esbuild dream-test.ts --bundle --format=esm --platform=browser --outfile=dream-test-bundle.js && esbuild p4-test.ts --bundle --format=esm --platform=browser --outfile=p4-test-bundle.js && esbuild harness-api-test.ts --bundle --format=esm --platform=browser --outfile=harness-api-test-bundle.js" },
"scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js && esbuild watchdog-test.ts --bundle --format=esm --platform=browser --outfile=watchdog-test-bundle.js && esbuild workspaces-test.ts --bundle --format=esm --platform=browser --outfile=workspaces-test-bundle.js && esbuild goal-questions-test.ts --bundle --format=esm --platform=browser --outfile=goal-questions-test-bundle.js && esbuild dream-test.ts --bundle --format=esm --platform=browser --outfile=dream-test-bundle.js && esbuild p4-test.ts --bundle --format=esm --platform=browser --outfile=p4-test-bundle.js && esbuild harness-api-test.ts --bundle --format=esm --platform=browser --outfile=harness-api-test-bundle.js && esbuild proxy-models-test.ts --bundle --format=esm --platform=browser --outfile=proxy-models-test-bundle.js" },
"dependencies": {
"@earendil-works/pi-durable": "1.5.0",
"@earendil-works/pi-ai": "1.5.0",
File diff suppressed because one or more lines are too long
+17
View File
@@ -0,0 +1,17 @@
/** Exercise both real pi-ai transports and their catalog prices, without provider SDK mocks. */
import { proxyModels } from './proxy-models';
const models = await proxyModels(globalThis.testProxy);
for (const [provider, id] of [['openai', 'gpt-6.1-sol'], ['anthropic', 'claude-opus-5-5']]) {
const model = models.getModel(provider, id);
if (model.cost.input !== 2 || model.cost.cacheWrite !== 3) throw new Error('catalog prices lost');
const result = await models.completeSimple(model, {messages: [{role: 'user', content: 'Say hello.', timestamp: Date.now()}]}, {reasoning: 'low', maxTokens: 128});
if (result.stopReason === 'error' || !result.content.some(block => block.type === 'text' && block.text === 'hello')) {
throw new Error(`${provider}: ${result.errorMessage ?? JSON.stringify(result.content)}`);
}
if (!(result.usage.cost.total > 0)) throw new Error('turn cost missing');
}
const fallback = await proxyModels({...globalThis.testProxy, base_url: `${globalThis.testProxy.base_url}/offline`});
for (const [provider, api] of [['openai', 'openai-responses'], ['anthropic', 'anthropic-messages']]) {
const model = fallback.getModels(provider)[0];
if (model.api !== api || model.cost.input !== 0) throw new Error('offline transport fallback failed');
}
+47
View File
@@ -0,0 +1,47 @@
/** Host-only proxy catalog, provider transports, authentication, and quoted token prices. */
import { createModels } from '@earendil-works/pi-ai/models';
import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';
import { anthropicProvider } from '@earendil-works/pi-ai/providers/anthropic';
export async function proxyModels(proxy: {base_url: string, api_key: string}) {
const models = createModels();
const base = proxy.base_url.replace(/\/$/, '');
let catalog: any[] = [];
try {
const response = await fetch(`${base}/v1/models`, {
headers: {Authorization: `Bearer ${proxy.api_key}`}, signal: AbortSignal.timeout(5000),
});
if (response.ok) catalog = (await response.json()).data ?? [];
} catch { /* An unavailable catalog must not prevent durable recovery. */ }
for (const [provider, api, path, fallback] of [
[openaiProvider(), 'openai-responses', '/v1/responses', 'gpt-6.1-sol'],
[anthropicProvider(), 'anthropic-messages', '/apis/anthropic/v1/messages', 'claude-opus-5-5'],
] as const) {
const rows = catalog.filter(row => row.creator === provider.id && row.endpoints?.some((endpoint: any) => endpoint.api === api));
const entries = (rows.length ? rows : [{id: fallback}]).map(row => {
const endpoint = row.endpoints?.find((endpoint: any) => endpoint.api === api)?.path ?? path;
// pi-ai appends /responses or /v1/messages, respectively.
const suffix = api === 'anthropic-messages' ? '/v1/messages' : '/responses';
const baseUrl = `${base}${endpoint.slice(0, -suffix.length)}`;
const price = row.providers?.[0]?.pricing;
const scale = price?.currency === 'USD' ? 1000000 / price.unit_tokens : 0;
return {
id: row.id, name: row.name ?? row.id, provider: provider.id, api, baseUrl,
reasoning: row.capabilities?.reasoning ?? true, input: ['text'],
thinkingLevelMap: {off: null, minimal: null,
...Object.fromEntries((row.capabilities?.reasoning_options?.find((option: any) => option.type === 'effort')?.values ?? []).map((level: string) => [level, level]))},
compat: api === 'anthropic-messages' ? {forceAdaptiveThinking: row.capabilities?.reasoning_options?.some((option: any) => option.type === 'effort') ?? true} : undefined,
cost: {input: (price?.input ?? 0) * scale, output: (price?.output ?? 0) * scale,
cacheRead: (price?.cached_input ?? 0) * scale, cacheWrite: (price?.cache_write ?? 0) * scale},
contextWindow: row.limits?.context_window ?? 200000, maxTokens: 4096,
};
});
models.setProvider({...provider, baseUrl: entries[0].baseUrl, getModels: () => entries as any,
auth: {apiKey: {name: 'Sausage proxy', resolve: async () => ({
auth: {apiKey: proxy.api_key, headers: {'Shopify-Usage-Tag': JSON.stringify(['pi', 'sausage'])}},
source: 'llm-proxy',
})}},
});
}
return models;
}
+14 -1
View File
@@ -21353,13 +21353,23 @@ function environment(cwd = "/", conversation, invocation) {
if (!appended.ok) throw new Error(appended.error.message);
}
};
let rid;
const abort = () => {
if (rid !== void 0) {
Deno.core.close(rid);
rid = void 0;
}
};
try {
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag);
context.abortSignal?.throwIfAborted();
rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag);
context.abortSignal?.addEventListener("abort", abort, { once: true });
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
let result2;
while (true) {
const chunk = await ops.op_exec_next(rid);
if (!chunk.stream) {
rid = void 0;
result2 = chunk;
break;
}
@@ -21373,6 +21383,9 @@ function environment(cwd = "/", conversation, invocation) {
return ok({ exitCode: result2.exitCode, ...spillPath ? { spillPath } : {} });
} catch (error) {
return { ok: false, error: Object.assign(new Error(String(error)), { code: "unknown", ...spillPath ? { spillPath } : {} }) };
} finally {
context.abortSignal?.removeEventListener("abort", abort);
abort();
}
},
cleanup: async () => {
+14 -1
View File
@@ -21353,13 +21353,23 @@ function environment(cwd = "/", conversation, invocation) {
if (!appended.ok) throw new Error(appended.error.message);
}
};
let rid;
const abort = () => {
if (rid !== void 0) {
Deno.core.close(rid);
rid = void 0;
}
};
try {
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag);
context.abortSignal?.throwIfAborted();
rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag);
context.abortSignal?.addEventListener("abort", abort, { once: true });
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
let result2;
while (true) {
const chunk = await ops.op_exec_next(rid);
if (!chunk.stream) {
rid = void 0;
result2 = chunk;
break;
}
@@ -21373,6 +21383,9 @@ function environment(cwd = "/", conversation, invocation) {
return ok({ exitCode: result2.exitCode, ...spillPath ? { spillPath } : {} });
} catch (error) {
return { ok: false, error: Object.assign(new Error(String(error)), { code: "unknown", ...spillPath ? { spillPath } : {} }) };
} finally {
context.abortSignal?.removeEventListener("abort", abort);
abort();
}
},
cleanup: async () => {
+4 -2
View File
@@ -24,6 +24,7 @@ pub enum Input {
reply: mpsc::UnboundedSender<Value>,
},
Stop,
Shutdown,
}
pub type Sender = mpsc::UnboundedSender<Input>;
@@ -78,7 +79,8 @@ pub(super) fn console(sender: Sender) {
});
}
pub(super) fn stop_signal(
/// Register SIGTERM and SIGINT before the harness starts.
pub fn stop_signal(
sender: Sender,
) -> std::io::Result<impl std::future::Future<Output = ()> + Send> {
// Register before V8 starts, so a stop cannot arrive before the listener.
@@ -89,6 +91,6 @@ pub(super) fn stop_signal(
_ = interrupt.recv() => {}
_ = terminate.recv() => {}
}
let _ = sender.send(Input::Stop);
let _ = sender.send(Input::Shutdown);
})
}
+175 -4
View File
@@ -130,7 +130,15 @@ impl Compiler {
}
}
}
/// Compile programs in both command folders without blocking world startup.
fn priority(name: &str) -> usize {
[
"sh", "git", "rg", "grep", "ls", "cat", "sed", "find", "cargo",
]
.iter()
.position(|tool| *tool == name.strip_suffix(".wasm").unwrap_or(name))
.unwrap_or(9)
}
/// Compile common tools first in both command folders without blocking world startup.
pub(super) fn precompile(
compiler: Arc<Compiler>,
root: Arc<dyn sausage_vfs::Layer>,
@@ -140,18 +148,20 @@ pub(super) fn precompile(
// Leave CPU and file bandwidth for commands during startup.
let slots = Arc::new(tokio::sync::Semaphore::new(2));
for folder in ["/bin", "/usr/local/bin"] {
let entries = match root.list(folder).await {
let mut entries = match root.list(folder).await {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e.into()),
};
entries.sort_by_key(|entry| priority(&entry.name));
for entry in entries.into_iter().filter(|entry| !entry.directory) {
// Reserve before spawning so task scheduling cannot reorder admission.
let permit = slots.clone().acquire_owned().await?;
let root = root.clone();
let compiler = compiler.clone();
let path = format!("{folder}/{}", entry.name);
let slots = slots.clone();
tasks.spawn(async move {
let _permit = slots.acquire().await?;
let _permit = permit;
let file = root
.open(
&path,
@@ -186,6 +196,167 @@ mod tests {
use crate::exec::{Command, Exec, Options};
use std::{future::Future, sync::atomic::Ordering};
#[test]
fn common_tools_precede_other_programs() {
let mut names = [
"z", "cat.wasm", "cargo", "sh.wasm", "git.wasm", "rg", "ls", "grep", "sed", "find",
"arch",
];
names.sort_by_key(|name| priority(name));
assert_eq!(
names,
[
"sh.wasm", "git.wasm", "rg", "grep", "ls", "cat.wasm", "sed", "find", "cargo", "z",
"arch"
]
);
}
#[tokio::test(flavor = "multi_thread")]
async fn cold_compilation_does_not_spend_command_timeout() -> anyhow::Result<()> {
use std::time::Duration;
let temp = tempfile::tempdir()?;
let compiler = Compiler::new(&temp.path().join("cache"))?;
let world = crate::world::World::open(temp.path())?;
std::fs::create_dir_all(temp.path().join("world/bin"))?;
let program = temp.path().join("world/bin/program.wasm");
std::fs::write(
&program,
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "_start")))"#)?,
)?;
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (finish_tx, finish_rx) = std::sync::mpsc::channel();
*compiler.before_compile.lock().unwrap() = Some(Box::new(move || {
let _ = started_tx.send(());
let _ = finish_rx.recv();
}));
let runtime = super::super::Runtime::with_compiler(world, compiler)?;
started_rx.await?;
let command_runtime = runtime.clone();
let mut command = tokio::spawn(async move {
command_runtime
.exec(
Command::Argv(vec!["/bin/program".into()]),
Options {
timeout: Some(Duration::from_secs(2)),
..Default::default()
},
&mut |_, _| {},
)
.await
});
let pending = tokio::time::timeout(Duration::from_millis(2100), &mut command).await;
// Always release the blocking compiler, even when the old deadline fails this assertion.
finish_tx.send(())?;
assert!(
pending.is_err(),
"Compilation consumed the execution deadline: {pending:?}"
);
assert_eq!(command.await??, 0);
std::fs::write(
&program,
wat::parse_str(
r#"(module (memory (export "memory") 1) (func (export "_start") (loop br 0)))"#,
)?,
)?;
let result = runtime
.exec(
Command::Argv(vec!["/bin/program".into()]),
Options {
timeout: Some(Duration::from_millis(100)),
..Default::default()
},
&mut |_, _| {},
)
.await;
assert!(
result
.unwrap_err()
.to_string()
.contains("command timed out")
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn child_compilation_pauses_the_parent_deadline() -> anyhow::Result<()> {
use super::super::{GroupGuard, Request, Stdio, deadline::Deadline};
use std::time::Duration;
let temp = tempfile::tempdir()?;
let world = crate::world::World::open(temp.path())?;
let compiler = Compiler::new(&temp.path().join("cache"))?;
let parent = wat::parse_str(
r#"(module (memory (export "memory") 1) (func (export "_start") (loop br 0)))"#,
)?;
std::fs::write(temp.path().join("world/parent.wasm"), &parent)?;
std::fs::write(
temp.path().join("world/child.wasm"),
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "_start")))"#)?,
)?;
compiler.get(parent).await?;
let runtime = super::super::Runtime::with_compiler(world, compiler.clone())?;
let deadline = Deadline::new(Duration::ZERO);
let request = |name: &str| Request {
program: name.into(),
argv: vec![name.into()],
cwd: "/".into(),
env: Vec::new(),
group: None,
stdin: Stdio::Null,
stdout: Stdio::Null,
stderr: Stdio::Null,
};
let parent = runtime
.start(
request("/parent.wasm"),
0,
0,
0,
Vec::new(),
None,
None,
Some(deadline.clone()),
)
.await?;
let _guard = GroupGuard {
runtime: runtime.clone(),
group: parent.pid,
};
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (finish_tx, finish_rx) = std::sync::mpsc::channel();
*compiler.before_compile.lock().unwrap() = Some(Box::new(move || {
let _ = started_tx.send(());
let _ = finish_rx.recv();
}));
let child_runtime = runtime.clone();
let child = tokio::spawn(async move {
child_runtime
.start(
request("/child.wasm"),
parent.pid,
parent.pid,
parent.group,
Vec::new(),
None,
None,
None,
)
.await
});
let started = tokio::time::timeout(Duration::from_secs(5), started_rx).await;
let expired = deadline.expired();
tokio::pin!(expired);
let paused = std::future::poll_fn(|cx| {
std::task::Poll::Ready(expired.as_mut().poll(cx).is_pending())
})
.await;
let _ = finish_tx.send(());
started??;
assert!(paused, "Child compilation did not pause the parent budget");
child.await??;
Ok(())
}
#[test]
fn compilation_survives_the_originating_runtime() {
let temp = tempfile::tempdir().unwrap();
+87
View File
@@ -0,0 +1,87 @@
//! Command-wide execution budgets exclude the union of component compilation waits, including child spawns.
use std::{sync::Arc, time::Duration};
use tokio::{sync::watch, time::Instant};
#[derive(Clone, Copy)]
struct State {
deadline: Instant,
compiling: usize,
paused_at: Instant,
}
pub(super) struct Deadline(watch::Sender<State>);
impl Deadline {
pub fn new(duration: Duration) -> Arc<Self> {
let now = Instant::now();
Arc::new(Self(
watch::channel(State {
deadline: now + duration,
compiling: 0,
paused_at: now,
})
.0,
))
}
pub fn pause(self: &Arc<Self>) -> Compilation {
self.0.send_modify(|state| {
if state.compiling == 0 {
state.paused_at = Instant::now();
}
state.compiling += 1;
});
Compilation(self.clone())
}
pub async fn expired(&self) {
let mut state = self.0.subscribe();
loop {
let current = *state.borrow_and_update();
if current.compiling == 0 {
tokio::select! {
biased;
changed = state.changed() => { changed.unwrap(); }
_ = tokio::time::sleep_until(current.deadline) => return,
}
} else {
state.changed().await.unwrap();
}
}
}
}
pub(super) struct Compilation(Arc<Deadline>);
impl Drop for Compilation {
fn drop(&mut self) {
self.0.0.send_modify(|state| {
state.compiling -= 1;
if state.compiling == 0 {
state.deadline += state.paused_at.elapsed();
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn overlapping_compilations_share_one_pause_and_drop_resumes() {
let deadline = Deadline::new(Duration::from_secs(2));
let first = deadline.pause();
let paused_at = deadline.0.borrow().paused_at;
let second = deadline.pause();
assert_eq!(deadline.0.borrow().compiling, 2);
assert_eq!(deadline.0.borrow().paused_at, paused_at);
let original = deadline.0.borrow().deadline;
drop(first);
assert_eq!(deadline.0.borrow().compiling, 1);
assert_eq!(deadline.0.borrow().deadline, original);
drop(second);
assert_eq!(deadline.0.borrow().compiling, 0);
assert!(deadline.0.borrow().deadline >= original);
}
}
+112 -78
View File
@@ -47,6 +47,7 @@ mod bindings {
}
use bindings::sausage::process::host::{Host, HostChild, Request, Stdio};
mod cache;
mod deadline;
mod http;
pub use http::Router;
pub mod net;
@@ -83,6 +84,7 @@ struct Entry {
command: u32,
group: u32,
cancelled: Arc<AtomicBool>,
deadline: Option<Arc<deadline::Deadline>>,
tag: sausage_vfs::journal::Tag,
abort: AbortHandle,
}
@@ -329,16 +331,17 @@ impl Runtime {
conversations: Vec<String>,
inherited_io: Option<Io>,
tool_call: Option<String>,
deadline: Option<Arc<deadline::Deadline>>,
) -> anyhow::Result<Started> {
anyhow::ensure!(!request.argv.is_empty(), "empty argv");
let cancelled = if parent == 0 {
Arc::new(AtomicBool::new(false))
let (cancelled, deadline) = if parent == 0 {
(Arc::new(AtomicBool::new(false)), deadline)
} else {
self.entries
.lock()
.unwrap()
.get(&parent)
.map(|entry| entry.cancelled.clone())
.map(|entry| (entry.cancelled.clone(), entry.deadline.clone()))
.ok_or_else(|| anyhow::anyhow!("parent exited"))?
};
if !request.env.iter().any(|(k, _)| k == "PATH") {
@@ -366,7 +369,9 @@ impl Runtime {
let bytes = file
.read(0, usize::try_from(file.stat().await?.size)?)
.await?;
let compilation = deadline.as_ref().map(|deadline| deadline.pause());
let pre = self.compiler.get(bytes.clone()).await?;
drop(compilation);
self.cache.lock().unwrap().insert(bytes);
let pid = self.next.fetch_add(1, Ordering::Relaxed);
let tag = if parent == 0 {
@@ -425,7 +430,13 @@ impl Runtime {
None
};
let mut mounts = Mounts::default();
mounts.mount("/", root, false);
let operations = mounts.mount("/", root, false);
let creations =
matches!(executable.rsplit('/').next(), Some("git" | "git.wasm")).then(|| {
sausage_vfs::Layer::observer(&mounts)
.unwrap()
.exclusive_creations()
});
let mut wasi = WasiCtxBuilder::new();
wasi.args(&request.argv)
.envs(&request.env)
@@ -485,6 +496,8 @@ impl Runtime {
runtime: self.clone(),
pid,
tx,
creations,
operations,
};
// The start gate makes the entry visible before a guest can spawn children or exit.
let (gate, ready) = tokio::sync::oneshot::channel();
@@ -528,6 +541,7 @@ impl Runtime {
command,
group,
cancelled: cancelled.clone(),
deadline,
tag,
abort: task.abort_handle(),
},
@@ -553,19 +567,40 @@ struct Cleanup {
runtime: Arc<Runtime>,
pid: u32,
tx: watch::Sender<Option<i32>>,
creations: Option<sausage_vfs::observe::Creations>,
operations: sausage_vfs::Process,
}
impl Cleanup {
fn reaped(runtime: &Runtime, pid: u32) {
runtime.entries.lock().unwrap().remove(&pid);
runtime.world.processes.lock().unwrap().remove(&pid);
}
}
impl Drop for Cleanup {
fn drop(&mut self) {
if self.tx.borrow().is_none() {
self.tx.send_replace(Some(137));
if self.tx.borrow().is_none()
&& let Some(creations) = self.creations.clone()
{
let (runtime, pid, tx, operations) = (
self.runtime.clone(),
self.pid,
self.tx.clone(),
self.operations.clone(),
);
tokio::spawn(async move {
operations.idle().await;
// A cancelled guest cannot run Git's lockfile destructors. Retain
// ownership across descriptor closure, but never remove a replacement.
let result = creations.remove(|path| path.ends_with("/index.lock")).await;
Cleanup::reaped(&runtime, pid);
tx.send_replace(Some(if result.is_ok() { 137 } else { 125 }));
});
} else {
if self.tx.borrow().is_none() {
self.tx.send_replace(Some(137));
}
Cleanup::reaped(&self.runtime, self.pid);
}
self.runtime.entries.lock().unwrap().remove(&self.pid);
self.runtime
.world
.processes
.lock()
.unwrap()
.remove(&self.pid);
}
}
impl Host for State {
@@ -583,6 +618,7 @@ impl Host for State {
self.conversations.clone(),
Some(self.stdio.clone()),
None,
None,
)
.await
{
@@ -707,79 +743,77 @@ impl Exec for Arc<Runtime> {
options: Options,
output: &mut (dyn for<'chunk> FnMut(Stream, &'chunk [u8]) + Send),
) -> anyhow::Result<i32> {
let deadline = options
.timeout
.map(|duration| tokio::time::Instant::now() + duration);
let argv = match command {
Command::Shell(script) => vec![
"/bin/sh".into(),
"--noprofile".into(),
"--norc".into(),
"-c".into(),
script,
],
Command::Argv(argv) => argv,
};
let env = options.env;
let start = self.start(
Request {
program: argv.first().cloned().unwrap_or_default(),
argv,
env,
cwd: options.cwd,
group: None,
stdin: Stdio::Pipe,
stdout: Stdio::Pipe,
stderr: Stdio::Pipe,
},
0,
0,
0,
options.conversations,
None,
options.tool_call,
);
let mut child = if let Some(deadline) = deadline {
tokio::time::timeout_at(deadline, start)
.await
.map_err(|_| anyhow::anyhow!("command timed out"))??
} else {
start.await?
};
let _guard = GroupGuard {
runtime: self.clone(),
group: child.pid,
};
drop(child.input);
let mut stdout = child.output.take().unwrap();
let mut stderr = child.error.take().unwrap();
let deadline = options.timeout.map(deadline::Deadline::new);
let run = async {
let (mut out_open, mut err_open) = (true, true);
let (mut out, mut err) = ([0; 8192], [0; 8192]);
while out_open || err_open {
tokio::select! {
n = stdout.read(&mut out), if out_open => { let n = n?; out_open = n != 0; if n != 0 { output(Stream::Stdout, &out[..n]); } }
n = stderr.read(&mut err), if err_open => { let n = n?; err_open = n != 0; if n != 0 { output(Stream::Stderr, &err[..n]); } }
let argv = match command {
Command::Shell(script) => vec![
"/bin/sh".into(),
"--noprofile".into(),
"--norc".into(),
"-c".into(),
script,
],
Command::Argv(argv) => argv,
};
let env = options.env;
let mut child = self
.start(
Request {
program: argv.first().cloned().unwrap_or_default(),
argv,
env,
cwd: options.cwd,
group: None,
stdin: Stdio::Pipe,
stdout: Stdio::Pipe,
stderr: Stdio::Pipe,
},
0,
0,
0,
options.conversations,
None,
options.tool_call,
deadline.clone(),
)
.await?;
let _guard = GroupGuard {
runtime: self.clone(),
group: child.pid,
};
drop(child.input);
let mut stdout = child.output.take().unwrap();
let mut stderr = child.error.take().unwrap();
let run = async {
let (mut out_open, mut err_open) = (true, true);
let (mut out, mut err) = ([0; 8192], [0; 8192]);
while out_open || err_open {
tokio::select! {
n = stdout.read(&mut out), if out_open => { let n = n?; out_open = n != 0; if n != 0 { output(Stream::Stdout, &out[..n]); } }
n = stderr.read(&mut err), if err_open => { let n = n?; err_open = n != 0; if n != 0 { output(Stream::Stderr, &err[..n]); } }
}
}
}
loop {
if let Some(code) = *child.code.borrow_and_update() {
return Ok(code);
loop {
if let Some(code) = *child.code.borrow_and_update() {
return Ok(code);
}
child.code.changed().await?;
}
child.code.changed().await?;
};
let result = run.await;
if let Some(journal) = self.world.root.journal() {
journal.retain().await?;
}
result
};
let result = if let Some(deadline) = deadline {
tokio::time::timeout_at(deadline, run)
.await
.map_err(|_| anyhow::anyhow!("command timed out"))?
if let Some(deadline) = &deadline {
tokio::select! {
result = run => result,
_ = deadline.expired() => anyhow::bail!("command timed out"),
}
} else {
run.await
};
if let Some(journal) = self.world.root.journal() {
journal.retain().await?;
}
result
}
}
/// Construct the harness's shared executor over the same open world.
+1
View File
@@ -34,6 +34,7 @@ pub fn supervise(runtime: Arc<Runtime>) -> Supervisor {
Vec::new(),
None,
None,
None,
);
let child = tokio::select! { _ = &mut stop => return, child = start => child };
if let Ok(mut child) = child {
+30 -2
View File
@@ -3,7 +3,7 @@
//! Layout of a world directory:
//! - `world/`: the virtual root `/` that every guest sees. Agents change everything in it.
//! - `host/`: host state outside the sandbox (harness storage, later the vault). No guest sees it.
use sausage_vfs::{Generated, Host, Layer, Memory, Mounts, Process};
use sausage_vfs::{Device, Generated, Host, Layer, Memory, Mounts, Process};
use std::{
path::{Path, PathBuf},
sync::Arc,
@@ -24,7 +24,9 @@ fn copy_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
for entry in std::fs::read_dir(source)? {
let entry = entry?;
let target = destination.join(entry.file_name());
if entry.file_type()?.is_dir() {
if entry.file_type()?.is_symlink() {
std::os::unix::fs::symlink(std::fs::read_link(entry.path())?, &target)?;
} else if entry.file_type()?.is_dir() {
copy_tree(&entry.path(), &target)?;
} else {
std::fs::copy(entry.path(), target)?;
@@ -40,6 +42,9 @@ impl World {
}
/// Copy an optional bundle into a new world. Existing worlds retain their programs.
/// Each `.wasm` tool keeps its independent file and gains a relative `/bin/<name>` symlink.
/// `/dev` exposes generated null, zero, and random byte devices in every world.
/// Missing `/etc/gitconfig` defaults to Sausage <sausage@localhost>. Local repository config overrides it.
pub fn open_with_userland(dir: &Path, userland: Option<&Path>) -> anyhow::Result<Self> {
let root_dir = dir.join("world");
let host_dir = dir.join("host");
@@ -49,6 +54,24 @@ impl World {
for folder in ["bin", "src"] {
copy_tree(&bundle.join(folder), &root_dir.join(folder))?;
}
// Keep independent .wasm files and expose conventional executable names.
for entry in std::fs::read_dir(root_dir.join("bin"))? {
let path = entry?.path();
if path.extension().is_some_and(|ext| ext == "wasm") {
let alias = path.with_extension("");
if std::fs::symlink_metadata(&alias).is_err() {
std::os::unix::fs::symlink(path.file_name().unwrap(), alias)?;
}
}
}
}
let gitconfig = root_dir.join("etc/gitconfig");
if !gitconfig.try_exists()? {
std::fs::create_dir_all(root_dir.join("etc"))?;
std::fs::write(
gitconfig,
"[user]\n\tname = Sausage\n\temail = sausage@localhost\n",
)?;
}
std::fs::create_dir_all(&host_dir)?;
let mut mounts = Mounts::default();
@@ -81,6 +104,11 @@ impl World {
});
mounts.mount("/proc", Arc::new(proc), true);
mounts.mount("/run", Arc::new(Memory::default()), false);
let mut devices = Generated::default();
devices.insert_device("/null", Device::Null);
devices.insert_device("/zero", Device::Zero);
devices.insert_device("/urandom", Device::Random);
mounts.mount("/dev", Arc::new(devices), false);
let journal =
sausage_vfs::journal::Journal::open(&host_dir.join("undo"), Arc::new(mounts))?;
let root = journal.layer(sausage_vfs::journal::Tag::process(
+37
View File
@@ -0,0 +1,37 @@
# Host integration tests
All host integration tests share the `it` executable. Each module keeps its former file name.
```sh
cargo test -p sausage --test it
cargo test -p sausage --test it -- chain::
cargo test -p sausage --test it -- --test-threads=1
```
Use `nix develop -c` and the required build slot wrapper for these commands.
Set `CARGO_TARGET_DIR` to your executor's target directory.
The journal timing benchmark keeps its 10% threshold but does not run in the default suite.
Run it by hand on an idle machine:
```sh
cargo test --release -p sausage --test it -- --ignored undo_cost
```
Add new modules under `host/tests/it/` and declare them in `it/main.rs`.
Cargo does not discover separate test executables in `host/tests/`.
Fixture sources remain in `builder-fixture/`.
The bundle-copy regression preserves relative symlink targets, including broken links.
`common.rs` shares the target lookup and the cached userland Git build.
The goal/questions and harness API fixtures copy the complete extension tree through its shared helper.
A shared mutex protects the cwd-init output and the brush checkout during fixture builds.
The cwd-init build runs at most once per process when its output is absent.
The first parallel run reproduced a collision when two compiler processes wrote `init.o`.
Tests use temporary worlds and ephemeral listener ports.
Child processes receive environment variables through `Command`, not through changes to the test process.
SIGKILL parents use fully qualified test filters with `--exact`.
The `hook::hook_git` and `hook_git` registrations both existed before this move.
Both remain as wrappers around one shared helper to preserve the test count.
The shared `hook_guest` module now builds its fixture once per process.
@@ -6,7 +6,7 @@ use std::{
process::Command,
time::{Duration, Instant},
};
const BUNDLE: &str = include_str!("../js/automations-test-bundle.js");
const BUNDLE: &str = include_str!("../../js/automations-test-bundle.js");
fn copy_tree(source: &Path, destination: &Path) -> Result<()> {
std::fs::create_dir_all(destination)?;
for entry in std::fs::read_dir(source)? {
@@ -24,14 +24,7 @@ fn setup(directory: &Path) -> 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() {
ensure!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success(),
"cwd build failed"
);
crate::common::cwd_init();
}
ensure!(
Command::new("cargo")
@@ -57,11 +50,7 @@ fn setup(directory: &Path) -> Result<()> {
let release = target.join("wasm32-wasip2/release");
if !release.join("brush.wasm").is_file() {
ensure!(
Command::new("nu")
.arg("brush/build.nu")
.current_dir(&userland)
.status()?
.success(),
crate::common::build_brush()?.success(),
"shell build failed"
);
}
@@ -251,7 +240,7 @@ async fn fire_after_sigkill() -> Result<()> {
let directory = tempfile::tempdir()?;
setup(directory.path())?;
let mut child = Command::new(std::env::current_exe()?)
.args(["--exact", "automations_child", "--nocapture"])
.args(["--exact", "automations::automations_child", "--nocapture"])
.env("SAUSAGE_AUTOMATIONS_CHILD", directory.path())
.spawn()?;
let deadline = Instant::now() + Duration::from_secs(60);
@@ -13,13 +13,7 @@ fn programs() -> PathBuf {
static PATH: OnceLock<PathBuf> = OnceLock::new();
PATH.get_or_init(|| {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
assert!(
std::process::Command::new("nu")
.arg(root.join("userland/cwd-init/build.nu"))
.status()
.unwrap()
.success()
);
crate::common::cwd_init();
assert!(
std::process::Command::new("cargo")
.args(["build", "--release", "-p", "world-cargo"])
@@ -28,13 +22,7 @@ fn programs() -> PathBuf {
.unwrap()
.success()
);
assert!(
std::process::Command::new("nu")
.arg(root.join("userland/brush/build.nu"))
.status()
.unwrap()
.success()
);
assert!(crate::common::build_brush().unwrap().success());
PathBuf::from(std::env::var_os("CARGO_TARGET_DIR").expect("executor target required"))
.join("wasm32-wasip2/release")
})
@@ -108,23 +96,23 @@ async fn cargo_build_test_and_source_changes() -> anyhow::Result<()> {
for (path, bytes) in [
(
"Cargo.toml",
include_bytes!("builder-fixture/Cargo.toml").as_slice(),
include_bytes!("../builder-fixture/Cargo.toml").as_slice(),
),
(
"build.rs",
include_bytes!("builder-fixture/build.rs").as_slice(),
include_bytes!("../builder-fixture/build.rs").as_slice(),
),
(
"src/main.rs",
include_bytes!("builder-fixture/src/main.rs").as_slice(),
include_bytes!("../builder-fixture/src/main.rs").as_slice(),
),
(
"tests/integration.rs",
include_bytes!("builder-fixture/tests/integration.rs").as_slice(),
include_bytes!("../builder-fixture/tests/integration.rs").as_slice(),
),
(
"src/add.c",
include_bytes!("builder-fixture/src/add.c").as_slice(),
include_bytes!("../builder-fixture/src/add.c").as_slice(),
),
] {
std::fs::write(source.join(path), bytes)?;
+4 -15
View File
@@ -16,14 +16,7 @@ fn components() -> Result<PathBuf> {
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() {
ensure!(
std::process::Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success(),
"cwd fixture build failed"
);
crate::common::cwd_init();
}
ensure!(
std::process::Command::new("cargo")
@@ -48,11 +41,7 @@ fn components() -> Result<PathBuf> {
let release = target.join("wasm32-wasip2/release");
if !release.join("brush.wasm").is_file() {
ensure!(
std::process::Command::new("nu")
.arg("brush/build.nu")
.current_dir(&userland)
.status()?
.success(),
crate::common::build_brush()?.success(),
"shell fixture build failed"
);
}
@@ -101,7 +90,7 @@ async fn ancestry_reaches_the_real_hook() -> Result<()> {
},
runtime.clone(),
cred.clone(),
include_str!("../js/chain-test-bundle.js"),
include_str!("../../js/chain-test-bundle.js"),
)
.await?;
let records: serde_json::Value =
@@ -155,7 +144,7 @@ async fn ancestry_reaches_the_real_hook() -> Result<()> {
});
let source = format!(
"globalThis.chainReady = true; globalThis.chainPort = {port};\n{}",
include_str!("../js/chain-test-bundle.js")
include_str!("../../js/chain-test-bundle.js")
);
let result = tokio::time::timeout(
Duration::from_secs(90),
+81
View File
@@ -0,0 +1,81 @@
//! Shared fixture copies and builds. A mutex protects cwd-init and the brush checkout.
use std::{
path::{Path, PathBuf},
process::{Command, ExitStatus},
sync::{Mutex, OnceLock},
};
pub static BUILD: Mutex<()> = Mutex::new(());
pub fn copy_tree(source: &Path, destination: &Path) -> anyhow::Result<()> {
std::fs::create_dir_all(destination)?;
for entry in std::fs::read_dir(source)? {
let entry = entry?;
let target = destination.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_tree(&entry.path(), &target)?;
} else {
std::fs::copy(entry.path(), target)?;
}
}
Ok(())
}
pub fn target_dir() -> PathBuf {
std::env::var_os("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
Path::new(env!("CARGO_TARGET_TMPDIR"))
.parent()
.unwrap()
.to_owned()
})
}
pub fn cwd_init() {
static INIT: OnceLock<()> = OnceLock::new();
INIT.get_or_init(|| {
let _guard = BUILD.lock().unwrap();
let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland");
if !userland.join("cwd-init/init.o").is_file() {
assert!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(userland)
.status()
.unwrap()
.success()
);
}
});
}
pub fn build_brush() -> std::io::Result<ExitStatus> {
let _guard = BUILD.lock().unwrap();
Command::new("nu")
.args(["--no-config-file", "brush/build.nu"])
.env("CARGO_TARGET_DIR", target_dir())
.current_dir(Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland"))
.status()
}
pub fn build_git() -> &'static Path {
static GIT: OnceLock<PathBuf> = OnceLock::new();
GIT.get_or_init(|| {
let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland");
let target = target_dir();
cwd_init();
assert!(
Command::new("cargo")
.args(["build", "--release", "-p", "git", "--manifest-path"])
.arg(userland.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(&target)
.args(["-j", "2", "--locked"])
.current_dir(&userland)
.status()
.unwrap()
.success()
);
target
})
}
@@ -4,7 +4,7 @@ use sausage::{
world::World,
};
use std::{path::Path, sync::Arc};
const BUNDLE: &str = include_str!("../js/core-ext-test-bundle.js");
const BUNDLE: &str = include_str!("../../js/core-ext-test-bundle.js");
struct Author(Arc<dyn Exec>);
#[async_trait::async_trait]
impl Exec for Author {
@@ -47,28 +47,7 @@ fn write(root: &Path, path: &str, text: &str) -> anyhow::Result<()> {
#[tokio::test(flavor = "current_thread")]
async fn core_extensions() -> anyhow::Result<()> {
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let userland = repo.join("userland");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
if !userland.join("cwd-init/init.o").exists() {
assert!(
std::process::Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success()
);
}
assert!(
std::process::Command::new("cargo")
.args(["build", "--release", "-p", "git", "--manifest-path"])
.arg(userland.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
.current_dir(&userland)
.status()?
.success()
);
let target = crate::common::build_git();
let dir = tempfile::tempdir()?;
let world = World::open(dir.path())?;
let disk = dir.path().join("world");
+2 -25
View File
@@ -1,7 +1,7 @@
//! World-loaded dream built-ins and messages survive a harness reopen.
use sausage::{harness::run_module, world::World};
use std::{path::Path, process::Command};
const BUNDLE: &str = include_str!("../js/dream-test-bundle.js");
const BUNDLE: &str = include_str!("../../js/dream-test-bundle.js");
fn git(repo: &Path, args: &[&str]) -> anyhow::Result<String> {
let output = Command::new("git")
.args(args)
@@ -36,30 +36,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
let disk = dir.join("world");
let repo = disk.join("src/sausage");
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
let userland = source.join("userland");
if !userland.join("cwd-init/init.o").exists() {
anyhow::ensure!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success(),
"cwd-init build failed"
);
}
anyhow::ensure!(
Command::new("cargo")
.args(["build", "--release", "-p", "git", "--manifest-path"])
.arg(userland.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
.current_dir(&userland)
.status()?
.success(),
"world git build failed"
);
let target = crate::common::build_git();
std::fs::create_dir_all(disk.join("bin"))?;
std::fs::copy(
target.join("wasm32-wasip2/release/git.wasm"),
@@ -20,7 +20,7 @@ async fn restart_child() -> anyhow::Result<()> {
let dir = std::path::Path::new(&dir);
let source = format!(
"globalThis.expectedState = 'approved';\n{}",
include_str!("../js/durable-test-bundle.js")
include_str!("../../js/durable-test-bundle.js")
);
run_module(
World::open(dir)?,
@@ -59,7 +59,7 @@ fn sigkill_preserves_request_wait() -> anyhow::Result<()> {
cred.unlocked()?,
)?;
let mut child = Command::new(std::env::current_exe()?)
.args(["--exact", "restart_child", "--nocapture"])
.args(["--exact", "durable::restart_child", "--nocapture"])
.env("DURABLE_RESTART_DIR", &dir)
.spawn()?;
let deadline = Instant::now() + Duration::from_secs(30);
@@ -117,7 +117,7 @@ fn sigkill_preserves_request_wait() -> anyhow::Result<()> {
)?;
for _ in 0..2 {
let mut resumed = Command::new(std::env::current_exe()?)
.args(["--exact", "restart_child", "--nocapture"])
.args(["--exact", "durable::restart_child", "--nocapture"])
.env("DURABLE_RESTART_DIR", &dir)
.spawn()?;
let deadline = Instant::now() + Duration::from_secs(30);
@@ -147,7 +147,7 @@ async fn proxy_child() -> anyhow::Result<()> {
let dir = std::path::Path::new(&dir);
let source = format!(
"globalThis.proxyOnly = true; const proxy = Deno.core.ops.op_model_proxy(); if (proxy.base_url !== 'https://proxy.invalid' || proxy.api_key !== 'faux-private-token') throw new Error('Wrong proxy item');\n{}",
include_str!("../js/durable-test-bundle.js")
include_str!("../../js/durable-test-bundle.js")
);
run_module(
World::open(dir)?,
@@ -172,7 +172,7 @@ fn proxy_bootstrap_once() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join(format!("sausage-durable-proxy-{}", std::process::id()));
for start in 0..2 {
let status = std::process::Command::new(std::env::current_exe()?)
.args(["--exact", "proxy_child", "--nocapture"])
.args(["--exact", "durable::proxy_child", "--nocapture"])
.env("DURABLE_PROXY_DIR", &dir)
.env(
"PI_PROXY_BASE_URL",
@@ -259,7 +259,7 @@ async fn decisions_wake_idle_conversation() -> anyhow::Result<()> {
)?;
let source = format!(
"globalThis.expectedState = {state:?};\n{}",
include_str!("../js/durable-test-bundle.js")
include_str!("../../js/durable-test-bundle.js")
);
let decide = async {
let conversation = loop {
@@ -5,7 +5,7 @@ use std::{
process::Command,
time::{Duration, Instant},
};
const BUNDLE: &str = include_str!("../js/events-test-bundle.js");
const BUNDLE: &str = include_str!("../../js/events-test-bundle.js");
struct Author(std::sync::Arc<dyn sausage::exec::Exec>);
#[async_trait::async_trait]
impl sausage::exec::Exec for Author {
@@ -51,7 +51,7 @@ async fn restart_child() -> anyhow::Result<()> {
fn sigkill_between_emit_and_reaction() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let mut child = Command::new(std::env::current_exe()?)
.args(["--exact", "restart_child", "--nocapture"])
.args(["--exact", "events::restart_child", "--nocapture"])
.env("EVENTS_RESTART_DIR", dir.path())
.env("EVENTS_MODE", "emit")
.spawn()?;
@@ -68,7 +68,7 @@ fn sigkill_between_emit_and_reaction() -> anyhow::Result<()> {
child.wait()?;
for _ in 0..2 {
let mut child = Command::new(std::env::current_exe()?)
.args(["--exact", "restart_child", "--nocapture"])
.args(["--exact", "events::restart_child", "--nocapture"])
.env("EVENTS_RESTART_DIR", dir.path())
.env("EVENTS_MODE", "consume")
.spawn()?;
@@ -90,28 +90,7 @@ fn sigkill_between_emit_and_reaction() -> anyhow::Result<()> {
}
#[tokio::test(flavor = "current_thread")]
async fn world_extensions() -> 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() {
assert!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success()
);
}
assert!(
Command::new("cargo")
.args(["build", "--release", "-p", "git", "--manifest-path"])
.arg(userland.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
.current_dir(&userland)
.status()?
.success()
);
let target = crate::common::build_git();
let dir = tempfile::tempdir()?;
let world = World::open(dir.path())?;
let disk = dir.path().join("world");
+165
View File
@@ -0,0 +1,165 @@
//! Parse extension modules and reject free runtime identifiers outside the world runtime globals.
use deno_ast::swc::{
ast::{Ident, TsInterfaceDecl, TsType},
common::SyntaxContext,
ecma_visit::{Visit, VisitWith},
};
use deno_ast::{MediaType, ParseParams, ProgramRef, parse_module};
use std::path::Path;
// Standard JavaScript globals. World extensions also see Deno.
const GLOBALS: &[&str] = &[
"undefined",
"globalThis",
"Deno",
"console",
"crypto",
"fetch",
"performance",
"setTimeout",
"clearTimeout",
"setInterval",
"clearInterval",
"queueMicrotask",
"structuredClone",
"atob",
"btoa",
"parseInt",
"parseFloat",
"isNaN",
"Infinity",
"NaN",
"Object",
"Function",
"Boolean",
"Number",
"BigInt",
"String",
"Symbol",
"Array",
"Map",
"Set",
"WeakMap",
"WeakSet",
"WeakRef",
"FinalizationRegistry",
"Promise",
"Date",
"RegExp",
"Error",
"TypeError",
"RangeError",
"SyntaxError",
"AggregateError",
"JSON",
"Math",
"Reflect",
"Proxy",
"Intl",
"Iterator",
"Uint8Array",
"ArrayBuffer",
"TextEncoder",
"TextDecoder",
"URL",
"URLSearchParams",
"AbortController",
"AbortSignal",
"Request",
"Response",
"Headers",
"ReadableStream",
"WritableStream",
"TransformStream",
"Blob",
"File",
"FormData",
"DOMException",
"encodeURIComponent",
"decodeURIComponent",
];
fn undefined(source: &str) -> Vec<String> {
let parsed = parse_module(ParseParams {
specifier: "file:///extension.ts".parse().unwrap(),
text: source.into(),
media_type: MediaType::TypeScript,
capture_tokens: false,
scope_analysis: true,
maybe_syntax: None,
})
.unwrap();
struct FreeNames {
context: SyntaxContext,
names: Vec<String>,
}
impl Visit for FreeNames {
// Type signatures contain parameter names but do not execute.
fn visit_ts_type(&mut self, _node: &TsType) {}
fn visit_ts_interface_decl(&mut self, _node: &TsInterfaceDecl) {}
fn visit_ident(&mut self, ident: &Ident) {
if ident.ctxt == self.context && !GLOBALS.contains(&ident.sym.as_ref()) {
self.names.push(ident.sym.to_string());
}
}
}
let mut free = FreeNames {
context: parsed.unresolved_context(),
names: Vec::new(),
};
match parsed.program_ref() {
ProgramRef::Module(module) => module.visit_with(&mut free),
ProgramRef::Script(script) => script.visit_with(&mut free),
}
free.names.sort();
free.names.dedup();
free.names
}
fn scan(directory: &Path, failures: &mut Vec<String>) {
for entry in std::fs::read_dir(directory).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
scan(&path, failures);
} else if path.extension().is_some_and(|ext| ext == "ts")
&& !path.to_string_lossy().ends_with(".test.ts")
{
let names = undefined(&std::fs::read_to_string(&path).unwrap());
if !names.is_empty() {
failures.push(format!("{}: {}", path.display(), names.join(", ")));
}
}
}
}
#[test]
fn extension_identifiers_are_defined() {
let mut failures = Vec::new();
scan(
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../extensions"),
&mut failures,
);
assert!(
failures.is_empty(),
"Undefined identifiers:\n{}",
failures.join("\n")
);
}
#[test]
fn catches_the_missing_route_import() {
let source = std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR")).join("../extensions/messages/route.ts"),
)
.unwrap();
let old = source.replace(
"import { ConversationsApi } from '../conversations/api.ts'\n",
"",
);
assert!(
undefined(&old)
.iter()
.any(|name| name == "ConversationsApi")
);
assert!(undefined("import { Api } from 'service'; type Callback = (arg: UnknownType) => void; interface Service { use(parameter: UnknownType): void }; function use(host: any) { const local = Api; return host.need(local) }").is_empty());
assert_eq!(
undefined("function use(host: any) { return host.need(MissingApi) }"),
["MissingApi"]
);
}
@@ -4,7 +4,7 @@ use sausage::{
world::World,
};
use std::{path::Path, sync::Arc};
const BUNDLE: &str = include_str!("../js/extensions-test-bundle.js");
const BUNDLE: &str = include_str!("../../js/extensions-test-bundle.js");
struct Author(Arc<dyn Exec>);
#[async_trait::async_trait]
impl Exec for Author {
@@ -30,13 +30,7 @@ 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() {
assert!(
std::process::Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success()
);
crate::common::cwd_init();
}
assert!(
std::process::Command::new("cargo")
+94
View File
@@ -0,0 +1,94 @@
//! Fresh worlds provide a Git identity and resolve conventional tool names through which.
use sausage::{
exec::{Command, Options, Stream},
world::World,
};
#[tokio::test(flavor = "current_thread")]
async fn default_identity_and_which() -> anyhow::Result<()> {
let userland = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland");
let target = crate::common::build_git();
assert!(
std::process::Command::new("cargo")
.args(["build", "--release", "-p", "sausage-exec-tools", "--locked"])
.current_dir(&userland)
.status()?
.success()
);
let bundle = tempfile::tempdir()?;
std::fs::create_dir_all(bundle.path().join("bin"))?;
std::fs::create_dir_all(bundle.path().join("src"))?;
for name in ["git", "which"] {
std::fs::copy(
target.join(format!("wasm32-wasip2/release/{name}.wasm")),
bundle.path().join(format!("bin/{name}.wasm")),
)?;
}
let dir = tempfile::tempdir()?;
let world = World::open_with_userland(dir.path(), Some(bundle.path()))?;
std::fs::create_dir_all(dir.path().join("world/repo"))?;
std::fs::write(dir.path().join("world/repo/file.txt"), "base\n")?;
let exec = sausage::process::exec(World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
})?;
for (argv, expected) in [
(vec!["git", "-C", "/repo", "init", "-q", "-b", "main"], ""),
(
vec!["git", "-C", "/repo", "config", "--get", "user.name"],
"Sausage\n",
),
(
vec!["git", "-C", "/repo", "config", "--get", "user.email"],
"sausage@localhost\n",
),
(vec!["git", "-C", "/repo", "add", "."], ""),
(vec!["git", "-C", "/repo", "commit", "-q", "-m", "base"], ""),
(vec!["which", "git"], "/bin/git\n"),
(vec!["which", "-a", "git"], "/bin/git\n/bin/git.wasm\n"),
] {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let code = exec
.exec(
Command::Argv(argv.iter().map(|s| (*s).into()).collect()),
Options::default(),
&mut |stream, bytes| match stream {
Stream::Stdout => stdout.extend_from_slice(bytes),
Stream::Stderr => stderr.extend_from_slice(bytes),
},
)
.await?;
assert_eq!(code, 0, "{argv:?}: {}", String::from_utf8_lossy(&stderr));
assert_eq!(stdout, expected.as_bytes(), "{argv:?}");
}
std::fs::copy(
dir.path().join("world/bin/git.wasm"),
dir.path().join("world/usr/local/bin/git.wasm"),
)?;
let mut stdout = Vec::new();
assert_eq!(
exec.exec(
Command::Argv(vec!["which".into(), "git".into()]),
Options::default(),
&mut |stream, bytes| if matches!(stream, Stream::Stdout) {
stdout.extend_from_slice(bytes);
}
)
.await?,
0
);
assert_eq!(stdout, b"/usr/local/bin/git.wasm\n");
let config = dir.path().join("world/etc/gitconfig");
std::fs::write(
&config,
"[user]\nname = Custom\nemail = custom@example.test\n",
)?;
let _ = World::open(dir.path())?;
assert_eq!(
std::fs::read_to_string(config)?,
"[user]\nname = Custom\nemail = custom@example.test\n"
);
Ok(())
}
+210
View File
@@ -0,0 +1,210 @@
//! Git stays responsive through the shell, and cancellation removes only its own index lock.
use sausage::{
exec::{Command, Exec, Options},
process::Runtime,
world::World,
};
use sausage_vfs::{Layer, Open};
use std::{
path::{Path, PathBuf},
sync::{Arc, OnceLock},
time::Duration,
};
fn guests() -> &'static Path {
static BUILT: OnceLock<PathBuf> = OnceLock::new();
BUILT.get_or_init(|| {
let target = crate::common::build_git();
assert!(crate::common::build_brush().unwrap().success());
target.join("wasm32-wasip2/release")
})
}
fn native(cwd: &Path, args: &[&str]) {
let output = std::process::Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}
struct Fixture {
_temp: tempfile::TempDir,
runtime: Arc<Runtime>,
root: Arc<dyn Layer>,
processes: sausage::process::Processes,
}
fn fixture() -> Fixture {
let programs = guests();
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("world");
for path in ["bin", "src/demo/src", "worktrees"] {
std::fs::create_dir_all(root.join(path)).unwrap();
}
for (source, name) in [("brush", "sh"), ("git", "git")] {
std::fs::copy(
programs.join(format!("{source}.wasm")),
root.join(format!("bin/{name}.wasm")),
)
.unwrap();
}
let repo = root.join("src/demo");
std::fs::write(repo.join("src/main.rs"), "old\n").unwrap();
native(&repo, &["init", "-b", "main"]);
native(&repo, &["add", "."]);
native(
&repo,
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"--no-gpg-sign",
"-m",
"initial",
],
);
native(
&repo,
&[
"worktree",
"add",
"-b",
"worker",
root.join("worktrees/160").to_str().unwrap(),
],
);
std::fs::write(
root.join("worktrees/160/.git"),
"gitdir: /src/demo/.git/worktrees/160\n",
)
.unwrap();
std::fs::write(
repo.join(".git/worktrees/160/gitdir"),
"/worktrees/160/.git\n",
)
.unwrap();
let world = World::open(temp.path()).unwrap();
let root = world.root.clone();
let processes = world.processes.clone();
let runtime = Runtime::new(world).unwrap();
Fixture {
_temp: temp,
runtime,
root,
processes,
}
}
fn options() -> Options {
Options {
cwd: "/worktrees/160".into(),
env: [
("GIT_AUTHOR_NAME", "Test"),
("GIT_AUTHOR_EMAIL", "test@example.com"),
("GIT_COMMITTER_NAME", "Test"),
("GIT_COMMITTER_EMAIL", "test@example.com"),
]
.map(|(k, v)| (k.into(), v.into()))
.into(),
..Default::default()
}
}
async fn run(fixture: &Fixture, script: &str) -> String {
let mut output = Vec::new();
let code = fixture
.runtime
.exec(Command::Shell(script.into()), options(), &mut |_, bytes| {
output.extend_from_slice(bytes)
})
.await
.unwrap();
assert_eq!(code, 0, "{}", String::from_utf8_lossy(&output));
String::from_utf8(output).unwrap()
}
async fn edit(fixture: &Fixture) {
let file = fixture
.root
.open(
"/worktrees/160/src/main.rs",
Open {
write: true,
truncate: true,
..Default::default()
},
)
.await
.unwrap();
file.write(0, b"changed\n".to_vec()).await.unwrap();
}
#[cfg(not(debug_assertions))]
#[tokio::test(flavor = "current_thread")]
async fn linked_worktree_commands_stay_under_two_seconds() {
let fixture = fixture();
run(&fixture, "git status --short").await;
edit(&fixture).await;
for script in [
"git status --short",
"git log -1",
"git add src/main.rs && git commit --no-gpg-sign -m changed",
] {
let start = std::time::Instant::now();
let output = run(&fixture, script).await;
let elapsed = start.elapsed();
println!("{script}: {elapsed:?}\n{output}");
assert!(elapsed < Duration::from_secs(2), "{script}: {elapsed:?}");
}
}
#[tokio::test(flavor = "current_thread")]
async fn cancelled_add_releases_its_index_lock() {
let fixture = fixture();
run(&fixture, "git status --short").await;
edit(&fixture).await;
let path = "/src/demo/.git/worktrees/160/index.lock";
let mut changes = fixture
.root
.observer()
.unwrap()
.subscribe(vec![path.into()]);
let mut output = Vec::new();
let mut collect = |_, bytes: &[u8]| output.extend_from_slice(bytes);
let mut command = Box::pin(fixture.runtime.exec(
Command::Argv(vec![
"/bin/git.wasm".into(),
"add".into(),
"src/main.rs".into(),
]),
options(),
&mut collect,
));
tokio::time::timeout(Duration::from_secs(30), async {
tokio::select! {
biased;
change = changes.recv() => { assert_eq!(change.unwrap().0, path); },
result = &mut command => panic!("add completed before cancellation: {result:?}"),
}
})
.await
.unwrap();
assert!(
!fixture.processes.lock().unwrap().is_empty(),
"Git must still run when cancellation occurs"
);
drop(command);
tokio::time::timeout(Duration::from_secs(30), async {
while !fixture.processes.lock().unwrap().is_empty() {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert!(fixture.root.stat(path, false).await.is_err());
run(
&fixture,
"git add src/main.rs && git commit --no-gpg-sign -m recovered",
)
.await;
}
@@ -1,7 +1,7 @@
//! Goal and question-card server cases execute through the world loader.
use sausage::{harness::run_module, world::World};
use std::{path::Path, process::Command};
const BUNDLE: &str = include_str!("../js/goal-questions-test-bundle.js");
const BUNDLE: &str = include_str!("../../js/goal-questions-test-bundle.js");
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn prepare(dir: &Path) -> anyhow::Result<()> {
let _guard = PREPARE.lock().unwrap();
@@ -11,71 +11,19 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
return Ok(());
}
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
let userland = source.join("userland");
if !userland.join("cwd-init/init.o").exists() {
assert!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success()
);
}
assert!(
Command::new("cargo")
.args(["build", "--release", "-p", "git", "--manifest-path"])
.arg(userland.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
.current_dir(&userland)
.status()?
.success()
);
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 directory in [
"extensions/subagents",
"extensions/messages",
"extensions/lineage",
"extensions/models",
"extensions/goal",
"extensions/questions",
"extensions/automations",
"extensions/core",
"agents",
] {
std::fs::create_dir_all(repo.join(directory))?;
}
for directory in [
"extensions/subagents",
"extensions/messages",
"extensions/lineage",
"extensions/models",
"extensions/goal",
"extensions/questions",
"extensions/automations",
] {
for entry in std::fs::read_dir(source.join(directory))? {
let entry = entry?;
if entry.path().extension().is_some_and(|ext| ext == "ts") {
std::fs::copy(entry.path(), repo.join(directory).join(entry.file_name()))?;
}
}
}
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"))?;
std::fs::write(
repo.join("extensions/probe/server.ts"),
"export default host => { globalThis.testHost = host; globalThis.subagentService = host.need({name:'subagents'}); globalThis.messageService = host.need({name:'messages'}); globalThis.lineageService = host.need({name:'lineage'}); host.implement({name:'conversations'}, {summaryModel: async () => globalThis.summaryRef, concludedIn: async () => new Set(), generatedTitle: async () => {}, setStanding: async () => {}, setAutomaticSummaries: async () => {}}); return {name:'probe'}; }",
)?;
std::fs::copy(
source.join("extensions/core/agents.ts"),
repo.join("extensions/core/agents.ts"),
)?;
std::fs::write(
repo.join("agents/models.json"),
r#"{"default":{"model":"faux/faux-1"}}"#,
@@ -45,7 +45,7 @@ async fn crash_child() -> anyhow::Result<()> {
let source = format!(
"globalThis.testCrash = {};\n{}",
serde_json::to_string(&phase)?,
include_str!("../js/test-bundle.js")
include_str!("../../js/test-bundle.js")
);
tokio::time::timeout(
std::time::Duration::from_secs(20),
@@ -69,7 +69,7 @@ fn sigkill_resumes_model_and_tool() -> anyhow::Result<()> {
let dir =
std::env::temp_dir().join(format!("sausage-crash-{}-{phase}", std::process::id()));
let mut child = Command::new(std::env::current_exe()?)
.args(["--exact", "crash_child", "--nocapture"])
.args(["--exact", "harness::crash_child", "--nocapture"])
.env("HARNESS_CRASH_DIR", &dir)
.env("HARNESS_CRASH_PHASE", phase)
.stdout(Stdio::piped())
@@ -92,7 +92,7 @@ fn sigkill_resumes_model_and_tool() -> anyhow::Result<()> {
child.wait()?;
ready?;
let resumed = Command::new(std::env::current_exe()?)
.args(["--exact", "crash_child", "--nocapture"])
.args(["--exact", "harness::crash_child", "--nocapture"])
.env("HARNESS_CRASH_DIR", &dir)
.env("HARNESS_CRASH_PHASE", "resume")
.status()?;
@@ -110,7 +110,7 @@ async fn file_tools_and_exec() -> anyhow::Result<()> {
let result = run_module(
World::open(&dir)?,
exec.clone(),
include_str!("../js/test-bundle.js"),
include_str!("../../js/test-bundle.js"),
)
.await;
result?;
@@ -2,7 +2,7 @@
use sausage::{harness::run_module_with_input, world::World};
use std::sync::Arc;
use std::{path::Path, process::Command};
const BUNDLE: &str = include_str!("../js/harness-api-test-bundle.js");
const BUNDLE: &str = include_str!("../../js/harness-api-test-bundle.js");
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn prepare(dir: &Path) -> anyhow::Result<()> {
let _guard = PREPARE.lock().unwrap();
@@ -15,13 +15,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
let userland = source.join("userland");
if !userland.join("cwd-init/init.o").exists() {
assert!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success()
);
crate::common::cwd_init();
}
assert!(
Command::new("cargo")
@@ -71,44 +65,13 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
target.join("wasm32-wasip2/release/curl.wasm"),
disk.join("bin/curl.wasm"),
)?;
for directory in [
"extensions/subagents",
"extensions/messages",
"extensions/lineage",
"extensions/models",
"extensions/goal",
"extensions/questions",
"extensions/automations",
"extensions/core",
"agents",
] {
std::fs::create_dir_all(repo.join(directory))?;
}
for directory in [
"extensions/subagents",
"extensions/messages",
"extensions/lineage",
"extensions/models",
"extensions/goal",
"extensions/questions",
"extensions/automations",
] {
for entry in std::fs::read_dir(source.join(directory))? {
let entry = entry?;
if entry.path().extension().is_some_and(|ext| ext == "ts") {
std::fs::copy(entry.path(), repo.join(directory).join(entry.file_name()))?;
}
}
}
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"))?;
std::fs::write(
repo.join("extensions/probe/server.ts"),
"export default host => { globalThis.testHost = host; globalThis.subagentService = host.need({name:'subagents'}); globalThis.messageService = host.need({name:'messages'}); globalThis.lineageService = host.need({name:'lineage'}); host.implement({name:'conversations'}, {summaryModel: async () => globalThis.summaryRef, concludedIn: async () => new Set(), generatedTitle: async () => {}, setStanding: async () => {}, setAutomaticSummaries: async () => {}}); return {name:'probe'}; }",
)?;
std::fs::copy(
source.join("extensions/core/agents.ts"),
repo.join("extensions/core/agents.ts"),
)?;
std::fs::write(
repo.join("agents/models.json"),
r#"{"default":{"model":"faux/faux-1"}}"#,
@@ -13,16 +13,16 @@ use std::{
time::{Duration, Instant},
};
const BUNDLE: &str = include_str!("../js/foundation-test-bundle.js");
const BUNDLE: &str = include_str!("../../js/foundation-test-bundle.js");
#[test]
fn bundle_has_patched_hooks() {
for bundle in [BUNDLE, include_str!("../js/bundle.js")] {
for bundle in [BUNDLE, include_str!("../../js/bundle.js")] {
assert!(bundle.contains("handlers.beforeInput"));
assert!(bundle.contains("input: true"));
assert!(bundle.contains("#held"));
}
assert!(!include_str!("../js/patches/pi-durable-1.5.0.patch").contains("dist/env/node"));
assert!(!include_str!("../../js/patches/pi-durable-1.5.0.patch").contains("dist/env/node"));
}
async fn probe(case: &str) -> anyhow::Result<()> {
@@ -87,7 +87,11 @@ async fn headless_child() -> anyhow::Result<()> {
#[test]
fn channel_with_closed_stdin() -> anyhow::Result<()> {
let mut child = Command::new(std::env::current_exe()?)
.args(["--exact", "headless_child", "--nocapture"])
.args([
"--exact",
"harness_foundation::headless_child",
"--nocapture",
])
.env("HEADLESS_CHILD", "1")
.stdin(Stdio::null())
.spawn()?;
+135
View File
@@ -0,0 +1,135 @@
//! SIGTERM cancels a world tool and preserves its durable invocation for restart.
use sausage::{
cred::Cred,
harness::{input, run_module_with_input},
world::World,
};
use std::{
path::Path,
process::{Command, Stdio},
sync::Arc,
time::{Duration, Instant},
};
#[tokio::test(flavor = "current_thread")]
async fn shutdown_child() -> anyhow::Result<()> {
let Ok(path) = std::env::var("SHUTDOWN_WORLD") else {
return Ok(());
};
let world = World::open(Path::new(&path))?;
let processes = world.processes.clone();
let cred = Arc::new(Cred::open(&world.host_dir)?);
let exec = sausage::process::exec(World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: processes.clone(),
})?;
let (sender, receiver) = input::channel();
let signal = tokio::spawn(input::stop_signal(sender)?);
run_module_with_input(
world,
exec,
cred,
receiver,
concat!(
"globalThis.foundationCase = 'shutdown';\n",
include_str!("../../js/foundation-test-bundle.js")
),
)
.await?;
signal.abort();
tokio::time::timeout(Duration::from_secs(1), async {
while !processes.lock().unwrap().is_empty() {
tokio::task::yield_now().await;
}
})
.await?;
Ok(())
}
#[test]
fn sigterm_cancels_tool_and_resumes() -> anyhow::Result<()> {
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
assert!(
Command::new("cargo")
.args([
"build",
"--release",
"--locked",
"-p",
"sausage-coreutils",
"--bin",
"sleep",
"--manifest-path",
])
.arg(source.join("userland/Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2"])
.status()?
.success()
);
let dir = tempfile::tempdir()?;
World::open(dir.path())?;
std::fs::create_dir_all(dir.path().join("world/bin"))?;
std::fs::copy(
target.join("wasm32-wasip2/release/sleep.wasm"),
dir.path().join("world/bin/sleep.wasm"),
)?;
let spawn = || {
Command::new(std::env::current_exe().unwrap())
.args(["--exact", "harness_shutdown::shutdown_child", "--nocapture"])
.env("SHUTDOWN_WORLD", dir.path())
.stdin(Stdio::null())
.spawn()
};
let mut child = spawn()?;
let deadline = Instant::now() + Duration::from_secs(60);
while !dir.path().join("world/sleep-ready").exists() {
if let Some(status) = child.try_wait()? {
anyhow::bail!("Tool child exited early: {status}");
}
if Instant::now() > deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("World sleep did not start");
}
std::thread::yield_now();
}
assert!(
Command::new("kill")
.args(["-TERM", &child.id().to_string()])
.status()?
.success()
);
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Some(status) = child.try_wait()? {
assert!(status.success());
break;
}
if Instant::now() > deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("SIGTERM exceeded five seconds");
}
std::thread::yield_now();
}
std::fs::write(dir.path().join("world/resume"), "yes")?;
let mut child = spawn()?;
let deadline = Instant::now() + Duration::from_secs(20);
loop {
if let Some(status) = child.try_wait()? {
assert!(status.success());
break;
}
if Instant::now() > deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("Restart did not complete");
}
std::thread::yield_now();
}
Ok(())
}
+8 -2
View File
@@ -1,6 +1,12 @@
//! Real guest HTTP requests pass through caller identity, credential injection, and streaming transport.
mod hook_git;
mod hook_guest;
mod hook_git {
//! Preserve the Git push registration that also ran inside the former hook executable.
#[tokio::test]
async fn several_mb_git_push() {
crate::hook_git::push().await;
}
}
use super::hook_guest;
use sausage::{
cred::{
self,
@@ -10,8 +10,8 @@ use std::{
io::{Read, Write},
sync::Arc,
};
#[path = "hook_guest.rs"]
mod hook_guest;
use super::hook_guest;
#[tokio::test]
async fn one_api_for_pages_and_guest_requests() -> anyhow::Result<()> {
@@ -4,12 +4,7 @@ use sausage::{
process::Runtime,
world::World,
};
use std::{
io::Write,
path::Path,
process::Stdio,
sync::{Arc, OnceLock},
};
use std::{io::Write, path::Path, process::Stdio, sync::Arc};
fn git(cwd: &Path, args: &[&str]) {
assert!(
std::process::Command::new("git")
@@ -22,42 +17,10 @@ fn git(cwd: &Path, args: &[&str]) {
}
#[tokio::test]
async fn several_mb_git_push() {
static BUILT: OnceLock<std::path::PathBuf> = OnceLock::new();
let component = BUILT.get_or_init(|| {
let target = std::env::var_os("CARGO_TARGET_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
Path::new(env!("CARGO_TARGET_TMPDIR"))
.parent()
.unwrap()
.to_owned()
});
let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland");
if !userland.join("cwd-init/init.o").is_file() {
assert!(
std::process::Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()
.unwrap()
.success()
);
}
let manifest = userland.join("Cargo.toml");
assert!(
std::process::Command::new("cargo")
.args(["build", "--release", "-p", "git", "--manifest-path"])
.arg(manifest)
.current_dir(&userland)
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(&target)
.args(["-j", "2", "--locked"])
.status()
.unwrap()
.success()
);
target.join("wasm32-wasip2/release/git.wasm")
});
push().await;
}
pub async fn push() {
let component = crate::common::build_git().join("wasm32-wasip2/release/git.wasm");
let temp = tempfile::tempdir().unwrap();
let world = World::open(temp.path()).unwrap();
let root = temp.path().join("world");
@@ -12,12 +12,12 @@ fn write(path: PathBuf, text: &str) -> std::io::Result<()> {
pub fn component() -> &'static Path {
static PATH: OnceLock<PathBuf> = OnceLock::new();
PATH.get_or_init(|| {
let target = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap().to_owned());
let target = crate::common::target_dir();
let directory = Path::new(env!("CARGO_TARGET_TMPDIR")).join("hook-guest");
std::fs::create_dir_all(directory.join("src")).unwrap();
std::fs::create_dir_all(directory.join("wit/deps")).unwrap();
write(directory.join("wit/process.wit"), include_str!("../wit/process.wit")).unwrap();
write(directory.join("wit/deps/io.wit"), include_str!("../wit/deps/io.wit")).unwrap();
write(directory.join("wit/process.wit"), include_str!("../../wit/process.wit")).unwrap();
write(directory.join("wit/deps/io.wit"), include_str!("../../wit/deps/io.wit")).unwrap();
write(directory.join("Cargo.toml"), "[package]\nname='hook-guest'\nversion='0.1.0'\nedition='2024'\n[workspace]\n[dependencies]\nwstd='=0.6.8'\nanyhow='1'\nfutures-lite='2'\nwit-bindgen='0.46'\n").unwrap();
write(directory.join("src/main.rs"), r#"
use wstd::http::{Body, BodyExt, Client, Request};
+65
View File
@@ -0,0 +1,65 @@
//! Host integration tests share one executable. Modules retain their original test registrations.
mod api;
mod api_auth;
mod api_listener;
mod automations;
mod builder;
mod chain;
mod common;
mod core_ext;
mod cred;
mod cred_accounting;
mod cred_authorization;
mod cred_challenges;
mod cred_core;
mod cred_edits;
mod cred_entry;
mod cred_grants;
mod cred_maintenance;
mod cred_oauth2;
mod cred_policy;
mod cred_presentation;
mod cred_proofs;
mod cred_quickjs;
mod cred_quickjs_security;
mod cred_regressions;
mod cred_replay;
mod cred_security;
mod cred_sign;
mod cred_storage;
mod cred_summary;
mod cred_webauthn;
mod dream;
mod durable;
mod events;
mod extension_names;
mod extensions;
mod git_compat;
mod git_runtime;
mod goal_questions;
mod harness;
mod harness_api;
mod harness_foundation;
mod harness_shutdown;
mod hook;
mod hook_api;
mod hook_git;
mod hook_guest;
mod init;
mod messages;
mod net;
mod p1;
mod p2;
mod p3;
mod p4;
mod plan_todos;
mod process;
mod proxy_models;
mod subagents;
mod undo;
mod undo_api;
mod undo_cost;
mod undo_exec;
mod undo_harness;
mod watchdog;
mod workspaces;
@@ -5,7 +5,7 @@ use std::{
process::Command,
time::{Duration, Instant},
};
const BUNDLE: &str = include_str!("../js/messages-test-bundle.js");
const BUNDLE: &str = include_str!("../../js/messages-test-bundle.js");
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn prepare(dir: &Path) -> anyhow::Result<()> {
let _guard = PREPARE.lock().unwrap();
@@ -15,28 +15,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
return Ok(());
}
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
let userland = source.join("userland");
if !userland.join("cwd-init/init.o").exists() {
assert!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success()
);
}
assert!(
Command::new("cargo")
.args(["build", "--release", "-p", "git", "--manifest-path"])
.arg(userland.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
.current_dir(&userland)
.status()?
.success()
);
let target = crate::common::build_git();
std::fs::create_dir_all(disk.join("bin"))?;
std::fs::copy(
target.join("wasm32-wasip2/release/git.wasm"),
@@ -48,6 +27,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
"extensions/lineage",
"extensions/models",
"extensions/core",
"extensions/conversations",
"agents",
] {
std::fs::create_dir_all(repo.join(directory))?;
@@ -70,6 +50,10 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
repo.join("extensions/probe/server.ts"),
"export default host => { globalThis.testHost = host; globalThis.subagentService = host.need({name:'subagents'}); globalThis.messageService = host.need({name:'messages'}); globalThis.lineageService = host.need({name:'lineage'}); host.implement({name:'conversations'}, {summaryModel: async () => globalThis.summaryRef, concludedIn: async () => new Set(), generatedTitle: async () => {}, setStanding: async () => {}, setAutomaticSummaries: async () => {}}); return {name:'probe'}; }",
)?;
std::fs::copy(
source.join("extensions/conversations/api.ts"),
repo.join("extensions/conversations/api.ts"),
)?;
std::fs::copy(
source.join("extensions/core/agents.ts"),
repo.join("extensions/core/agents.ts"),
@@ -88,7 +72,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
)?;
std::fs::write(
repo.join("agents/coordinator.md"),
"---\nname: coordinator\ndescription: Coordinate\nisolate: none\n---\nCoordinator agent\n",
"---\nname: coordinator\ndescription: Coordinate\nmodel: faux/coordinator\nisolate: none\n---\nCoordinator agent\n",
)?;
for args in [
vec!["init", "-b", "main"],
@@ -143,7 +127,7 @@ fn sigkill_after_reply_placement() -> anyhow::Result<()> {
prepare(dir.path())?;
let spawn = |mode: &str| {
Command::new(std::env::current_exe().unwrap())
.args(["--exact", "restart_child", "--nocapture"])
.args(["--exact", "messages::restart_child", "--nocapture"])
.env("MESSAGES_DIR", dir.path())
.env("MESSAGES_MODE", mode)
.spawn()
+1 -1
View File
@@ -1,5 +1,5 @@
//! Real components can accept TCP connections. Host port registration and host-only config deny access.
mod hook_guest;
use super::hook_guest;
use sausage::{
exec::{Command, Exec, Options},
process::{Runtime, net::NetPolicy},
+125 -10
View File
@@ -1,13 +1,15 @@
//! P1 integration: independent bundle copies and real shell calls from the durable harness.
//! P1 integration: bundle contents, source commits, extension loading, and real shell calls.
use sausage::{harness::run_module, world::World};
use sausage_vfs::Open;
use std::path::PathBuf;
#[tokio::test(flavor = "current_thread")]
async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
fn bundle() -> anyhow::Result<PathBuf> {
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
let bundle = PathBuf::from(std::env::var("CARGO_TARGET_DIR").unwrap()).join("bundle");
if !bundle.join("bin/sh.wasm").exists() {
let bundle = crate::common::target_dir().join("bundle");
let _guard = crate::common::BUILD.lock().unwrap();
if !bundle.join("bin/sh.wasm").exists()
|| !bundle.join("src/sausage/agents/models.json").exists()
{
assert!(
std::process::Command::new("nu")
.arg(repo.join("userland/bundle.nu"))
@@ -16,15 +18,59 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
.success()
);
}
Ok(bundle)
}
#[tokio::test(flavor = "current_thread")]
async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
let bundle = bundle()?;
let dir = tempfile::tempdir()?;
let world = World::open_with_userland(dir.path(), Some(&bundle))?;
assert!(world.root.stat("/bin/sh.wasm", true).await?.size > 0);
let snapshot = dir.path().join("world/src/sausage");
for path in [
"agents/models.json",
"agents/ultimate.md",
"skills/README.md",
"extensions/README.md",
] {
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"])?;
let revision = message.strip_prefix("Bundle source ").unwrap();
for path in ["agents", "skills", "extensions"] {
assert_eq!(
git(&snapshot, &["rev-parse", &format!("HEAD:{path}")])?,
git(&repo, &["rev-parse", &format!("{revision}:{path}")])?,
"bundled {path} must match the source commit"
);
}
use std::os::unix::fs::MetadataExt;
for entry in std::fs::read_dir(dir.path().join("world/bin"))? {
let entry = entry?;
assert_eq!(entry.path().extension().unwrap(), "wasm");
assert_eq!(entry.metadata()?.nlink(), 1);
assert!(!entry.file_type()?.is_symlink());
if entry.file_type()?.is_symlink() {
assert_eq!(
std::fs::read_link(entry.path())?,
PathBuf::from(format!("{}.wasm", entry.file_name().to_string_lossy()))
);
} else {
assert_eq!(entry.path().extension().unwrap(), "wasm");
assert_eq!(entry.metadata()?.nlink(), 1);
}
}
assert!(world.root.stat("/usr/local/bin", true).await?.directory);
let writable = world
@@ -46,7 +92,7 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
})?;
use sausage::exec::{Command, Options, Stream};
for (script, expected) in [
("command -v ls", "/bin/ls.wasm\n"),
("command -v ls", "/bin/ls\n"),
("ls", "bin"),
("git --version", "git version"),
("printf 'a\\n' | xargs echo", "a\n"),
@@ -67,6 +113,31 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
assert_eq!(code, 0, "{script}");
assert!(String::from_utf8(out)?.contains(expected), "{script}");
}
for (script, expected) in [
(
"echo x > /dev/null && cat < /dev/null && ls /bin/git && printf 'a\\nb\\n' | xargs -I{} echo [{}]",
"/bin/git\n[a]\n[b]\n",
),
("test -x /bin/git && echo executable", "executable\n"),
("head -c 4 /dev/zero | wc -c", "4\n"),
("head -c 4 /dev/urandom | wc -c", "4\n"),
("echo x > /dev/null 2>&1; cat < /dev/null", ""),
] {
let mut out = Vec::new();
let code = exec
.exec(
Command::Shell(script.into()),
Options::default(),
&mut |stream, bytes| {
if matches!(stream, Stream::Stdout) {
out.extend_from_slice(bytes);
}
},
)
.await?;
assert_eq!(code, 0, "{script}");
assert_eq!(out, expected.as_bytes(), "{script}");
}
let mut history = Vec::new();
assert_eq!(
exec.exec(
@@ -101,7 +172,7 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
assert!(status.is_empty(), "{}", String::from_utf8_lossy(&status));
let source = format!(
"globalThis.testP1 = true;\n{}",
include_str!("../js/test-bundle.js")
include_str!("../../js/test-bundle.js")
);
run_module(world, exec.clone(), &source).await?;
assert_eq!(
@@ -171,3 +242,47 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
assert!(world.root.stat("/bin/sh", true).await.is_err());
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn bundled_extensions_load() -> anyhow::Result<()> {
let bundle = bundle()?;
let dir = tempfile::tempdir()?;
let world = World::open_with_userland(dir.path(), Some(&bundle))?;
let root = world.root.clone();
let exec = sausage::process::exec(World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
})?;
// Use the compiled production loader without the console entrypoint or model proxy.
let (library, _) = include_str!("../../js/bundle.js")
.split_once("// main.ts")
.expect("console bundle entrypoint");
let source = format!(
r#"{library}
const bundleModels = createModels();
const bundleRegistry = createRegistry();
bundleRegistry.install(CodingTools2);
bundleRegistry.install(CredentialRequests);
const bundleStorage = await openStorage(globalThis.storagePath);
const bundleHarness = await Harness.open(bundleStorage, {{
models: bundleModels, registry: bundleRegistry,
settings: {{ get extensions() {{ return bundleRegistry.snapshot().installed(); }} }}
}}, BACKGROUND_CONTEXT);
await bundleHarness.root(BACKGROUND_CONTEXT);
const bundleLoader = await loadExtensions(bundleHarness, bundleRegistry, bundleStorage,
bundleModels, BACKGROUND_CONTEXT, [CodingTools2, CredentialRequests]);
if (bundleLoader.state().error) throw new Error(bundleLoader.state().error);
await bundleLoader.close();
await bundleHarness.close(BACKGROUND_CONTEXT);
"#
);
run_module(world, 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}");
assert!(status["error"].is_null(), "{status}");
Ok(())
}
+4 -9
View File
@@ -166,9 +166,9 @@ impl Browser {
}
fn components() -> Result<PathBuf> {
let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("p2-userland");
let target = crate::common::target_dir();
if !userland.join("cwd-init/init.o").is_file() {
command(&userland, &["nu", "--no-config-file", "cwd-init/build.nu"])?;
crate::common::cwd_init();
}
let status = Command::new("cargo")
.current_dir(&userland)
@@ -192,12 +192,7 @@ fn components() -> Result<PathBuf> {
let release = target.join("wasm32-wasip2/release");
if !release.join("sh.wasm").is_file() {
ensure!(
Command::new("nu")
.arg("brush/build.nu")
.env("CARGO_TARGET_DIR", &target)
.current_dir(&userland)
.status()?
.success(),
crate::common::build_brush()?.success(),
"shell build failed"
);
std::fs::copy(release.join("brush.wasm"), release.join("sh.wasm"))?;
@@ -413,7 +408,7 @@ async fn request_approve_fetch_and_ssh_push() -> Result<()> {
browser.enter(&ssh_item, &key).await?;
let source = format!(
"globalThis.p2Http = {http_port};\n{}",
include_str!("../js/p2-test-bundle.js")
include_str!("../../js/p2-test-bundle.js")
);
let approve = async {
for item in [&http_item, &ssh_item] {
+5 -45
View File
@@ -1,11 +1,12 @@
//! P3 exit scenario across worker, check, and recorded fast-forward SIGKILL boundaries.
use crate::common::copy_tree;
use sausage::{harness::run_module, world::World};
use std::{
path::Path,
process::Command,
time::{Duration, Instant},
};
const BUNDLE: &str = include_str!("../js/p3-test-bundle.js");
const BUNDLE: &str = include_str!("../../js/p3-test-bundle.js");
fn git(repo: &Path, args: &[&str]) -> anyhow::Result<String> {
let output = Command::new("git")
.args(args)
@@ -22,48 +23,12 @@ fn git(repo: &Path, args: &[&str]) -> anyhow::Result<String> {
);
Ok(String::from_utf8(output.stdout)?.trim().into())
}
fn copy_tree(source: &Path, destination: &Path) -> anyhow::Result<()> {
std::fs::create_dir_all(destination)?;
for entry in std::fs::read_dir(source)? {
let entry = entry?;
let target = destination.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_tree(&entry.path(), &target)?;
} else {
std::fs::copy(entry.path(), target)?;
}
}
Ok(())
}
fn prepare(dir: &Path, all_extensions: bool) -> anyhow::Result<()> {
World::open(dir)?;
let disk = dir.join("world");
let repo = disk.join("src/sausage");
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
let userland = source.join("userland");
if !userland.join("cwd-init/init.o").exists() {
anyhow::ensure!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success(),
"cwd-init build failed"
);
}
anyhow::ensure!(
Command::new("cargo")
.args(["build", "--release", "-p", "git", "--manifest-path"])
.arg(userland.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
.current_dir(&userland)
.status()?
.success(),
"world git build failed"
);
let target = crate::common::build_git();
std::fs::create_dir_all(disk.join("bin"))?;
std::fs::copy(
target.join("wasm32-wasip2/release/git.wasm"),
@@ -71,12 +36,7 @@ fn prepare(dir: &Path, all_extensions: bool) -> anyhow::Result<()> {
)?;
if !target.join("wasm32-wasip2/release/brush.wasm").exists() {
anyhow::ensure!(
Command::new("nu")
.arg("brush/build.nu")
.env("CARGO_TARGET_DIR", target)
.current_dir(&userland)
.status()?
.success(),
crate::common::build_brush()?.success(),
"brush build failed"
);
}
@@ -185,7 +145,7 @@ fn recovery(point: &str) -> anyhow::Result<()> {
}
let spawn = |mode: &str| {
Command::new(std::env::current_exe().unwrap())
.args(["--exact", "restart_child", "--nocapture"])
.args(["--exact", "p3::restart_child", "--nocapture"])
.env("P3_DIR", dir.path())
.env("P3_MODE", mode)
.spawn()
+6 -3
View File
@@ -26,7 +26,7 @@ fn prepare(dir: &Path) -> anyhow::Result<PathBuf> {
ensure!(revision.status.success(), "fixture revision");
if fs::read(bundle.join("revision")).ok().as_deref() != Some(revision.stdout.as_slice()) {
if !userland.join("cwd-init/init.o").exists() {
checked(Command::new("nu").arg(userland.join("cwd-init/build.nu")))?;
crate::common::cwd_init();
}
checked(Command::new("cargo").current_dir(&userland).args([
"build",
@@ -43,7 +43,10 @@ fn prepare(dir: &Path) -> anyhow::Result<PathBuf> {
"-p",
"sausage-coreutils",
]))?;
checked(Command::new("nu").arg(userland.join("brush/build.nu")))?;
ensure!(
crate::common::build_brush()?.success(),
"shell build failed"
);
fs::create_dir_all(bundle.join("bin"))?;
fs::create_dir_all(bundle.join("src"))?;
for (binary, name) in [
@@ -68,7 +71,7 @@ fn prepare(dir: &Path) -> anyhow::Result<PathBuf> {
fs::create_dir_all(repo.join("extensions/p4"))?;
fs::write(
repo.join("extensions/p4/server.ts"),
include_str!("../js/p4-test-bundle.js"),
include_str!("../../js/p4-test-bundle.js"),
)?;
for args in [
vec!["init", "-b", "main"],
@@ -1,32 +1,11 @@
//! The plan and todos ports run through real world extension snapshots and survive restart.
use sausage::world::World;
use std::path::Path;
const BUNDLE: &str = include_str!("../js/plan-todos-test-bundle.js");
const BUNDLE: &str = include_str!("../../js/plan-todos-test-bundle.js");
#[tokio::test(flavor = "current_thread")]
async fn plan_todos_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() {
assert!(
std::process::Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success()
);
}
assert!(
std::process::Command::new("cargo")
.args(["build", "--release", "-p", "git", "--manifest-path"])
.arg(userland.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
.current_dir(&userland)
.status()?
.success()
);
let target = crate::common::build_git();
let dir = tempfile::tempdir()?;
let disk = dir.path().join("world");
let _world = World::open(dir.path())?;
@@ -21,6 +21,12 @@ fn world_copies_programs_and_nested_source_only_once() -> anyhow::Result<()> {
b"ref: refs/heads/main\n",
)?;
std::fs::write(bundle.join("src/sausage/README.md"), b"source")?;
for (name, target) in [
("readme-link", "README.md"),
("broken-link", "missing.wasm"),
] {
std::os::unix::fs::symlink(target, bundle.join("src/sausage").join(name))?;
}
let path = temp.path().join("world");
let _world = World::open_with_userland(&path, Some(&bundle))?;
assert_eq!(
@@ -32,6 +38,15 @@ fn world_copies_programs_and_nested_source_only_once() -> anyhow::Result<()> {
b"source"
);
assert!(path.join("world/src/sausage/.git/HEAD").is_file());
for (name, target) in [
("readme-link", "README.md"),
("broken-link", "missing.wasm"),
] {
assert_eq!(
std::fs::read_link(path.join("world/src/sausage").join(name))?,
Path::new(target)
);
}
std::fs::write(path.join("world/src/sausage/README.md"), b"agent patch")?;
let _world = World::open_with_userland(&path, Some(Path::new("/missing-bundle")))?;
assert_eq!(
@@ -375,10 +390,7 @@ fn build() {
"guest build failed: {args:?}"
);
};
run(
&["nu", "--no-config-file", "userland/cwd-init/build.nu"],
&root,
);
crate::common::cwd_init();
run(
&[
"cargo",
@@ -394,10 +406,7 @@ fn build() {
],
&root.join("userland"),
);
run(
&["nu", "--no-config-file", "userland/brush/build.nu"],
&root,
);
assert!(crate::common::build_brush().unwrap().success());
run(
&[
"cargo",
@@ -560,7 +569,13 @@ async fn corpus_matches_bash() {
.args(["-c", &script])
.output()
.unwrap();
assert_eq!(cli.status.code(), result.status.code());
assert_eq!(
cli.status.code(),
result.status.code(),
"CLI stdout: {}\nCLI stderr: {}",
String::from_utf8_lossy(&cli.stdout),
String::from_utf8_lossy(&cli.stderr)
);
assert_eq!(
cli.stdout,
expected.as_bytes(),
+107
View File
@@ -0,0 +1,107 @@
//! The host proxy catalog selects distinct pi-ai transports with shared proxy authentication.
use sausage::{
exec::{Command, Exec, Options, Stream},
world::World,
};
use std::{sync::Arc, time::Duration};
struct NoExec;
#[async_trait::async_trait]
impl Exec for NoExec {
async fn exec(
&self,
_: Command,
_: Options,
_: &mut (dyn for<'a> FnMut(Stream, &'a [u8]) + Send),
) -> anyhow::Result<i32> {
anyhow::bail!("unexpected world command")
}
}
fn event(name: &str, value: serde_json::Value) -> String {
format!("event: {name}\ndata: {value}\n\n")
}
#[tokio::test(flavor = "current_thread")]
async fn provider_transport_and_prices() -> anyhow::Result<()> {
use serde_json::json;
// Production initializes this in the process runtime; this fixture uses NoExec.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let server = tiny_http::Server::http("127.0.0.1:0").map_err(|e| anyhow::anyhow!(e))?;
let base = format!("http://{}", server.server_addr());
let proxy = json!({"base_url":base,"api_key":"test-proxy-key"});
let thread = std::thread::spawn(move || -> anyhow::Result<()> {
for path in [
"/v1/models",
"/v1/responses",
"/apis/anthropic/v1/messages?beta=true",
"/offline/v1/models",
] {
let mut request = server
.recv_timeout(Duration::from_secs(20))?
.ok_or_else(|| anyhow::anyhow!("request timeout"))?;
assert_eq!(request.url(), path);
let authenticated = request.headers().iter().any(|h| {
(h.field.equiv("Authorization") && h.value.as_str() == "Bearer test-proxy-key")
|| (h.field.equiv("x-api-key") && h.value.as_str() == "test-proxy-key")
});
assert!(authenticated, "missing proxy key");
if path == "/offline/v1/models" {
request.respond(tiny_http::Response::empty(503))?;
continue;
}
let (body, mime) = if path == "/v1/models" {
let rows: Vec<_> = [("openai", "gpt-6.1-sol", "openai-responses", "/v1/responses"),
("anthropic", "claude-opus-5-5", "anthropic-messages", "/apis/anthropic/v1/messages")]
.into_iter().map(|(creator,id,api,path)|json!({"creator":creator,"id":id,"endpoints":[{"api":api,"path":path}],
"providers":[{"pricing":{"currency":"USD","unit_tokens":1000,"input":0.002,"output":0.008,"cached_input":0.0001,"cache_write":0.003}}]})).collect();
(json!({"data":rows}).to_string(), "application/json")
} else {
let mut body = String::new();
request.as_reader().read_to_string(&mut body)?;
let body: serde_json::Value = serde_json::from_str(&body)?;
assert_eq!(body["stream"], true);
assert!(
request
.headers()
.iter()
.any(|h| h.field.equiv("Shopify-Usage-Tag")
&& h.value.as_str() == "[\"pi\",\"sausage\"]")
);
let stream = if path == "/v1/responses" {
assert_eq!(body["model"], "gpt-6.1-sol");
[
event("response.output_item.added", json!({"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"m","role":"assistant","content":[]}})),
event("response.content_part.added", json!({"type":"response.content_part.added","output_index":0,"content_index":0,"part":{"type":"output_text","text":""}})),
event("response.output_text.delta", json!({"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"hello"})),
event("response.completed", json!({"type":"response.completed","response":{"id":"r","status":"completed","output":[],"usage":{"input_tokens":10,"output_tokens":1,"total_tokens":11}}})),
].concat()
} else {
assert_eq!(body["model"], "claude-opus-5-5");
assert_eq!(body["thinking"]["type"], "adaptive");
assert_eq!(body["output_config"]["effort"], "low");
[
event("message_start", json!({"type":"message_start","message":{"id":"m","type":"message","role":"assistant","model":"claude-opus-5-5","content":[],"usage":{"input_tokens":10,"output_tokens":0}}})),
event("content_block_start", json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}})),
event("content_block_delta", json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}})),
event("content_block_stop", json!({"type":"content_block_stop","index":0})),
event("message_delta", json!({"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}})),
event("message_stop", json!({"type":"message_stop"})),
].concat()
};
(stream, "text/event-stream")
};
request.respond(
tiny_http::Response::from_string(body)
.with_header(tiny_http::Header::from_bytes("Content-Type", mime).unwrap()),
)?;
}
Ok(())
});
let dir = tempfile::tempdir()?;
let source = format!(
"globalThis.testProxy = {proxy};\n{}",
include_str!("../../js/proxy-models-test-bundle.js")
);
let result =
sausage::harness::run_module(World::open(dir.path())?, Arc::new(NoExec), &source).await;
thread.join().unwrap()?;
result
}
@@ -5,7 +5,7 @@ use std::{
process::Command,
time::{Duration, Instant},
};
const BUNDLE: &str = include_str!("../js/subagents-test-bundle.js");
const BUNDLE: &str = include_str!("../../js/subagents-test-bundle.js");
fn prepare(dir: &Path) -> anyhow::Result<()> {
let disk = dir.join("world");
let repo = disk.join("src/sausage");
@@ -13,28 +13,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
return Ok(());
}
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
let userland = source.join("userland");
if !userland.join("cwd-init/init.o").exists() {
assert!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success()
);
}
assert!(
Command::new("cargo")
.args(["build", "--release", "-p", "git", "--manifest-path"])
.arg(userland.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
.current_dir(&userland)
.status()?
.success()
);
let target = crate::common::build_git();
std::fs::create_dir_all(disk.join("bin"))?;
std::fs::copy(
target.join("wasm32-wasip2/release/git.wasm"),
@@ -131,7 +110,7 @@ fn sigkill_between_deliver_and_report() -> anyhow::Result<()> {
prepare(dir.path())?;
let spawn = |mode: &str| {
Command::new(std::env::current_exe().unwrap())
.args(["--exact", "restart_child", "--nocapture"])
.args(["--exact", "subagents::restart_child", "--nocapture"])
.env("SUBAGENTS_DIR", dir.path())
.env("SUBAGENTS_MODE", mode)
.spawn()
@@ -6,19 +6,14 @@ use sausage::{
};
use sausage_vfs::{Host, Mounts, Process};
use std::{path::PathBuf, sync::Arc, time::Instant};
// This timing benchmark has a 10% threshold and is flaky on a loaded machine.
#[tokio::test(flavor = "multi_thread")]
#[ignore = "Run this timing benchmark by hand on an idle machine"]
async fn status_cost() {
let target = PathBuf::from(std::env::var("CARGO_TARGET_DIR").unwrap());
if !target.join("wasm32-wasip2/release/git.wasm").is_file() {
let userland = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../userland");
assert!(
std::process::Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()
.unwrap()
.success()
);
crate::common::cwd_init();
assert!(
std::process::Command::new("cargo")
.args(["build", "--release", "-p", "git"])
@@ -39,14 +39,7 @@ async fn tool_call_commit_and_independent_change() {
|| !guest_dir.join("touch.wasm").is_file()
|| !guest_dir.join("undo.wasm").is_file()
{
assert!(
std::process::Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../userland"))
.status()
.unwrap()
.success()
);
crate::common::cwd_init();
assert!(
std::process::Command::new("cargo")
.args([
@@ -42,14 +42,7 @@ async fn command(runtime: &Arc<Runtime>, args: &[&str]) -> String {
async fn tools_restore_one_call_or_the_whole_turn() {
let target = PathBuf::from(std::env::var("CARGO_TARGET_DIR").unwrap());
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
assert!(
std::process::Command::new("nu")
.args(["--no-config-file", "userland/cwd-init/build.nu"])
.current_dir(repo)
.status()
.unwrap()
.success()
);
crate::common::cwd_init();
assert!(
std::process::Command::new("cargo")
.args([
@@ -71,15 +64,7 @@ async fn tools_restore_one_call_or_the_whole_turn() {
.unwrap()
.success()
);
assert!(
std::process::Command::new("nu")
.args(["--no-config-file", "userland/brush/build.nu"])
.env("CARGO_TARGET_DIR", &target)
.current_dir(repo)
.status()
.unwrap()
.success()
);
assert!(crate::common::build_brush().unwrap().success());
for since in [false, true] {
let temp = tempfile::tempdir().unwrap();
let world = World::open(temp.path()).unwrap();
@@ -112,7 +97,7 @@ async fn tools_restore_one_call_or_the_whole_turn() {
sausage::harness::run_module(
world,
Arc::new(runtime.clone()),
include_str!("../js/undo-harness-test-bundle.js"),
include_str!("../../js/undo-harness-test-bundle.js"),
)
.await
.unwrap();
@@ -5,7 +5,7 @@ use std::{
process::Command,
time::{Duration, Instant},
};
const BUNDLE: &str = include_str!("../js/watchdog-test-bundle.js");
const BUNDLE: &str = include_str!("../../js/watchdog-test-bundle.js");
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn prepare(dir: &Path) -> anyhow::Result<()> {
let _guard = PREPARE.lock().unwrap();
@@ -15,28 +15,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
return Ok(());
}
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
let userland = source.join("userland");
if !userland.join("cwd-init/init.o").exists() {
assert!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success()
);
}
assert!(
Command::new("cargo")
.args(["build", "--release", "-p", "git", "--manifest-path"])
.arg(userland.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
.current_dir(&userland)
.status()?
.success()
);
let target = crate::common::build_git();
std::fs::create_dir_all(disk.join("bin"))?;
std::fs::copy(
target.join("wasm32-wasip2/release/git.wasm"),
@@ -150,7 +129,7 @@ fn sigkill_during_durable_wait() -> anyhow::Result<()> {
prepare(dir.path())?;
let spawn = |mode: &str| {
Command::new(std::env::current_exe().unwrap())
.args(["--exact", "restart_child", "--nocapture"])
.args(["--exact", "watchdog::restart_child", "--nocapture"])
.env("WATCHDOG_DIR", dir.path())
.env("WATCHDOG_MODE", mode)
.spawn()
@@ -7,7 +7,7 @@ use std::{
sync::Arc,
time::{Duration, Instant},
};
const BUNDLE: &str = include_str!("../js/workspaces-test-bundle.js");
const BUNDLE: &str = include_str!("../../js/workspaces-test-bundle.js");
fn git(path: &Path, args: &[&str]) -> anyhow::Result<()> {
assert!(
Command::new("git")
@@ -29,37 +29,9 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
return Ok(());
}
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
let userland = source.join("userland");
if !userland.join("cwd-init/init.o").exists() {
assert!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success()
);
}
assert!(
Command::new("cargo")
.args(["build", "--release", "-p", "git", "--manifest-path"])
.arg(userland.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
.current_dir(&userland)
.status()?
.success()
);
let target = crate::common::build_git();
if !target.join("wasm32-wasip2/release/brush.wasm").exists() {
assert!(
Command::new("nu")
.arg("brush/build.nu")
.env("CARGO_TARGET_DIR", target)
.current_dir(&userland)
.status()?
.success()
);
assert!(crate::common::build_brush()?.success());
}
std::fs::create_dir_all(disk.join("bin"))?;
for (binary, name) in [("git", "git"), ("brush", "sh")] {
@@ -116,8 +88,6 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
std::fs::write(project.join("base.txt"), "base")?;
for path in [&project, &repo] {
git(path, &["init", "-b", "main"])?;
git(path, &["config", "user.name", "Test"])?;
git(path, &["config", "user.email", "test@example.com"])?;
git(path, &["add", "."])?;
git(path, &["commit", "--no-gpg-sign", "-m", "fixture"])?;
}
@@ -254,7 +224,7 @@ fn sigkill_between_fast_forward_and_push() -> anyhow::Result<()> {
let _server = serve(dir.path())?;
let spawn = |mode: &str| {
Command::new(std::env::current_exe().unwrap())
.args(["--exact", "restart_child", "--nocapture"])
.args(["--exact", "workspaces::restart_child", "--nocapture"])
.env("WORKSPACES_DIR", dir.path())
.env("WORKSPACES_MODE", mode)
.spawn()
+1 -1
View File
@@ -39,6 +39,6 @@ def main [] {
$env.SAUSAGE_CWD_INIT = ($here | path join .. cwd-init init.o | path expand)
$env.CARGO_TARGET_WASM32_WASIP2_LINKER = ($here | path join .. cwd-init link.nu | path expand)
$env.CARGO_TARGET_WASM32_WASIP2_RUSTFLAGS = '-C link-arg=sausage-cwd-init'
^cargo build --manifest-path ($source | path join Cargo.toml) -p brush-shell --release --features minimal --no-default-features --target wasm32-wasip2 --locked
^cargo build --manifest-path ($source | path join Cargo.toml) -p brush-shell --release --features minimal,tokio/time --no-default-features --target wasm32-wasip2 --locked
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush build failed'} }
}

Some files were not shown because too many files have changed in this diff Show More