- userland/bundle.nu builds the userland and assembles target/userland/bin (bin names, tools.txt aliases, brush as sh and bash). - run and sh take --userland (default SAUSAGE_USERLAND); /bin is read-only. - The console's bash tool runs through the process table with the conversation id; large output spills to the world's /tmp. - sausage run starts the services supervisor. P1 exit session (openai/gpt-6.1-sol, fresh world): write, read, edit, git init/add/commit/diff/log, grep -rn, rg, find and a sed|sort|uniq|wc pipeline.
110 lines
4.9 KiB
TypeScript
110 lines
4.9 KiB
TypeScript
// 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 = '/', conversation?: string) {
|
|
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 && (await call('stat', current)).value?.kind !== 'directory') 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) => {
|
|
let spillPath: string | undefined;
|
|
let pending = '', bytes = 0, lines = 0;
|
|
const output = async (text: string, stream: string) => {
|
|
options?.onOutput?.(text, context, { stream });
|
|
if (!options?.spill) return;
|
|
bytes += new TextEncoder().encode(text).length;
|
|
lines += text.split('\n').length - 1;
|
|
if (!spillPath) {
|
|
pending += text;
|
|
if (bytes <= options.spill.afterBytes && lines + (pending.endsWith('\n') ? 0 : 1) <= options.spill.afterLines) return;
|
|
spillPath = `/tmp/bash-${crypto.randomUUID()}.log`;
|
|
const created = await env.writeFile(spillPath, pending);
|
|
if (!created.ok) throw new Error(created.error.message);
|
|
pending = '';
|
|
} else {
|
|
const appended = await call('append', spillPath, Array.from(new TextEncoder().encode(text)));
|
|
if (!appended.ok) throw new Error(appended.error.message);
|
|
}
|
|
};
|
|
try {
|
|
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, conversation);
|
|
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; }
|
|
await output(decoders[chunk.stream].decode(new Uint8Array(chunk.bytes), { stream: true }), chunk.stream);
|
|
}
|
|
if (result.error) throw new Error(result.error);
|
|
for (const stream of ['stdout', 'stderr']) {
|
|
const text = decoders[stream].decode();
|
|
if (text) await output(text, stream);
|
|
}
|
|
return ok({ exitCode: result.exitCode, ...(spillPath ? { spillPath } : {}) });
|
|
} catch (error) { return { ok: false, error: Object.assign(new Error(String(error)), { code: 'unknown', ...(spillPath ? { spillPath } : {}) }) }; }
|
|
},
|
|
cleanup: async () => {},
|
|
};
|
|
return env;
|
|
}
|