Merge branch 'v3' into v3-t63
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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]`.
|
||||
|
||||
@@ -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) : []
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {},
|
||||
};
|
||||
|
||||
+2201
-308
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,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
@@ -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');
|
||||
}
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)?;
|
||||
@@ -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),
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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")
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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()?;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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,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},
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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] {
|
||||
@@ -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()
|
||||
@@ -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(),
|
||||
@@ -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()
|
||||
@@ -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
Reference in New Issue
Block a user