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:
2026-11-07 13:50:06 +00:00
20 changed files with 24624 additions and 1490 deletions
+7
View File
@@ -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
View File
@@ -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);
+42 -4
View File
@@ -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() };
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -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) {
+8 -2
View File
@@ -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);
+8 -2
View File
@@ -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);
+6 -3
View File
@@ -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
View File
@@ -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';
+9 -3
View File
@@ -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 -1
View File
@@ -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",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -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
+32
View File
@@ -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);
+12 -1
View File
@@ -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
View File
@@ -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"
);
}
+147
View File
@@ -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
View File
@@ -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
}