Files
sausage/host/js/environment.ts
T
surma a9a7f0961b Merge v3-t49: messages and lineage core as world extensions (T49, P3-i)
# Conflicts:
#	host/js/bundle.js
#	host/js/package.json
2026-11-07 14:15:08 +00:00

143 lines
6.7 KiB
TypeScript

// The coding tools see only world paths. Rust owns the VFS and process execution.
import { LineScanner } from '@earendil-works/pi-durable/env';
import { defineDoc } from '@earendil-works/pi-durable';
import { CodingTools as tools } from '@earendil-works/pi-durable/tools';
// Bind each invocation separately. Concurrent tools must never share a mutable tag.
export const CodingTools = { ...tools, tools: tools.tools.map((tool: any) => ({
...tool, execute: (args: any, api: any, context: any) =>
tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context),
})) };
// D63: read the optional branch starter without creating a document.
const Started = defineDoc<{ by?: number }>({ kind: 'board.started', version: 1,
checkpointWhen: () => false, scope: 'conversation', history: 'latest', fork: 'initial', initial: () => ({}) });
export function conversationChains(harness: any, context: any) {
const cache = new Map<number, Promise<string[]>>();
const chain = (id: number): Promise<string[]> => {
let result = cache.get(id);
if (!result) {
result = (async () => {
const record = await harness.commit((tx: any) => tx.conversation(id), context);
const up = record?.owner?.conversationId ?? record?.parent?.conversationId;
const starter = (await harness.snapshot(Started, id, context))?.by;
return [...new Set([String(id), ...(up === undefined ? [] : await chain(up)),
...(starter === undefined ? [] : await chain(starter))])];
})();
cache.set(id, result);
}
return result;
};
return chain;
}
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 | number | string[], invocation?: {id:string;tool:string}) {
const conversations = Array.isArray(conversation) ? conversation : conversation === undefined ? [] : [String(conversation)];
const tag = invocation && {id:invocation.id, pid:0, program:invocation.tool, conversations};
const call = (kind: string, p: string, bytes: number[] = []) => ops.op_file(kind, path(cwd, p), bytes, tag);
const env: any = {
id: 'world', cwd,
forTool: (id:string, tool:string) => environment(cwd, conversations, {id,tool}),
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, undefined, undefined, conversations, tag);
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;
}