Merge v3-t48: harness tool call ids in the undo journal (T48, P5 exit test)
# Conflicts: # host/js/package.json
This commit is contained in:
@@ -8,6 +8,13 @@ WAL preserves atomic transactions after SIGKILL. `synchronous=FULL` flushes each
|
||||
|
||||
The coding tools access the shared VFS through Rust operations.
|
||||
The shell sends commands to the host's `Exec` implementation.
|
||||
Each coding invocation binds its call id to a separate environment.
|
||||
File mutations and shell descendants share that undo tag and the conversation chain.
|
||||
The journal lists the tool name in `program` and its paths in `changed_paths`, newest first.
|
||||
Extension `host.exec` uses the extension name and `options.task` as its tag.
|
||||
Without a task, it creates a unique invocation tag.
|
||||
An extension can supply `options.toolCall` to share the tag of its triggering tool call.
|
||||
It supplies `options.conversations` for the conversation filter, as with other extension commands.
|
||||
The adapter supplies the filesystem operations that the four coding tools use.
|
||||
It does not implement the other portable filesystem capabilities.
|
||||
The console uses the real process table and passes the conversation id to each command.
|
||||
|
||||
+315
-303
@@ -50574,6 +50574,161 @@ var MemoryStorage = class {
|
||||
}
|
||||
};
|
||||
|
||||
// node_modules/@earendil-works/pi-durable/dist/env/decode.js
|
||||
function rangeDecoder() {
|
||||
return new TextDecoder("utf-8", { ignoreBOM: true });
|
||||
}
|
||||
function startsWithBom(firstBytes) {
|
||||
return firstBytes[0] === 239 && firstBytes[1] === 187 && firstBytes[2] === 191;
|
||||
}
|
||||
|
||||
// node_modules/@earendil-works/pi-durable/dist/env/line-scan.js
|
||||
var NEWLINE2 = 10;
|
||||
var encoder4 = new TextEncoder();
|
||||
function decodedBytes(text2) {
|
||||
return text2 === "" ? 0 : encoder4.encode(text2).length;
|
||||
}
|
||||
var LineScanner = class {
|
||||
#startLine;
|
||||
#endLine;
|
||||
#position = 0;
|
||||
#newlines = 0;
|
||||
#lineStart = 0;
|
||||
#start;
|
||||
#end;
|
||||
#firstLineEnd;
|
||||
#lastLineStart;
|
||||
#selectedBytes = 0;
|
||||
#firstLineBytes = 0;
|
||||
#selection;
|
||||
#firstLine;
|
||||
/** The first bytes, held until it is known whether they are a byte-order mark. */
|
||||
#head = [];
|
||||
#bom = false;
|
||||
/** `startLine` and `endLine` must be non-negative integers with `endLine > startLine`; `endLine` absent: to the end. */
|
||||
constructor(startLine, endLine = Number.POSITIVE_INFINITY) {
|
||||
if (!Number.isSafeInteger(startLine) || startLine < 0 || !(endLine > startLine)) {
|
||||
throw new RangeError("Invalid line range");
|
||||
}
|
||||
if (endLine !== Number.POSITIVE_INFINITY && !Number.isSafeInteger(endLine))
|
||||
throw new RangeError("Invalid line range");
|
||||
this.#startLine = startLine;
|
||||
this.#endLine = endLine;
|
||||
if (startLine === 0)
|
||||
this.#begin(0);
|
||||
}
|
||||
push(chunk) {
|
||||
if (this.#head !== void 0) {
|
||||
const take = Math.min(3 - this.#head.length, chunk.length);
|
||||
this.#head.push(...chunk.subarray(0, take));
|
||||
if (this.#head.length < 3)
|
||||
return;
|
||||
this.#releaseHead();
|
||||
chunk = chunk.subarray(take);
|
||||
}
|
||||
this.#process(chunk);
|
||||
}
|
||||
#releaseHead() {
|
||||
const head = Uint8Array.from(this.#head ?? []);
|
||||
this.#head = void 0;
|
||||
this.#bom = startsWithBom(head);
|
||||
this.#process(head);
|
||||
}
|
||||
#process(chunk) {
|
||||
const base = this.#position;
|
||||
let from = 0;
|
||||
for (let index = chunk.indexOf(NEWLINE2); index !== -1; index = chunk.indexOf(NEWLINE2, index + 1)) {
|
||||
this.#feed(chunk, base, from, index);
|
||||
const line = this.#newlines;
|
||||
const position = base + index;
|
||||
if (line === this.#startLine)
|
||||
this.#endFirstLine(position);
|
||||
if (line === this.#endLine - 1)
|
||||
this.#endSelection(position);
|
||||
this.#feed(chunk, base, index, index + 1);
|
||||
from = index + 1;
|
||||
this.#newlines++;
|
||||
this.#lineStart = position + 1;
|
||||
if (this.#newlines === this.#startLine)
|
||||
this.#begin(this.#lineStart);
|
||||
if (this.#newlines === this.#endLine - 1)
|
||||
this.#lastLineStart = this.#lineStart;
|
||||
}
|
||||
this.#feed(chunk, base, from, chunk.length);
|
||||
this.#position += chunk.length;
|
||||
}
|
||||
finish() {
|
||||
if (this.#head !== void 0)
|
||||
this.#releaseHead();
|
||||
const size = this.#position;
|
||||
if (this.#start === void 0) {
|
||||
return {
|
||||
newlines: this.#newlines,
|
||||
start: size,
|
||||
end: size,
|
||||
firstLineEnd: size,
|
||||
lastLineStart: size,
|
||||
selectedBytes: 0,
|
||||
firstLineBytes: 0
|
||||
};
|
||||
}
|
||||
if (this.#firstLineEnd === void 0)
|
||||
this.#endFirstLine(size);
|
||||
if (this.#end === void 0)
|
||||
this.#endSelection(size);
|
||||
return {
|
||||
newlines: this.#newlines,
|
||||
start: this.#start,
|
||||
end: this.#end ?? size,
|
||||
firstLineEnd: this.#firstLineEnd ?? size,
|
||||
// A selection that reaches past the last line ends with the last line.
|
||||
lastLineStart: this.#lastLineStart ?? this.#lineStart,
|
||||
selectedBytes: this.#selectedBytes,
|
||||
firstLineBytes: this.#firstLineBytes
|
||||
};
|
||||
}
|
||||
#begin(start) {
|
||||
this.#start = start;
|
||||
if (this.#startLine === this.#endLine - 1)
|
||||
this.#lastLineStart = start;
|
||||
this.#selection = rangeDecoder();
|
||||
this.#firstLine = rangeDecoder();
|
||||
}
|
||||
#endFirstLine(position) {
|
||||
this.#firstLineEnd = position;
|
||||
if (this.#firstLine !== void 0)
|
||||
this.#firstLineBytes += decodedBytes(this.#firstLine.decode());
|
||||
this.#firstLine = void 0;
|
||||
}
|
||||
#endSelection(position) {
|
||||
this.#end = position;
|
||||
if (this.#selection !== void 0)
|
||||
this.#selectedBytes += decodedBytes(this.#selection.decode());
|
||||
this.#selection = void 0;
|
||||
}
|
||||
/** Feed `chunk[from, to)`, which starts at file offset `base`, to the decoders of the ranges still open. */
|
||||
#feed(chunk, base, from, to) {
|
||||
if (this.#bom && base + from < 3)
|
||||
from = Math.min(to, 3 - base);
|
||||
if (to <= from)
|
||||
return;
|
||||
const bytes2 = chunk.subarray(from, to);
|
||||
if (this.#selection !== void 0) {
|
||||
this.#selectedBytes += decodedBytes(this.#selection.decode(bytes2, { stream: true }));
|
||||
}
|
||||
if (this.#firstLine !== void 0) {
|
||||
this.#firstLineBytes += decodedBytes(this.#firstLine.decode(bytes2, { stream: true }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// node_modules/@earendil-works/pi-durable/dist/env/index.js
|
||||
function getOrThrow(result) {
|
||||
if (!result.ok)
|
||||
throw result.error;
|
||||
return result.value;
|
||||
}
|
||||
|
||||
// node_modules/@earendil-works/pi-durable/dist/tools/index.js
|
||||
var tools_exports = {};
|
||||
__export(tools_exports, {
|
||||
@@ -51455,161 +51610,6 @@ function generateDiffString(oldContent, newContent, contextLines = 4) {
|
||||
return { diff: output.join("\n"), firstChangedLine };
|
||||
}
|
||||
|
||||
// node_modules/@earendil-works/pi-durable/dist/env/decode.js
|
||||
function rangeDecoder() {
|
||||
return new TextDecoder("utf-8", { ignoreBOM: true });
|
||||
}
|
||||
function startsWithBom(firstBytes) {
|
||||
return firstBytes[0] === 239 && firstBytes[1] === 187 && firstBytes[2] === 191;
|
||||
}
|
||||
|
||||
// node_modules/@earendil-works/pi-durable/dist/env/line-scan.js
|
||||
var NEWLINE2 = 10;
|
||||
var encoder4 = new TextEncoder();
|
||||
function decodedBytes(text2) {
|
||||
return text2 === "" ? 0 : encoder4.encode(text2).length;
|
||||
}
|
||||
var LineScanner = class {
|
||||
#startLine;
|
||||
#endLine;
|
||||
#position = 0;
|
||||
#newlines = 0;
|
||||
#lineStart = 0;
|
||||
#start;
|
||||
#end;
|
||||
#firstLineEnd;
|
||||
#lastLineStart;
|
||||
#selectedBytes = 0;
|
||||
#firstLineBytes = 0;
|
||||
#selection;
|
||||
#firstLine;
|
||||
/** The first bytes, held until it is known whether they are a byte-order mark. */
|
||||
#head = [];
|
||||
#bom = false;
|
||||
/** `startLine` and `endLine` must be non-negative integers with `endLine > startLine`; `endLine` absent: to the end. */
|
||||
constructor(startLine, endLine = Number.POSITIVE_INFINITY) {
|
||||
if (!Number.isSafeInteger(startLine) || startLine < 0 || !(endLine > startLine)) {
|
||||
throw new RangeError("Invalid line range");
|
||||
}
|
||||
if (endLine !== Number.POSITIVE_INFINITY && !Number.isSafeInteger(endLine))
|
||||
throw new RangeError("Invalid line range");
|
||||
this.#startLine = startLine;
|
||||
this.#endLine = endLine;
|
||||
if (startLine === 0)
|
||||
this.#begin(0);
|
||||
}
|
||||
push(chunk) {
|
||||
if (this.#head !== void 0) {
|
||||
const take = Math.min(3 - this.#head.length, chunk.length);
|
||||
this.#head.push(...chunk.subarray(0, take));
|
||||
if (this.#head.length < 3)
|
||||
return;
|
||||
this.#releaseHead();
|
||||
chunk = chunk.subarray(take);
|
||||
}
|
||||
this.#process(chunk);
|
||||
}
|
||||
#releaseHead() {
|
||||
const head = Uint8Array.from(this.#head ?? []);
|
||||
this.#head = void 0;
|
||||
this.#bom = startsWithBom(head);
|
||||
this.#process(head);
|
||||
}
|
||||
#process(chunk) {
|
||||
const base = this.#position;
|
||||
let from = 0;
|
||||
for (let index = chunk.indexOf(NEWLINE2); index !== -1; index = chunk.indexOf(NEWLINE2, index + 1)) {
|
||||
this.#feed(chunk, base, from, index);
|
||||
const line = this.#newlines;
|
||||
const position = base + index;
|
||||
if (line === this.#startLine)
|
||||
this.#endFirstLine(position);
|
||||
if (line === this.#endLine - 1)
|
||||
this.#endSelection(position);
|
||||
this.#feed(chunk, base, index, index + 1);
|
||||
from = index + 1;
|
||||
this.#newlines++;
|
||||
this.#lineStart = position + 1;
|
||||
if (this.#newlines === this.#startLine)
|
||||
this.#begin(this.#lineStart);
|
||||
if (this.#newlines === this.#endLine - 1)
|
||||
this.#lastLineStart = this.#lineStart;
|
||||
}
|
||||
this.#feed(chunk, base, from, chunk.length);
|
||||
this.#position += chunk.length;
|
||||
}
|
||||
finish() {
|
||||
if (this.#head !== void 0)
|
||||
this.#releaseHead();
|
||||
const size = this.#position;
|
||||
if (this.#start === void 0) {
|
||||
return {
|
||||
newlines: this.#newlines,
|
||||
start: size,
|
||||
end: size,
|
||||
firstLineEnd: size,
|
||||
lastLineStart: size,
|
||||
selectedBytes: 0,
|
||||
firstLineBytes: 0
|
||||
};
|
||||
}
|
||||
if (this.#firstLineEnd === void 0)
|
||||
this.#endFirstLine(size);
|
||||
if (this.#end === void 0)
|
||||
this.#endSelection(size);
|
||||
return {
|
||||
newlines: this.#newlines,
|
||||
start: this.#start,
|
||||
end: this.#end ?? size,
|
||||
firstLineEnd: this.#firstLineEnd ?? size,
|
||||
// A selection that reaches past the last line ends with the last line.
|
||||
lastLineStart: this.#lastLineStart ?? this.#lineStart,
|
||||
selectedBytes: this.#selectedBytes,
|
||||
firstLineBytes: this.#firstLineBytes
|
||||
};
|
||||
}
|
||||
#begin(start) {
|
||||
this.#start = start;
|
||||
if (this.#startLine === this.#endLine - 1)
|
||||
this.#lastLineStart = start;
|
||||
this.#selection = rangeDecoder();
|
||||
this.#firstLine = rangeDecoder();
|
||||
}
|
||||
#endFirstLine(position) {
|
||||
this.#firstLineEnd = position;
|
||||
if (this.#firstLine !== void 0)
|
||||
this.#firstLineBytes += decodedBytes(this.#firstLine.decode());
|
||||
this.#firstLine = void 0;
|
||||
}
|
||||
#endSelection(position) {
|
||||
this.#end = position;
|
||||
if (this.#selection !== void 0)
|
||||
this.#selectedBytes += decodedBytes(this.#selection.decode());
|
||||
this.#selection = void 0;
|
||||
}
|
||||
/** Feed `chunk[from, to)`, which starts at file offset `base`, to the decoders of the ranges still open. */
|
||||
#feed(chunk, base, from, to) {
|
||||
if (this.#bom && base + from < 3)
|
||||
from = Math.min(to, 3 - base);
|
||||
if (to <= from)
|
||||
return;
|
||||
const bytes2 = chunk.subarray(from, to);
|
||||
if (this.#selection !== void 0) {
|
||||
this.#selectedBytes += decodedBytes(this.#selection.decode(bytes2, { stream: true }));
|
||||
}
|
||||
if (this.#firstLine !== void 0) {
|
||||
this.#firstLineBytes += decodedBytes(this.#firstLine.decode(bytes2, { stream: true }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// node_modules/@earendil-works/pi-durable/dist/env/index.js
|
||||
function getOrThrow(result) {
|
||||
if (!result.ok)
|
||||
throw result.error;
|
||||
return result.value;
|
||||
}
|
||||
|
||||
// node_modules/@earendil-works/pi-durable/dist/tools/file-mutation-queue.js
|
||||
var queues = /* @__PURE__ */ new Map();
|
||||
async function mutationKey(env, path3, context) {
|
||||
@@ -52185,6 +52185,155 @@ function createCodingTools(options = {}) {
|
||||
}
|
||||
var CodingTools = createCodingTools();
|
||||
|
||||
// environment.ts
|
||||
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({
|
||||
...tool,
|
||||
execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context)
|
||||
})) };
|
||||
var Started = defineDoc({
|
||||
kind: "board.started",
|
||||
version: 1,
|
||||
checkpointWhen: () => false,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "initial",
|
||||
initial: () => ({})
|
||||
});
|
||||
function conversationChains(harness2, context) {
|
||||
const cache = /* @__PURE__ */ new Map();
|
||||
const chain2 = (id) => {
|
||||
let result = cache.get(id);
|
||||
if (!result) {
|
||||
result = (async () => {
|
||||
const record2 = await harness2.commit((tx) => tx.conversation(id), context);
|
||||
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId ?? (await harness2.snapshot(Started, id, context))?.by;
|
||||
return [String(id), ...up === void 0 ? [] : await chain2(up)];
|
||||
})();
|
||||
cache.set(id, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return chain2;
|
||||
}
|
||||
var ops = Deno.core.ops;
|
||||
var ok = (value2) => ({ ok: true, value: value2 });
|
||||
var path2 = (cwd, input) => {
|
||||
const parts = [];
|
||||
for (const p of (input.startsWith("/") ? input : `${cwd}/${input}`).split("/")) {
|
||||
if (p === "..") parts.pop();
|
||||
else if (p && p !== ".") parts.push(p);
|
||||
}
|
||||
return "/" + parts.join("/");
|
||||
};
|
||||
function environment(cwd = "/", conversation, invocation) {
|
||||
const conversations = Array.isArray(conversation) ? conversation : conversation === void 0 ? [] : [String(conversation)];
|
||||
const tag = invocation && { id: invocation.id, pid: 0, program: invocation.tool, conversations };
|
||||
const call = (kind, p, bytes2 = []) => ops.op_file(kind, path2(cwd, p), bytes2, tag);
|
||||
const env = {
|
||||
id: "world",
|
||||
cwd,
|
||||
forTool: (id, tool) => environment(cwd, conversations, { id, tool }),
|
||||
absolutePath: async (p) => ok(path2(cwd, p)),
|
||||
canonicalPath: async () => ({ ok: false, error: { code: "not_supported", message: "The VFS does not expose canonical paths" } }),
|
||||
joinPath: async (parts) => ok(path2(cwd, parts.join("/"))),
|
||||
fileInfo: (p) => call("stat", p),
|
||||
exists: async (p) => {
|
||||
const result = await call("stat", p);
|
||||
return result.ok ? ok(true) : result.error.code === "not_found" ? ok(false) : result;
|
||||
},
|
||||
readBinaryFile: async (p) => {
|
||||
const r = await call("read", p);
|
||||
return r.ok ? ok(new Uint8Array(r.value)) : r;
|
||||
},
|
||||
readTextFile: async (p) => {
|
||||
const r = await env.readBinaryFile(p);
|
||||
return r.ok ? ok(new TextDecoder().decode(r.value)) : r;
|
||||
},
|
||||
writeFile: async (p, content) => {
|
||||
const full = path2(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, options) => {
|
||||
const full = path2(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(void 0);
|
||||
},
|
||||
openBinaryReader: async (p) => {
|
||||
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, length) => ok(data.value.slice(offset, offset + length)),
|
||||
scanLines: async (options) => {
|
||||
const scanner = new LineScanner(options.startLine, options.endLine);
|
||||
scanner.push(data.value);
|
||||
return ok(scanner.finish());
|
||||
},
|
||||
close: async () => {
|
||||
}
|
||||
});
|
||||
},
|
||||
exec: async (command, options, context) => {
|
||||
let spillPath;
|
||||
let pending = "", bytes2 = 0, lines2 = 0;
|
||||
const output = async (text2, stream2) => {
|
||||
options?.onOutput?.(text2, context, { stream: stream2 });
|
||||
if (!options?.spill) return;
|
||||
bytes2 += new TextEncoder().encode(text2).length;
|
||||
lines2 += text2.split("\n").length - 1;
|
||||
if (!spillPath) {
|
||||
pending += text2;
|
||||
if (bytes2 <= options.spill.afterBytes && lines2 + (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(text2)));
|
||||
if (!appended.ok) throw new Error(appended.error.message);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, 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 stream2 of ["stdout", "stderr"]) {
|
||||
const text2 = decoders[stream2].decode();
|
||||
if (text2) await output(text2, stream2);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// node_modules/@earendil-works/chord/dist/sqlite/schema.js
|
||||
function validateSqliteSchemaPrefix(prefix) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix)) {
|
||||
@@ -53125,149 +53274,6 @@ async function openStorage(path3) {
|
||||
return SqliteStorage.open(database);
|
||||
}
|
||||
|
||||
// environment.ts
|
||||
var Started = defineDoc({
|
||||
kind: "board.started",
|
||||
version: 1,
|
||||
checkpointWhen: () => false,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "initial",
|
||||
initial: () => ({})
|
||||
});
|
||||
function conversationChains(harness2, context) {
|
||||
const cache = /* @__PURE__ */ new Map();
|
||||
const chain2 = (id) => {
|
||||
let result = cache.get(id);
|
||||
if (!result) {
|
||||
result = (async () => {
|
||||
const record2 = await harness2.commit((tx) => tx.conversation(id), context);
|
||||
const up = record2?.owner?.conversationId ?? record2?.parent?.conversationId ?? (await harness2.snapshot(Started, id, context))?.by;
|
||||
return [String(id), ...up === void 0 ? [] : await chain2(up)];
|
||||
})();
|
||||
cache.set(id, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return chain2;
|
||||
}
|
||||
var ops = Deno.core.ops;
|
||||
var ok = (value2) => ({ ok: true, value: value2 });
|
||||
var path2 = (cwd, input) => {
|
||||
const parts = [];
|
||||
for (const p of (input.startsWith("/") ? input : `${cwd}/${input}`).split("/")) {
|
||||
if (p === "..") parts.pop();
|
||||
else if (p && p !== ".") parts.push(p);
|
||||
}
|
||||
return "/" + parts.join("/");
|
||||
};
|
||||
function environment(cwd = "/", conversation) {
|
||||
const conversations = Array.isArray(conversation) ? conversation : conversation === void 0 ? [] : [String(conversation)];
|
||||
const call = (kind, p, bytes2 = []) => ops.op_file(kind, path2(cwd, p), bytes2);
|
||||
const env = {
|
||||
id: "world",
|
||||
cwd,
|
||||
absolutePath: async (p) => ok(path2(cwd, p)),
|
||||
canonicalPath: async () => ({ ok: false, error: { code: "not_supported", message: "The VFS does not expose canonical paths" } }),
|
||||
joinPath: async (parts) => ok(path2(cwd, parts.join("/"))),
|
||||
fileInfo: (p) => call("stat", p),
|
||||
exists: async (p) => {
|
||||
const result = await call("stat", p);
|
||||
return result.ok ? ok(true) : result.error.code === "not_found" ? ok(false) : result;
|
||||
},
|
||||
readBinaryFile: async (p) => {
|
||||
const r = await call("read", p);
|
||||
return r.ok ? ok(new Uint8Array(r.value)) : r;
|
||||
},
|
||||
readTextFile: async (p) => {
|
||||
const r = await env.readBinaryFile(p);
|
||||
return r.ok ? ok(new TextDecoder().decode(r.value)) : r;
|
||||
},
|
||||
writeFile: async (p, content) => {
|
||||
const full = path2(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, options) => {
|
||||
const full = path2(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(void 0);
|
||||
},
|
||||
openBinaryReader: async (p) => {
|
||||
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, length) => ok(data.value.slice(offset, offset + length)),
|
||||
scanLines: async (options) => {
|
||||
const scanner = new LineScanner(options.startLine, options.endLine);
|
||||
scanner.push(data.value);
|
||||
return ok(scanner.finish());
|
||||
},
|
||||
close: async () => {
|
||||
}
|
||||
});
|
||||
},
|
||||
exec: async (command, options, context) => {
|
||||
let spillPath;
|
||||
let pending = "", bytes2 = 0, lines2 = 0;
|
||||
const output = async (text2, stream2) => {
|
||||
options?.onOutput?.(text2, context, { stream: stream2 });
|
||||
if (!options?.spill) return;
|
||||
bytes2 += new TextEncoder().encode(text2).length;
|
||||
lines2 += text2.split("\n").length - 1;
|
||||
if (!spillPath) {
|
||||
pending += text2;
|
||||
if (bytes2 <= options.spill.afterBytes && lines2 + (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(text2)));
|
||||
if (!appended.ok) throw new Error(appended.error.message);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations);
|
||||
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 stream2 of ["stdout", "stderr"]) {
|
||||
const text2 = decoders[stream2].decode();
|
||||
if (text2) await output(text2, stream2);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// credentials.ts
|
||||
var Requests = defineDoc({
|
||||
kind: "sausage.credential.requests",
|
||||
@@ -54157,7 +54163,13 @@ var fs = {
|
||||
};
|
||||
async function exec(command, options) {
|
||||
options.signal?.throwIfAborted();
|
||||
const rid = ops2.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations);
|
||||
const tag = options.extension ? {
|
||||
id: options.toolCall ?? `extension:${options.extension}:${options.task ?? crypto.randomUUID()}`,
|
||||
pid: 0,
|
||||
program: `extension:${options.extension}`,
|
||||
conversations: options.conversations ?? []
|
||||
} : void 0;
|
||||
const rid = ops2.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
|
||||
const abort = () => Deno.core.close(rid);
|
||||
options.signal?.addEventListener("abort", abort, { once: true });
|
||||
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
||||
@@ -54245,7 +54257,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
|
||||
settings: () => settings,
|
||||
metadata: Metadata,
|
||||
fs,
|
||||
exec,
|
||||
exec: (command, options) => exec(command, { ...options, extension: name }),
|
||||
git,
|
||||
implement: (api, value2) => {
|
||||
staged.set(api.name, value2);
|
||||
@@ -54428,7 +54440,7 @@ models.setProvider({
|
||||
}) } }
|
||||
});
|
||||
var registry = createRegistry();
|
||||
registry.install(CodingTools);
|
||||
registry.install(CodingTools2);
|
||||
registry.install(CredentialRequests);
|
||||
var storage = await openStorage(globalThis.storagePath);
|
||||
var harness = await Harness.open(storage, {
|
||||
@@ -54442,7 +54454,7 @@ var harness = await Harness.open(storage, {
|
||||
var chain = conversationChains(harness, BACKGROUND_CONTEXT);
|
||||
var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "openai", modelId: model2.id }, thinkingLevel: "low", cwd: "/" } });
|
||||
var storedAgent = await harness.snapshot(AgentDoc, root.id, BACKGROUND_CONTEXT);
|
||||
if (Array.isArray(storedAgent?.extensions) && storedAgent.extensions.length === 2 && [CodingTools, CredentialRequests].every((extension) => storedAgent.extensions.includes(extension.name))) {
|
||||
if (Array.isArray(storedAgent?.extensions) && storedAgent.extensions.length === 2 && [CodingTools2, CredentialRequests].every((extension) => storedAgent.extensions.includes(extension.name))) {
|
||||
await root.configure({ extensions: null }, BACKGROUND_CONTEXT);
|
||||
}
|
||||
var watch = await root.watch(BACKGROUND_CONTEXT);
|
||||
@@ -54470,7 +54482,7 @@ if (globalThis.consoleEnabled) watch.start((event) => {
|
||||
if (event.kind === "value") render(event.value);
|
||||
});
|
||||
var stopRequests = await startRequests(harness, BACKGROUND_CONTEXT);
|
||||
var extensions = await loadExtensions(harness, registry, storage, models, BACKGROUND_CONTEXT, [CodingTools, CredentialRequests]);
|
||||
var extensions = await loadExtensions(harness, registry, storage, models, BACKGROUND_CONTEXT, [CodingTools2, CredentialRequests]);
|
||||
harness.resume();
|
||||
await serve(harness, BACKGROUND_CONTEXT, globalThis.consoleEnabled ? async () => {
|
||||
await harness.waitForIdle(BACKGROUND_CONTEXT);
|
||||
|
||||
@@ -21249,6 +21249,10 @@ function createCodingTools(options = {}) {
|
||||
var CodingTools = createCodingTools();
|
||||
|
||||
// environment.ts
|
||||
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({
|
||||
...tool,
|
||||
execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context)
|
||||
})) };
|
||||
var Started = defineDoc({
|
||||
kind: "board.started",
|
||||
version: 1,
|
||||
@@ -21284,12 +21288,14 @@ var path = (cwd, input) => {
|
||||
}
|
||||
return "/" + parts.join("/");
|
||||
};
|
||||
function environment(cwd = "/", conversation) {
|
||||
function environment(cwd = "/", conversation, invocation) {
|
||||
const conversations = Array.isArray(conversation) ? conversation : conversation === void 0 ? [] : [String(conversation)];
|
||||
const call = (kind, p, bytes2 = []) => ops.op_file(kind, path(cwd, p), bytes2);
|
||||
const tag = invocation && { id: invocation.id, pid: 0, program: invocation.tool, conversations };
|
||||
const call = (kind, p, bytes2 = []) => ops.op_file(kind, path(cwd, p), bytes2, tag);
|
||||
const env = {
|
||||
id: "world",
|
||||
cwd,
|
||||
forTool: (id, tool) => environment(cwd, conversations, { id, tool }),
|
||||
absolutePath: async (p) => ok(path(cwd, p)),
|
||||
canonicalPath: async () => ({ ok: false, error: { code: "not_supported", message: "The VFS does not expose canonical paths" } }),
|
||||
joinPath: async (parts) => ok(path(cwd, parts.join("/"))),
|
||||
@@ -21364,7 +21370,7 @@ function environment(cwd = "/", conversation) {
|
||||
}
|
||||
};
|
||||
try {
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations);
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag);
|
||||
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
||||
let result;
|
||||
while (true) {
|
||||
@@ -21391,6 +21397,32 @@ function environment(cwd = "/", conversation) {
|
||||
return env;
|
||||
}
|
||||
|
||||
// events.ts
|
||||
function defineEvent(name, options = {}) {
|
||||
const kind = `event.${name}`;
|
||||
return { name, kind, version: options.version ?? 1, is: (event) => event?.kind === kind };
|
||||
}
|
||||
var NoticeEvent = defineEvent("sausage.notice");
|
||||
var EVENT_FEED = "sausage.event-feed";
|
||||
var EventLog = defineDoc({
|
||||
kind: "sausage.events",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "initial",
|
||||
checkpointWhen: () => false,
|
||||
initial: () => ({ cursors: {} })
|
||||
});
|
||||
var EventFeed = defineDoc({
|
||||
kind: EVENT_FEED,
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "initial",
|
||||
checkpointWhen: () => false,
|
||||
initial: () => ({ events: [] })
|
||||
});
|
||||
|
||||
// extensions.ts
|
||||
var ops2 = Deno.core.ops;
|
||||
var encoder3 = new TextEncoder();
|
||||
@@ -21415,7 +21447,13 @@ var fs = {
|
||||
};
|
||||
async function exec(command, options) {
|
||||
options.signal?.throwIfAborted();
|
||||
const rid = ops2.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations);
|
||||
const tag = options.extension ? {
|
||||
id: options.toolCall ?? `extension:${options.extension}:${options.task ?? crypto.randomUUID()}`,
|
||||
pid: 0,
|
||||
program: `extension:${options.extension}`,
|
||||
conversations: options.conversations ?? []
|
||||
} : void 0;
|
||||
const rid = ops2.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
|
||||
const abort = () => Deno.core.close(rid);
|
||||
options.signal?.addEventListener("abort", abort, { once: true });
|
||||
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
||||
|
||||
+682
-486
File diff suppressed because it is too large
Load Diff
+12
-3
@@ -1,6 +1,13 @@
|
||||
// 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,
|
||||
@@ -32,11 +39,13 @@ const path = (cwd: string, input: string) => {
|
||||
}
|
||||
return '/' + parts.join('/');
|
||||
};
|
||||
export function environment(cwd = '/', conversation?: string | number | string[]) {
|
||||
export function environment(cwd = '/', conversation?: string | number | string[], invocation?: {id:string;tool:string}) {
|
||||
const conversations = Array.isArray(conversation) ? conversation : conversation === undefined ? [] : [String(conversation)];
|
||||
const call = (kind: string, p: string, bytes: number[] = []) => ops.op_file(kind, path(cwd, p), bytes);
|
||||
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('/'))),
|
||||
@@ -110,7 +119,7 @@ export function environment(cwd = '/', conversation?: string | number | string[]
|
||||
}
|
||||
};
|
||||
try {
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, undefined, undefined, conversations);
|
||||
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) {
|
||||
|
||||
@@ -31392,7 +31392,13 @@ var fs = {
|
||||
};
|
||||
async function exec(command, options) {
|
||||
options.signal?.throwIfAborted();
|
||||
const rid = ops.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations);
|
||||
const tag = options.extension ? {
|
||||
id: options.toolCall ?? `extension:${options.extension}:${options.task ?? crypto.randomUUID()}`,
|
||||
pid: 0,
|
||||
program: `extension:${options.extension}`,
|
||||
conversations: options.conversations ?? []
|
||||
} : void 0;
|
||||
const rid = ops.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
|
||||
const abort = () => Deno.core.close(rid);
|
||||
options.signal?.addEventListener("abort", abort, { once: true });
|
||||
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
||||
@@ -31480,7 +31486,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
|
||||
settings: () => settings,
|
||||
metadata: Metadata,
|
||||
fs,
|
||||
exec,
|
||||
exec: (command, options) => exec(command, { ...options, extension: name }),
|
||||
git,
|
||||
implement: (api, value2) => {
|
||||
staged.set(api.name, value2);
|
||||
|
||||
@@ -31392,7 +31392,13 @@ var fs = {
|
||||
};
|
||||
async function exec(command, options) {
|
||||
options.signal?.throwIfAborted();
|
||||
const rid = ops.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations);
|
||||
const tag = options.extension ? {
|
||||
id: options.toolCall ?? `extension:${options.extension}:${options.task ?? crypto.randomUUID()}`,
|
||||
pid: 0,
|
||||
program: `extension:${options.extension}`,
|
||||
conversations: options.conversations ?? []
|
||||
} : void 0;
|
||||
const rid = ops.op_exec(command, options.cwd, options.timeout, void 0, options.env, options.conversations, tag);
|
||||
const abort = () => Deno.core.close(rid);
|
||||
options.signal?.addEventListener("abort", abort, { once: true });
|
||||
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
||||
@@ -31480,7 +31486,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
|
||||
settings: () => settings,
|
||||
metadata: Metadata,
|
||||
fs,
|
||||
exec,
|
||||
exec: (command, options) => exec(command, { ...options, extension: name }),
|
||||
git,
|
||||
implement: (api, value2) => {
|
||||
staged.set(api.name, value2);
|
||||
|
||||
@@ -36,9 +36,11 @@ export const fs = {
|
||||
remove: (path:string) => file('remove',path),
|
||||
rename: (from:string,to:string) => file('rename',from,Array.from(encoder.encode(to))),
|
||||
};
|
||||
export async function exec(command:string|string[], options:{cwd:string;env?:[string,string][];timeout?:number;conversations?:string[];signal?:AbortSignal;onOutput?:(text:string,stream:string)=>void}) {
|
||||
export async function exec(command:string|string[], options:{cwd:string;env?:[string,string][];timeout?:number;conversations?:string[];signal?:AbortSignal;toolCall?:string;task?:string|number;extension?:string;onOutput?:(text:string,stream:string)=>void}) {
|
||||
options.signal?.throwIfAborted();
|
||||
const rid = ops.op_exec(command, options.cwd, options.timeout, undefined, options.env, options.conversations);
|
||||
const tag = options.extension ? {id:options.toolCall ?? `extension:${options.extension}:${options.task ?? crypto.randomUUID()}`,
|
||||
pid:0, program:`extension:${options.extension}`, conversations:options.conversations ?? []} : undefined;
|
||||
const rid = ops.op_exec(command, options.cwd, options.timeout, undefined, options.env, options.conversations, tag);
|
||||
const abort = () => Deno.core.close(rid);
|
||||
options.signal?.addEventListener('abort',abort,{once:true});
|
||||
const decoders:any = {stdout:new TextDecoder(),stderr:new TextDecoder()};
|
||||
@@ -101,7 +103,8 @@ export async function loadExtensions(harness:any, registry:any, storage:any, mod
|
||||
} : member;
|
||||
}});
|
||||
const host:Host = {
|
||||
harness:()=>harness, storage, models:()=>models, settings:()=>settings, metadata:Metadata, fs,exec,git,
|
||||
harness:()=>harness, storage, models:()=>models, settings:()=>settings, metadata:Metadata, fs,
|
||||
exec:(command,options)=>exec(command,{...options,extension:name}),git,
|
||||
implement:(api,value)=>{staged.set(api.name,value);}, need:handle,
|
||||
want:(api)=>providers.has(api.name)?handle(api):undefined,
|
||||
resource:(key,create)=>{const id=`${name}/${key}`;if(!resources.has(id))resources.set(id,create());return resources.get(id);},
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ 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, AgentDoc, createRegistry } from '@earendil-works/pi-durable';
|
||||
import { CodingTools } from '@earendil-works/pi-durable/tools';
|
||||
import { CodingTools } from './environment';
|
||||
import { openStorage } from './sqlite';
|
||||
import { environment, conversationChains } from './environment';
|
||||
import { CredentialRequests, startRequests } from './credentials';
|
||||
|
||||
@@ -21249,6 +21249,10 @@ function createCodingTools(options = {}) {
|
||||
var CodingTools = createCodingTools();
|
||||
|
||||
// environment.ts
|
||||
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({
|
||||
...tool,
|
||||
execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context)
|
||||
})) };
|
||||
var Started = defineDoc({
|
||||
kind: "board.started",
|
||||
version: 1,
|
||||
@@ -21268,12 +21272,14 @@ var path = (cwd, input) => {
|
||||
}
|
||||
return "/" + parts.join("/");
|
||||
};
|
||||
function environment(cwd = "/", conversation) {
|
||||
function environment(cwd = "/", conversation, invocation) {
|
||||
const conversations = Array.isArray(conversation) ? conversation : conversation === void 0 ? [] : [String(conversation)];
|
||||
const call = (kind, p, bytes2 = []) => ops.op_file(kind, path(cwd, p), bytes2);
|
||||
const tag = invocation && { id: invocation.id, pid: 0, program: invocation.tool, conversations };
|
||||
const call = (kind, p, bytes2 = []) => ops.op_file(kind, path(cwd, p), bytes2, tag);
|
||||
const env = {
|
||||
id: "world",
|
||||
cwd,
|
||||
forTool: (id, tool) => environment(cwd, conversations, { id, tool }),
|
||||
absolutePath: async (p) => ok(path(cwd, p)),
|
||||
canonicalPath: async () => ({ ok: false, error: { code: "not_supported", message: "The VFS does not expose canonical paths" } }),
|
||||
joinPath: async (parts) => ok(path(cwd, parts.join("/"))),
|
||||
@@ -21348,7 +21354,7 @@ function environment(cwd = "/", conversation) {
|
||||
}
|
||||
};
|
||||
try {
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations);
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag);
|
||||
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
||||
let result;
|
||||
while (true) {
|
||||
|
||||
@@ -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" },
|
||||
"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" },
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-durable": "1.5.0",
|
||||
"@earendil-works/pi-ai": "1.5.0",
|
||||
|
||||
+685
-489
File diff suppressed because it is too large
Load Diff
+183
-173
File diff suppressed because it is too large
Load Diff
@@ -21249,6 +21249,10 @@ function createCodingTools(options = {}) {
|
||||
var CodingTools = createCodingTools();
|
||||
|
||||
// environment.ts
|
||||
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({
|
||||
...tool,
|
||||
execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context)
|
||||
})) };
|
||||
var Started = defineDoc({
|
||||
kind: "board.started",
|
||||
version: 1,
|
||||
@@ -21268,12 +21272,14 @@ var path = (cwd, input) => {
|
||||
}
|
||||
return "/" + parts.join("/");
|
||||
};
|
||||
function environment(cwd = "/", conversation) {
|
||||
function environment(cwd = "/", conversation, invocation) {
|
||||
const conversations = Array.isArray(conversation) ? conversation : conversation === void 0 ? [] : [String(conversation)];
|
||||
const call = (kind, p, bytes2 = []) => ops.op_file(kind, path(cwd, p), bytes2);
|
||||
const tag = invocation && { id: invocation.id, pid: 0, program: invocation.tool, conversations };
|
||||
const call = (kind, p, bytes2 = []) => ops.op_file(kind, path(cwd, p), bytes2, tag);
|
||||
const env = {
|
||||
id: "world",
|
||||
cwd,
|
||||
forTool: (id, tool) => environment(cwd, conversations, { id, tool }),
|
||||
absolutePath: async (p) => ok(path(cwd, p)),
|
||||
canonicalPath: async () => ({ ok: false, error: { code: "not_supported", message: "The VFS does not expose canonical paths" } }),
|
||||
joinPath: async (parts) => ok(path(cwd, parts.join("/"))),
|
||||
@@ -21348,7 +21354,7 @@ function environment(cwd = "/", conversation) {
|
||||
}
|
||||
};
|
||||
try {
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations);
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag);
|
||||
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
||||
let result2;
|
||||
while (true) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
// A faux model changes three independent files in one turn through the real coding tools.
|
||||
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 } from '@earendil-works/pi-durable';
|
||||
import { CodingTools, environment } from './environment';
|
||||
import { openStorage } from './sqlite';
|
||||
import { exec } from './extensions';
|
||||
const models = createModels(), faux = fauxProvider({tokensPerSecond:100000});
|
||||
models.setProvider(faux.provider);
|
||||
faux.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall('edit',{path:'a.txt',edits:[{oldText:'old',newText:'edited'}]},{id:'first'})],{stopReason:'toolUse'}),
|
||||
fauxAssistantMessage([fauxToolCall('bash',{command:'echo changed > b.txt && git add b.txt && git commit -m changed'},{id:'second'})],{stopReason:'toolUse'}),
|
||||
fauxAssistantMessage([fauxToolCall('write',{path:'c.txt',content:'written\n'},{id:'third'})],{stopReason:'toolUse'}),
|
||||
fauxAssistantMessage('done'),
|
||||
]);
|
||||
const registry = createRegistry(); registry.install(CodingTools);
|
||||
const harness = await Harness.open(await openStorage(globalThis.storagePath),{models,registry,
|
||||
env:(target:any)=>environment(target.cwd,target.conversationId)},context);
|
||||
const root = await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'},cwd:'/repo',extensions:[CodingTools]}});
|
||||
harness.resume();
|
||||
await (await root.submit({type:'input',content:'Change three files'},context)).wait(context);
|
||||
await harness.waitForIdle(context);
|
||||
const entries = (await root.watch(context)).value.entries;
|
||||
if(JSON.stringify(entries).includes('"isError":true'))throw new Error(JSON.stringify(entries));
|
||||
await harness.close(context);
|
||||
const result = await exec(['sh','-c','echo extension > /extension.txt'],
|
||||
{cwd:'/',extension:'test-extension',task:'background',conversations:['extension']});
|
||||
if(result.code)throw new Error(result.stderr);
|
||||
const triggered = await exec(['sh','-c','echo triggered > /triggered.txt'],
|
||||
{cwd:'/',extension:'test-extension',toolCall:'extension-call',conversations:['extension']});
|
||||
if(triggered.code)throw new Error(triggered.stderr);
|
||||
@@ -18,8 +18,10 @@ pub async fn op_file(
|
||||
#[string] kind: String,
|
||||
#[string] path: String,
|
||||
#[serde] bytes: Vec<u8>,
|
||||
#[serde] tag: Option<sausage_vfs::journal::Tag>,
|
||||
) -> Result<serde_json::Value, JsErrorBox> {
|
||||
let root = state.borrow().borrow::<Environment>().root.clone();
|
||||
let root = tag.and_then(|tag| root.tagged(tag)).unwrap_or(root);
|
||||
let result: std::io::Result<Value> = async {
|
||||
match kind.as_str() {
|
||||
"read" => {
|
||||
@@ -85,8 +87,10 @@ pub fn op_exec(
|
||||
#[string] conversation: Option<String>,
|
||||
#[serde] env: Option<Vec<(String, String)>>,
|
||||
#[serde] conversations: Option<Vec<String>>,
|
||||
#[serde] tag: Option<sausage_vfs::journal::Tag>,
|
||||
) -> Result<u32, JsErrorBox> {
|
||||
let exec = state.borrow::<Environment>().exec.clone();
|
||||
let journal = state.borrow::<Environment>().root.journal();
|
||||
let command = if let Some(s) = command.as_str() {
|
||||
Command::Shell(s.to_owned())
|
||||
} else {
|
||||
@@ -96,7 +100,14 @@ pub fn op_exec(
|
||||
};
|
||||
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||
let task = tokio::spawn(async move {
|
||||
let result = exec.exec(command, Options { cwd, env: env.unwrap_or_default(), conversations: conversations.unwrap_or_else(|| conversation.into_iter().collect()), timeout: timeout.map(std::time::Duration::from_secs_f64), tool_call: None }, &mut |stream, bytes| { let _ = sender.send(json!({"stream": if stream == Stream::Stdout {"stdout"} else {"stderr"}, "bytes": bytes})); }).await;
|
||||
let tool_call = tag.as_ref().map(|tag| tag.id.clone());
|
||||
if let (Some(journal), Some(tag)) = (journal, tag)
|
||||
&& let Err(error) = journal.register(tag).await
|
||||
{
|
||||
let _ = sender.send(json!({"error": error.to_string()}));
|
||||
return;
|
||||
}
|
||||
let result = exec.exec(command, Options { cwd, tool_call, env: env.unwrap_or_default(), conversations: conversations.unwrap_or_else(|| conversation.into_iter().collect()), timeout: timeout.map(std::time::Duration::from_secs_f64) }, &mut |stream, bytes| { let _ = sender.send(json!({"stream": if stream == Stream::Stdout {"stdout"} else {"stderr"}, "bytes": bytes})); }).await;
|
||||
let _ = sender.send(match result {
|
||||
Ok(exit) => json!({"exitCode": exit}),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
|
||||
+31
-14
@@ -84,8 +84,12 @@ async fn status_cost() {
|
||||
assert!(output.status.success());
|
||||
}
|
||||
let mut times = [Vec::new(), Vec::new()];
|
||||
for round in 0..3 {
|
||||
for enabled in [false, true] {
|
||||
for round in 0..5 {
|
||||
for enabled in if round % 2 == 0 {
|
||||
[false, true]
|
||||
} else {
|
||||
[true, false]
|
||||
} {
|
||||
let instance = temp.path().join(format!("{round}-{enabled}"));
|
||||
let mut world = World::open(&instance).unwrap();
|
||||
let root = instance.join("world");
|
||||
@@ -105,14 +109,6 @@ async fn status_cost() {
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
// Same content, new timestamp: status must refresh its index.
|
||||
assert!(
|
||||
std::process::Command::new("touch")
|
||||
.arg(root.join("src/sausage/README.md"))
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
if !enabled {
|
||||
let mut mounts = Mounts::default();
|
||||
mounts.mount(
|
||||
@@ -122,15 +118,16 @@ async fn status_cost() {
|
||||
);
|
||||
world.root = Arc::new(mounts);
|
||||
}
|
||||
let journal = world.root.journal();
|
||||
let runtime = Runtime::new(world).unwrap();
|
||||
let options = Options {
|
||||
let mut options = Options {
|
||||
cwd: "/src/sausage".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
runtime
|
||||
.exec(
|
||||
Command::Argv(vec!["/bin/git".into(), "--version".into()]),
|
||||
Command::Argv(vec!["/bin/git".into(), "status".into(), "--short".into()]),
|
||||
options.clone(),
|
||||
&mut |_, _| {}
|
||||
)
|
||||
@@ -138,6 +135,16 @@ async fn status_cost() {
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
// Warm the copied repository's index, not only component compilation.
|
||||
// Then require another index refresh in the measured call.
|
||||
assert!(
|
||||
std::process::Command::new("touch")
|
||||
.arg(root.join("src/sausage/README.md"))
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
options.tool_call = Some("measured".into());
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
runtime
|
||||
@@ -155,6 +162,16 @@ async fn status_cost() {
|
||||
0
|
||||
);
|
||||
times[usize::from(enabled)].push(start.elapsed().as_secs_f64());
|
||||
if let Some(journal) = journal {
|
||||
assert!(
|
||||
journal
|
||||
.show("measured".into())
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|snapshot| snapshot.path == "/src/sausage/.git/index")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for values in &mut times {
|
||||
@@ -162,10 +179,10 @@ async fn status_cost() {
|
||||
}
|
||||
println!(
|
||||
"status without journal: {:.6}s; with journal: {:.6}s",
|
||||
times[0][1], times[1][1]
|
||||
times[0][2], times[1][2]
|
||||
);
|
||||
assert!(
|
||||
times[1][1] <= times[0][1] * 1.10,
|
||||
times[1][2] <= times[0][2] * 1.10,
|
||||
"journal exceeds ten percent"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Faux-model coding calls use the real world journal, shell, Git, and undo CLI.
|
||||
use sausage::{
|
||||
exec::{Command, Exec, Options},
|
||||
process::Runtime,
|
||||
world::World,
|
||||
};
|
||||
use sausage_vfs::journal::{Entry, Outcome};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
fn git(repo: &Path, args: &[&str]) -> String {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap()
|
||||
}
|
||||
async fn command(runtime: &Arc<Runtime>, args: &[&str]) -> String {
|
||||
let mut bytes = Vec::new();
|
||||
let code = runtime
|
||||
.exec(
|
||||
Command::Argv(args.iter().map(|s| s.to_string()).collect()),
|
||||
Options {
|
||||
cwd: "/repo".into(),
|
||||
..Default::default()
|
||||
},
|
||||
&mut |_, b| bytes.extend_from_slice(b),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(code, 0, "{}", String::from_utf8_lossy(&bytes));
|
||||
String::from_utf8(bytes).unwrap()
|
||||
}
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn tools_restore_one_call_or_the_whole_turn() {
|
||||
let target = PathBuf::from(std::env::var("CARGO_TARGET_DIR").unwrap());
|
||||
for since in [false, true] {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let world = World::open(temp.path()).unwrap();
|
||||
let root = temp.path().join("world");
|
||||
std::fs::create_dir_all(root.join("bin")).unwrap();
|
||||
std::fs::create_dir_all(root.join("repo")).unwrap();
|
||||
for (source, name) in [("brush", "sh"), ("git", "git"), ("undo", "undo")] {
|
||||
std::fs::copy(
|
||||
target.join(format!("wasm32-wasip2/release/{source}.wasm")),
|
||||
root.join(format!("bin/{name}")),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let repo = root.join("repo");
|
||||
git(&repo, &["init", "-b", "main"]);
|
||||
git(&repo, &["config", "user.name", "Test"]);
|
||||
git(&repo, &["config", "user.email", "test@example.com"]);
|
||||
for name in ["a.txt", "b.txt"] {
|
||||
std::fs::write(repo.join(name), "old\n").unwrap();
|
||||
}
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "--no-gpg-sign", "-m", "initial"]);
|
||||
let before = git(&repo, &["log", "--oneline"]);
|
||||
let runtime = Runtime::new(World {
|
||||
root: world.root.clone(),
|
||||
host_dir: world.host_dir.clone(),
|
||||
processes: world.processes.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
sausage::harness::run_module(
|
||||
world,
|
||||
Arc::new(runtime.clone()),
|
||||
include_str!("../js/undo-harness-test-bundle.js"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let extensions: Vec<Entry> = serde_json::from_str(
|
||||
&command(
|
||||
&runtime,
|
||||
&["/bin/undo", "list", "--conversation", "extension"],
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(extensions.len(), 2);
|
||||
assert_eq!(extensions[0].tag, "extension-call");
|
||||
assert_eq!(extensions[1].tag, "extension:test-extension:background");
|
||||
assert_eq!(extensions[1].program, "extension:test-extension");
|
||||
assert_eq!(extensions[1].changed_paths, ["/extension.txt"]);
|
||||
let listed = command(&runtime, &["/bin/undo", "list", "--conversation", "1"]).await;
|
||||
let entries: Vec<Entry> = serde_json::from_str(&listed).unwrap();
|
||||
assert_eq!(
|
||||
entries.iter().map(|e| e.tag.as_str()).collect::<Vec<_>>(),
|
||||
["third", "second", "first"]
|
||||
);
|
||||
for (tag, tool, path) in [
|
||||
("first", "edit", "/repo/a.txt"),
|
||||
("second", "bash", "/repo/b.txt"),
|
||||
("third", "write", "/repo/c.txt"),
|
||||
] {
|
||||
let entry = entries.iter().find(|e| e.tag == tag).unwrap();
|
||||
assert_eq!(entry.program, tool);
|
||||
assert!(entry.changed_paths.contains(&path.to_string()));
|
||||
}
|
||||
assert!(
|
||||
entries[1]
|
||||
.changed_paths
|
||||
.iter()
|
||||
.any(|p| p.starts_with("/repo/.git/"))
|
||||
);
|
||||
let first = entries.last().unwrap().entry.to_string();
|
||||
let args = if since {
|
||||
vec!["/bin/undo", "--since", &first]
|
||||
} else {
|
||||
vec!["/bin/undo", "second"]
|
||||
};
|
||||
let restored: Outcome = serde_json::from_str(&command(&runtime, &args).await).unwrap();
|
||||
assert!(restored.conflicts.is_empty());
|
||||
assert!(restored.tag.is_some());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(repo.join("b.txt")).unwrap(),
|
||||
"old\n"
|
||||
);
|
||||
assert_eq!(git(&repo, &["log", "--oneline"]), before);
|
||||
if since {
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(repo.join("a.txt")).unwrap(),
|
||||
"old\n"
|
||||
);
|
||||
assert!(!repo.join("c.txt").exists());
|
||||
assert_eq!(git(&repo, &["status", "--short"]), "");
|
||||
} else {
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(repo.join("a.txt")).unwrap(),
|
||||
"edited\n"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(repo.join("c.txt")).unwrap(),
|
||||
"written\n"
|
||||
);
|
||||
assert_eq!(git(&repo, &["status", "--short"]), " M a.txt\n?? c.txt\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -58,6 +58,7 @@ pub struct Entry {
|
||||
pub conversations: Vec<String>,
|
||||
pub created: u64,
|
||||
pub paths: usize,
|
||||
pub changed_paths: Vec<String>,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Snapshot {
|
||||
@@ -198,10 +199,17 @@ impl Journal {
|
||||
self.seen.lock().unwrap().insert(key);
|
||||
Ok(())
|
||||
}
|
||||
/// Reserve caller metadata before a process takes its first snapshot.
|
||||
pub async fn register(&self, tag: Tag) -> Result<()> {
|
||||
self.db(move |s| {
|
||||
s.db.execute("INSERT OR IGNORE INTO tags(id,pid,program,conversations,created) VALUES (?1,?2,?3,?4,?5)", params![tag.id, tag.pid, tag.program, serde_json::to_string(&tag.conversations).map_err(err)?, now() as i64]).map_err(err)?;
|
||||
Ok(())
|
||||
}).await
|
||||
}
|
||||
pub async fn list(&self, conversation: Option<String>) -> Result<Vec<Entry>> {
|
||||
self.db(move |s| {
|
||||
let mut statement = s.db.prepare("SELECT t.entry,t.id,t.pid,t.program,t.conversations,t.created,COUNT(s.entry) FROM tags t JOIN snapshots s ON s.tag=t.id GROUP BY t.id ORDER BY t.entry DESC").map_err(err)?;
|
||||
let entries = statement.query_map([], |r| Ok(Entry { entry:r.get(0)?, tag:r.get(1)?, pid:r.get(2)?, program:r.get(3)?, conversations:serde_json::from_str(&r.get::<_,String>(4)?).unwrap_or_default(), created:r.get::<_, i64>(5)? as u64, paths:r.get::<_, i64>(6)? as usize })).map_err(err)?.collect::<rusqlite::Result<Vec<_>>>().map_err(err)?;
|
||||
let mut statement = s.db.prepare("SELECT t.entry,t.id,t.pid,t.program,t.conversations,t.created,COUNT(s.entry),json_group_array(s.path) FROM tags t JOIN snapshots s ON s.tag=t.id GROUP BY t.id ORDER BY t.entry DESC").map_err(err)?;
|
||||
let entries = statement.query_map([], |r| Ok(Entry { entry:r.get(0)?, tag:r.get(1)?, pid:r.get(2)?, program:r.get(3)?, conversations:serde_json::from_str(&r.get::<_,String>(4)?).unwrap_or_default(), created:r.get::<_, i64>(5)? as u64, paths:r.get::<_, i64>(6)? as usize, changed_paths:serde_json::from_str(&r.get::<_,String>(7)?).map_err(|e| rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(e)))? })).map_err(err)?.collect::<rusqlite::Result<Vec<_>>>().map_err(err)?;
|
||||
Ok(entries.into_iter().filter(|e| conversation.as_ref().is_none_or(|c| e.conversations.contains(c))).collect())
|
||||
}).await
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user