host: process table, shell, coreutils and harness (T8, T9)
T8: sausage:process WIT (spawn, wait, kill), one store per process over the shared root, /proc/processes, /run, pipeline and cancellation groups, Exec. brush and 78 uutils commands; the shared cwd-init object sets the cwd from PWD and keeps numeric exit codes. The corpus matches native bash. T9: V8 (deno_core) runs pi-durable, pi-ai and chord; SQLite storage in <dir>/host/harness.sqlite (WAL, synchronous=FULL); file tools on the VFS, bash through Exec; resumes after SIGKILL.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,32 @@
|
||||
# Host JavaScript
|
||||
|
||||
The console uses pi-durable in V8. Rust owns the SQLite connection.
|
||||
The console runs V8 on a dedicated current-thread Tokio runtime.
|
||||
This satisfies Deno's runtime requirement without a change to the CLI runtime.
|
||||
The database lives at `<dir>/host/harness.sqlite`, outside the world.
|
||||
WAL preserves atomic transactions after SIGKILL. `synchronous=FULL` flushes each commit before SQLite reports success.
|
||||
|
||||
The coding tools access the shared VFS through Rust operations.
|
||||
The shell sends commands to the host's `Exec` implementation.
|
||||
The adapter supplies the filesystem operations that the four coding tools use.
|
||||
It does not implement the other portable filesystem capabilities or shell spill files.
|
||||
The console currently uses an explicit unavailable stub. Host integration can call `run_with_exec`.
|
||||
|
||||
## Reproduce the bundles
|
||||
|
||||
```sh
|
||||
npm ci --prefix host/js
|
||||
npm run --prefix host/js bundle
|
||||
CARGO_TARGET_DIR=target/harness nix develop -c cargo test -p sausage --test harness
|
||||
```
|
||||
|
||||
The lock file pins npm dependencies. The checked-in bundles let Cargo build without npm or network access for JavaScript.
|
||||
Cargo still needs its normal Rust dependencies and the V8 archive.
|
||||
|
||||
For Nix, use a separate npm derivation with `importNpmLock` and the checked-in lock file.
|
||||
Run the bundle script there. Pass its output to the Rust source derivation before Crane builds the host.
|
||||
The Rust build must not execute npm or download JavaScript packages.
|
||||
|
||||
The console reads `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY` from the host environment.
|
||||
It selects `openai/gpt-6.1-sol` with low reasoning effort.
|
||||
This model rejects disabled reasoning. Tests use the faux provider without credentials.
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
// Install Deno's native Web APIs. No Node runtime is present.
|
||||
const core = Deno.core;
|
||||
const expose = (path, names) => {
|
||||
const exports = core.loadExtScript(path);
|
||||
for (const name of names) globalThis[name] = exports[name];
|
||||
};
|
||||
expose('ext:deno_web/01_dom_exception.js', ['DOMException']);
|
||||
expose('ext:deno_web/02_event.js', ['Event', 'EventTarget', 'MessageEvent']);
|
||||
expose('ext:deno_web/13_message_port.js', ['MessageChannel', 'MessagePort']);
|
||||
expose('ext:deno_web/00_url.js', ['URL', 'URLSearchParams']);
|
||||
expose('ext:deno_web/02_timers.js', ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval']);
|
||||
expose('ext:deno_web/03_abort_signal.js', ['AbortController', 'AbortSignal']);
|
||||
expose('ext:deno_web/06_streams.js', ['ReadableStream', 'WritableStream', 'TransformStream']);
|
||||
expose('ext:deno_web/08_text_encoding.js', ['TextEncoder', 'TextDecoder']);
|
||||
expose('ext:deno_web/09_file.js', ['Blob', 'File']);
|
||||
expose('ext:deno_web/15_performance.js', ['performance']);
|
||||
expose('ext:deno_web/02_structured_clone.js', ['structuredClone']);
|
||||
expose('ext:deno_web/05_base64.js', ['atob', 'btoa']);
|
||||
expose('ext:deno_crypto/00_crypto.js', ['crypto']);
|
||||
expose('ext:deno_fetch/20_headers.js', ['Headers']);
|
||||
expose('ext:deno_fetch/21_formdata.js', ['FormData']);
|
||||
expose('ext:deno_fetch/23_request.js', ['Request']);
|
||||
expose('ext:deno_fetch/23_response.js', ['Response']);
|
||||
expose('ext:deno_fetch/26_fetch.js', ['fetch']);
|
||||
const { Console } = core.loadExtScript('ext:deno_web/01_console.js');
|
||||
globalThis.console = new Console((message, level) => core.print(message, level > 1));
|
||||
globalThis.process = { env: {} };
|
||||
+44488
File diff suppressed because one or more lines are too long
@@ -0,0 +1,87 @@
|
||||
// The coding tools see only world paths. Rust owns the VFS and process execution.
|
||||
import { LineScanner } from '@earendil-works/pi-durable/env';
|
||||
const ops = Deno.core.ops;
|
||||
const ok = (value: any) => ({ ok: true, value });
|
||||
const path = (cwd: string, input: string) => {
|
||||
const parts: string[] = [];
|
||||
for (const p of (input.startsWith('/') ? input : `${cwd}/${input}`).split('/')) {
|
||||
if (p === '..') parts.pop();
|
||||
else if (p && p !== '.') parts.push(p);
|
||||
}
|
||||
return '/' + parts.join('/');
|
||||
};
|
||||
export function environment(cwd = '/') {
|
||||
const call = (kind: string, p: string, bytes: number[] = []) => ops.op_file(kind, path(cwd, p), bytes);
|
||||
const env: any = {
|
||||
id: 'world', cwd,
|
||||
absolutePath: async (p: string) => ok(path(cwd, p)),
|
||||
canonicalPath: async () => ({ ok: false, error: { code: 'not_supported', message: 'The VFS does not expose canonical paths' } }),
|
||||
joinPath: async (parts: string[]) => ok(path(cwd, parts.join('/'))),
|
||||
fileInfo: (p: string) => call('stat', p),
|
||||
exists: async (p: string) => {
|
||||
const result = await call('stat', p);
|
||||
return result.ok ? ok(true) : result.error.code === 'not_found' ? ok(false) : result;
|
||||
},
|
||||
readBinaryFile: async (p: string) => {
|
||||
const r = await call('read', p);
|
||||
return r.ok ? ok(new Uint8Array(r.value)) : r;
|
||||
},
|
||||
readTextFile: async (p: string) => {
|
||||
const r = await env.readBinaryFile(p);
|
||||
return r.ok ? ok(new TextDecoder().decode(r.value)) : r;
|
||||
},
|
||||
writeFile: async (p: string, content: string | Uint8Array) => {
|
||||
const full = path(cwd, p);
|
||||
const parent = full.slice(0, full.lastIndexOf('/')) || '/';
|
||||
const created = await env.createDir(parent, { recursive: true });
|
||||
if (!created.ok) return created;
|
||||
return call('write', full, Array.from(typeof content === 'string' ? new TextEncoder().encode(content) : content));
|
||||
},
|
||||
createDir: async (p: string, options: any) => {
|
||||
const full = path(cwd, p);
|
||||
if (!options?.recursive) return call('mkdir', full);
|
||||
let current = '';
|
||||
for (const part of full.split('/').filter(Boolean)) {
|
||||
current += '/' + part;
|
||||
const exists = await call('stat', current);
|
||||
if (!exists.ok) { const r = await call('mkdir', current); if (!r.ok) return r; }
|
||||
}
|
||||
return ok(undefined);
|
||||
},
|
||||
openBinaryReader: async (p: string) => {
|
||||
const data = await env.readBinaryFile(p);
|
||||
if (!data.ok) return data;
|
||||
const info = await env.fileInfo(p);
|
||||
return ok({
|
||||
info: async () => info,
|
||||
read: async (offset: number, length: number) => ok(data.value.slice(offset, offset + length)),
|
||||
scanLines: async (options: any) => {
|
||||
const scanner = new LineScanner(options.startLine, options.endLine);
|
||||
scanner.push(data.value);
|
||||
return ok(scanner.finish());
|
||||
},
|
||||
close: async () => {},
|
||||
});
|
||||
},
|
||||
exec: async (command: any, options: any, context: any) => {
|
||||
try {
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout);
|
||||
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; }
|
||||
options?.onOutput?.(decoders[chunk.stream].decode(new Uint8Array(chunk.bytes), { stream: true }), context, { stream: chunk.stream });
|
||||
}
|
||||
if (result.error) throw new Error(result.error);
|
||||
for (const stream of ['stdout', 'stderr']) {
|
||||
const text = decoders[stream].decode();
|
||||
if (text) options?.onOutput?.(text, context, { stream });
|
||||
}
|
||||
return ok({ exitCode: result.exitCode });
|
||||
} catch (error) { return { ok: false, error }; }
|
||||
},
|
||||
cleanup: async () => {},
|
||||
};
|
||||
return env;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// 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 { Harness, createRegistry } from '@earendil-works/pi-durable';
|
||||
import { CodingTools } from '@earendil-works/pi-durable/tools';
|
||||
import { openStorage } from './sqlite';
|
||||
import { environment } from './environment';
|
||||
if (!process.env.PI_PROXY_BASE_URL || !process.env.PI_PROXY_API_KEY) {
|
||||
throw new Error('PI_PROXY_BASE_URL and PI_PROXY_API_KEY are 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: `${process.env.PI_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: process.env.PI_PROXY_API_KEY, baseUrl: model.baseUrl,
|
||||
headers: { 'Shopify-Usage-Tag': JSON.stringify(['pi', 'sausage']) } },
|
||||
source: 'PI_PROXY_API_KEY',
|
||||
}) } },
|
||||
});
|
||||
const registry = createRegistry();
|
||||
registry.install(CodingTools);
|
||||
const harness = await Harness.open(await openStorage(globalThis.storagePath), {
|
||||
models, registry, env: (target: any) => environment(target.cwd ?? '/'),
|
||||
}, context);
|
||||
const root = await harness.root(context, { agent: { model: { provider: 'openai', modelId: model.id }, thinkingLevel: 'low', cwd: '/', extensions: [CodingTools] } });
|
||||
const watch = await root.watch(context);
|
||||
const emitted = new Map<number, string>();
|
||||
let partial = '';
|
||||
const text = (message: any) => message?.content?.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('') ?? '';
|
||||
const render = (view: any) => {
|
||||
for (const entry of view.entries) {
|
||||
if (entry.kind !== 'pi.assistant') continue;
|
||||
const value = text(entry.model?.[0]);
|
||||
if (emitted.has(entry.id)) continue;
|
||||
if (value.startsWith(partial)) Deno.core.ops.op_output(value.slice(partial.length));
|
||||
else Deno.core.ops.op_output(value);
|
||||
partial = '';
|
||||
emitted.set(entry.id, value);
|
||||
}
|
||||
const live = text(view.docs['pi.live']?.generation?.message);
|
||||
if (live.startsWith(partial)) Deno.core.ops.op_output(live.slice(partial.length));
|
||||
partial = live;
|
||||
};
|
||||
// Do not replay the historical transcript to the console.
|
||||
for (const entry of watch.value.entries) {
|
||||
if (entry.kind === 'pi.assistant') emitted.set(entry.id, text(entry.model?.[0]));
|
||||
}
|
||||
watch.start((event: any) => { if (event.kind === 'value') render(event.value); });
|
||||
harness.resume();
|
||||
await harness.waitForIdle(context);
|
||||
while (true) {
|
||||
const line = await Deno.core.ops.op_input();
|
||||
if (line === null) break;
|
||||
if (!line.trim()) continue;
|
||||
const submission = await root.submit({ type: 'input', content: line, requestId: crypto.randomUUID() }, context);
|
||||
const result = await submission.wait(context);
|
||||
await harness.waitForIdle(context);
|
||||
const final = await root.watch(context);
|
||||
render(final.value);
|
||||
await final.stop();
|
||||
Deno.core.ops.op_output('\n');
|
||||
if (result.status !== 'done') throw new Error(`Run ended: ${JSON.stringify(result)}`);
|
||||
}
|
||||
await watch.stop();
|
||||
await harness.close(context);
|
||||
Generated
+1687
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": { "bundle": "esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js" },
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-durable": "1.5.0",
|
||||
"@earendil-works/pi-ai": "1.5.0",
|
||||
"@earendil-works/chord": "1.5.0"
|
||||
},
|
||||
"devDependencies": { "esbuild": "0.28.2" }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Portable SQLite facade. Rust owns the connection and executes every statement.
|
||||
import { SqliteStorage } from '@earendil-works/pi-durable/storage/sqlite';
|
||||
const op = (kind: string, sql = '', params: any[] = []) => Deno.core.ops.op_sql(kind, sql, params.map(p => p instanceof Uint8Array ? Array.from(p) : typeof p === 'bigint' ? Number(p) : p));
|
||||
export async function openStorage(path: string) {
|
||||
op('open', path);
|
||||
let tail = Promise.resolve();
|
||||
const queue = <T>(call: () => Promise<T>): Promise<T> => {
|
||||
const result = tail.then(call);
|
||||
tail = result.then(() => {}, () => {});
|
||||
return result;
|
||||
};
|
||||
const executor = {
|
||||
exec: async (sql: string) => { op('exec', sql); },
|
||||
run: async (sql: string, ...params: any[]) => { op('run', sql, params); },
|
||||
runCounted: async (sql: string, ...params: any[]) => op('run', sql, params).changes,
|
||||
get: async (sql: string, ...params: any[]) => (await executor.all(sql, ...params))[0],
|
||||
all: async (sql: string, ...params: any[]) => op('all', sql, params).map((row: any) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, Array.isArray(value) ? new Uint8Array(value) : value]))),
|
||||
};
|
||||
const database = Object.fromEntries(Object.entries(executor).map(([name, method]) => [name, (...args: any[]) => queue(() => method(...args))]));
|
||||
database.transaction = (callback: any) => queue(async () => {
|
||||
op('exec', 'BEGIN IMMEDIATE');
|
||||
try {
|
||||
const result = await callback(executor);
|
||||
op('exec', 'COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
op('exec', 'ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
database.close = () => queue(async () => { op('close'); });
|
||||
return SqliteStorage.open(database as any);
|
||||
}
|
||||
+22288
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
// Faux-provider integration test. The Rust test checks VFS effects and Exec arguments.
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
|
||||
import { createModels } from '@earendil-works/pi-ai/models';
|
||||
import { fauxProvider, fauxAssistantMessage, fauxToolCall, fauxText } from '@earendil-works/pi-ai/providers/faux';
|
||||
import { Harness, createRegistry } from '@earendil-works/pi-durable';
|
||||
import { CodingTools } from '@earendil-works/pi-durable/tools';
|
||||
import { environment } from './environment';
|
||||
import { openStorage } from './sqlite';
|
||||
const models = createModels();
|
||||
const crash = globalThis.testCrash;
|
||||
const faux = fauxProvider({ tokensPerSecond: crash === 'model' ? 1 : 100000 });
|
||||
models.setProvider(faux.provider);
|
||||
faux.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall('write', { path: '/test.txt', content: 'before\n' }, { id: 'write' })], { stopReason: 'toolUse' }),
|
||||
fauxAssistantMessage([fauxToolCall('read', { path: '/test.txt' }, { id: 'read' })], { stopReason: 'toolUse' }),
|
||||
fauxAssistantMessage([fauxToolCall('edit', { path: '/test.txt', edits: [{ oldText: 'before', newText: 'after' }] }, { id: 'edit' })], { stopReason: 'toolUse' }),
|
||||
fauxAssistantMessage([fauxToolCall('bash', { command: 'echo test' }, { id: 'bash' })], { stopReason: 'toolUse' }),
|
||||
fauxAssistantMessage([fauxText('done')]),
|
||||
]);
|
||||
if (crash) {
|
||||
faux.setResponses(crash === 'tool' ? [
|
||||
fauxAssistantMessage([fauxToolCall('bash', { command: 'echo test' }, { id: 'crash' })], { stopReason: 'toolUse' }),
|
||||
] : crash === 'model' ? [
|
||||
fauxAssistantMessage([fauxText('A long response that the test interrupts before it completes.')]),
|
||||
] : [fauxAssistantMessage([fauxText('resumed done')])]);
|
||||
}
|
||||
const registry = createRegistry();
|
||||
registry.install(CodingTools);
|
||||
const harness = await Harness.open(await openStorage(globalThis.storagePath), { models, registry, env: () => environment('/') }, context);
|
||||
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' }, extensions: [CodingTools], cwd: '/' } });
|
||||
if (crash === 'model') {
|
||||
const progress = await root.watch(context);
|
||||
let ready = false;
|
||||
progress.start((event: any) => {
|
||||
if (event.kind !== 'value' || ready) return;
|
||||
const message = event.value.docs['pi.live']?.generation?.message;
|
||||
if (message?.content?.some((block: any) => block.type === 'text' && block.text.length > 0)) {
|
||||
ready = true;
|
||||
console.log('CRASH_READY');
|
||||
}
|
||||
});
|
||||
}
|
||||
harness.resume();
|
||||
const result = await (await root.submit({ type: 'input', content: 'Exercise tools', requestId: 'test' }, context)).wait(context);
|
||||
if (result.status !== 'done') throw new Error(JSON.stringify(result));
|
||||
await harness.waitForIdle(context);
|
||||
const watch = await root.watch(context);
|
||||
const transcript = JSON.stringify(watch.value.entries);
|
||||
if (!crash && transcript.includes('"isError":true')) throw new Error(transcript);
|
||||
for (const expected of crash ? ['resumed done'] : ['before', 'after', 'streamed stdout', 'streamed stderr', 'done']) {
|
||||
if (!transcript.includes(expected)) throw new Error(`Missing ${expected}: ${transcript}`);
|
||||
}
|
||||
await watch.stop();
|
||||
await harness.close(context);
|
||||
Reference in New Issue
Block a user