host: expose harness state and actions over the world API

This commit is contained in:
2026-11-07 19:22:06 +00:00
parent c3c5b36d09
commit 5dc4833e28
33 changed files with 33178 additions and 112 deletions
+7 -1
View File
@@ -32828,6 +32828,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next2.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -32893,7 +32894,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
+252 -72
View File
@@ -306,12 +306,12 @@ function lazyStream(model3, setup, observer) {
return outer;
}
function lazyApi(load, capabilities) {
const api = {
const api2 = {
stream: (model3, context, options) => lazyStream(model3, async () => (await load()).stream(model3, context, options)),
streamSimple: (model3, context, options) => lazyStream(model3, async () => (await load()).streamSimple(model3, context, options))
};
if (capabilities?.fetchDeferred) {
api.fetchDeferred = (model3, handle, options) => lazyStream(model3, async () => {
api2.fetchDeferred = (model3, handle, options) => lazyStream(model3, async () => {
const implementation = await load();
if (!implementation.fetchDeferred)
throw new Error("API does not support deferred responses");
@@ -319,14 +319,14 @@ function lazyApi(load, capabilities) {
});
}
if (capabilities?.cancelDeferred) {
api.cancelDeferred = async (model3, handle, options) => {
api2.cancelDeferred = async (model3, handle, options) => {
const implementation = await load();
if (!implementation.cancelDeferred)
throw new Error("API cannot cancel deferred responses");
await implementation.cancelDeferred(model3, handle, options);
};
}
return api;
return api2;
}
var init_lazy = __esm({
"node_modules/@earendil-works/pi-ai/dist/api/lazy.js"() {
@@ -1400,8 +1400,8 @@ function createProvider(input) {
}
return provider3;
}
function hasApi(model3, api) {
return isModelType(model3, "chat") && model3.api === api;
function hasApi(model3, api2) {
return isModelType(model3, "chat") && model3.api === api2;
}
function calculateCost(model3, usage) {
const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite;
@@ -33243,32 +33243,32 @@ function memberSchemas(service, implementation) {
}
function validateImplementation(service, implementation) {
const contract = service.contract;
const fail = (message) => {
const fail2 = (message) => {
throw invalid("implementation", `${contract.name}: ${message}`);
};
if (implementation === null || typeof implementation !== "object")
fail("factory must return an object");
fail2("factory must return an object");
let members;
if (contract.scope === "remote") {
const variant = implementation.variant;
if (typeof variant !== "string" || contract.description.variants[variant] === void 0)
fail(`unknown variant ${String(variant)}`);
fail2(`unknown variant ${String(variant)}`);
members = contract.description.variants[variant];
for (const key of Object.keys(implementation))
if (key !== "variant" && members[key] === void 0)
fail(`${key} is not in variant ${String(variant)}`);
fail2(`${key} is not in variant ${String(variant)}`);
} else {
members = contract.description.variants[LOCAL_VARIANT] ?? {};
}
for (const [name, description] of Object.entries(members)) {
const value2 = implementation[name];
if (description.kind === "method" && typeof value2 !== "function")
fail(`${name} must be a method`);
fail2(`${name} must be a method`);
if (description.kind === "state" && !isSource(value2) && !isReadableLike(value2)) {
fail(`${name} must be a cell, a derivation or a held object's state member`);
fail2(`${name} must be a cell, a derivation or a held object's state member`);
}
if (description.kind === "opaque" && value2 === void 0)
fail(`${name} is missing`);
fail2(`${name} is missing`);
}
}
var SelfImpl = class {
@@ -35787,9 +35787,9 @@ var noop3 = () => {
function deferred() {
let resolve2 = noop3;
let reject2 = noop3;
const promise = new Promise((done, fail) => {
const promise = new Promise((done, fail2) => {
resolve2 = done;
reject2 = fail;
reject2 = fail2;
});
promise.catch(noop3);
return { promise, resolve: resolve2, reject: reject2 };
@@ -46642,7 +46642,7 @@ async function run(runtime, input, call, tool, args, context) {
throw new Error(`Tool call ${call.id} has settled`);
};
let env;
const api = {
const api2 = {
taskId: runtime.taskId,
conversationId: runtime.conversationId,
callId: call.id,
@@ -46733,7 +46733,7 @@ async function run(runtime, input, call, tool, args, context) {
env = await runtime.env(context);
const startedAt = performance.now();
try {
result = await tool.execute(args, api, context);
result = await tool.execute(args, api2, context);
} finally {
durationMs = Math.round(performance.now() - startedAt);
}
@@ -50743,10 +50743,10 @@ __export(tools_exports, {
});
// node_modules/@earendil-works/pi-durable/dist/tools/env.js
function requireEnv(api) {
if (api.env === void 0)
function requireEnv(api2) {
if (api2.env === void 0)
throw new Error("No execution environment is configured");
return api.env;
return api2.env;
}
// node_modules/@earendil-works/pi-durable/dist/tools/bash.js
@@ -50775,19 +50775,19 @@ function validateTimeout(timeout) {
throw new Error(`Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`);
}
}
async function prepareExecution(command, options, api, context) {
async function prepareExecution(command, options, api2, context) {
const execution = {
command: options?.commandPrefix ? `${options.commandPrefix}
${command}` : command,
cwd: requireEnv(api).cwd,
cwd: requireEnv(api2).cwd,
env: {},
inheritEnv: true
};
await options?.prepare?.(execution, api, context);
await options?.prepare?.(execution, api2, context);
return execution;
}
async function runCommand(commands, execution, timeout, api, context) {
const env = requireEnv(api);
async function runCommand(commands, execution, timeout, api2, context) {
const env = requireEnv(api2);
let result;
for (const command of commands) {
result = await env.exec(command, {
@@ -50795,10 +50795,10 @@ async function runCommand(commands, execution, timeout, api, context) {
env: execution.env,
inheritEnv: execution.inheritEnv,
...timeout === void 0 ? {} : { timeout },
onOutput: (text2, _context, info) => api.output(text2, info.skipped),
onOutput: (text2, _context, info) => api2.output(text2, info.skipped),
spill: { afterBytes: DEFAULT_MAX_BYTES, afterLines: DEFAULT_MAX_LINES },
// An environment may then omit output outside the retained tail and report the omission.
...api.outputWindow === void 0 ? {} : { window: api.outputWindow }
...api2.outputWindow === void 0 ? {} : { window: api2.outputWindow }
}, context);
if (result.ok || result.error.code !== "spawn_error")
break;
@@ -50807,7 +50807,7 @@ async function runCommand(commands, execution, timeout, api, context) {
throw new Error("No command to run");
const spillPath = result.ok ? result.value.spillPath : result.error.spillPath;
if (spillPath !== void 0) {
api.diagnostic({ severity: "info", code: "full_output", message: `Full output: ${spillPath}` });
api2.diagnostic({ severity: "info", code: "full_output", message: `Full output: ${spillPath}` });
}
if (!result.ok) {
if (result.error.code === "aborted" && context.abortSignal?.aborted)
@@ -50819,7 +50819,7 @@ async function runCommand(commands, execution, timeout, api, context) {
throw result.error;
}
const exitCode = result.value.exitCode;
const retained = api.retainedOutput();
const retained = api2.retainedOutput();
const structuredOutput = {
output: retained.text,
truncated: retained.truncated,
@@ -50838,10 +50838,10 @@ function createBashTool(options) {
parameters: bashSchema,
structuredOutputSchema: shellStructuredOutputSchema,
outputLimits: { retain: "tail" },
async execute(args, api, context) {
async execute(args, api2, context) {
validateTimeout(args.timeout);
const execution = await prepareExecution(args.command, options, api, context);
return runCommand([execution.command], execution, args.timeout, api, context);
const execution = await prepareExecution(args.command, options, api2, context);
return runCommand([execution.command], execution, args.timeout, api2, context);
}
});
}
@@ -50855,13 +50855,13 @@ function createPowerShellTool(options) {
parameters: powershellSchema,
structuredOutputSchema: shellStructuredOutputSchema,
outputLimits: { retain: "tail" },
async execute(args, api, context) {
async execute(args, api2, context) {
validateTimeout(args.timeout);
const execution = await prepareExecution(args.command, options, api, context);
const execution = await prepareExecution(args.command, options, api2, context);
const script = `${UTF8_OUTPUT}
${execution.command}`;
const commands = programs.map((program) => [program, ...POWERSHELL_ARGS, script]);
return runCommand(commands, execution, args.timeout, api, context);
return runCommand(commands, execution, args.timeout, api2, context);
}
});
}
@@ -51735,9 +51735,9 @@ function createEditTool() {
description: "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.",
parameters: editSchema,
prepareArguments: prepareEditArguments,
async execute(args, api, context) {
async execute(args, api2, context) {
const { path: path3, edits } = validateEditInput(args);
const env = requireEnv(api);
const env = requireEnv(api2);
const absolutePath = await resolveToolPath(env, path3, context);
return withFileMutationQueue(env, absolutePath, async () => {
if (context.abortSignal?.aborted)
@@ -51993,9 +51993,9 @@ function createReadTool(options = {}) {
description: `Read the contents of a file. Supports text files and images (${formats2}). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
parameters: readSchema,
structuredOutputSchema: readStructuredOutputSchema,
async execute(args, api, context) {
async execute(args, api2, context) {
const { path: path3, offset, limit: limit2 } = args;
const env = requireEnv(api);
const env = requireEnv(api2);
const absolutePath = await resolveReadToolPath(env, path3, context);
const reader = getOrThrow(await env.openBinaryReader(absolutePath, void 0, context));
try {
@@ -52005,7 +52005,7 @@ function createReadTool(options = {}) {
size: before.size,
read: async (position, length) => getOrThrow(await reader.read(position, length, context))
});
const result = mimeType === void 0 ? await readText(reader, path3, offset, limit2, context) : await readImage(reader, before, path3, mimeType, options.images, await imageModel(api, context), context);
const result = mimeType === void 0 ? await readText(reader, path3, offset, limit2, context) : await readImage(reader, before, path3, mimeType, options.images, await imageModel(api2, context), context);
const after = getOrThrow(await reader.info(context));
const unchanged = after.size === before.size && after.mtimeMs === before.mtimeMs;
if (unchanged || mimeType === void 0 && after.size > before.size)
@@ -52060,9 +52060,9 @@ async function readImage(reader, info, path3, mimeType, processor, { vision, lim
const block = { type: "image", data: image.data, mimeType: image.mimeType };
return { output: [block], structuredOutput: block, diagnostics };
}
async function imageModel(api, context) {
const ref = (await api.agent(context)).model;
const model3 = ref === void 0 ? void 0 : api.models.getModel(ref.provider, ref.modelId);
async function imageModel(api2, context) {
const ref = (await api2.agent(context)).model;
const model3 = ref === void 0 ? void 0 : api2.models.getModel(ref.provider, ref.modelId);
const resize = model3?.inputLimits?.images?.resize;
return {
vision: model3?.input.includes("image") ?? true,
@@ -52160,9 +52160,9 @@ function createWriteTool() {
name: "write",
description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
parameters: writeSchema,
async execute(args, api, context) {
async execute(args, api2, context) {
const { path: path3, content } = args;
const env = requireEnv(api);
const env = requireEnv(api2);
const absolutePath = await resolveToolPath(env, path3, context);
return withFileMutationQueue(env, absolutePath, async () => {
if (context.abortSignal?.aborted)
@@ -52188,7 +52188,7 @@ 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)
execute: (args, api2, context) => tool.execute(args, { ...api2, env: api2.env.forTool(api2.callId, tool.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
@@ -53338,13 +53338,24 @@ async function startRequests(harness2, context) {
}
// input.ts
async function serve(harness2, context, afterSubmit) {
async function serve(harness2, context, afterSubmit, api2) {
const pending = /* @__PURE__ */ new Set();
const track2 = (work) => {
const task = work.catch((error) => Deno.core.ops.op_output(`${String(error)}
`)).finally(() => pending.delete(task));
pending.add(task);
};
while (true) {
const input = await Deno.core.ops.op_input();
if (input === null || input.type === "stop") {
await harness2.waitForIdle(context);
await Promise.all(pending);
return;
}
if (input.type === "api") {
track2(api2.handle(input));
continue;
}
const conversation = await harness2.conversation(input.conversation, context);
if (!conversation) throw new Error(`Unknown conversation: ${input.conversation}`);
const submission2 = await conversation.submit({
@@ -53353,9 +53364,10 @@ async function serve(harness2, context, afterSubmit) {
requestId: input.requestId
}, context);
if (afterSubmit) {
const result = await submission2.wait(context);
await afterSubmit();
if (result.status !== "done") throw new Error(`Run ended: ${JSON.stringify(result)}`);
track2(submission2.wait(context).then(async (result) => {
await afterSubmit();
if (result.status !== "done") throw new Error(`Run ended: ${JSON.stringify(result)}`);
}));
}
}
}
@@ -53615,11 +53627,11 @@ function splitStringByTokenSize(text2, minTokenSize, maxTokenSize) {
}
return chunks.length > 0 ? chunks : [""];
}
function cloneMessage(message, api, provider3, modelId) {
function cloneMessage(message, api2, provider3, modelId) {
const cloned = structuredClone(message);
return {
...cloned,
api,
api: api2,
provider: provider3,
model: modelId,
timestamp: cloned.timestamp ?? Date.now(),
@@ -53639,11 +53651,11 @@ function createDeferredMessage(model3, handle) {
timestamp: Date.now()
};
}
function createErrorMessage(error, api, provider3, modelId) {
function createErrorMessage(error, api2, provider3, modelId) {
return {
role: "assistant",
content: [],
api,
api: api2,
provider: provider3,
model: modelId,
usage: DEFAULT_USAGE,
@@ -53752,7 +53764,7 @@ async function streamWithDeltas(stream2, message, minTokenSize, maxTokenSize, to
stream2.end(message);
}
function createFauxCore(options) {
const api = options.api ?? randomId(DEFAULT_API);
const api2 = options.api ?? randomId(DEFAULT_API);
const provider3 = options.provider ?? DEFAULT_PROVIDER;
const minTokenSize = Math.max(1, Math.min(options.tokenSize?.min ?? DEFAULT_MIN_TOKEN_SIZE, options.tokenSize?.max ?? DEFAULT_MAX_TOKEN_SIZE));
const maxTokenSize = Math.max(minTokenSize, options.tokenSize?.max ?? DEFAULT_MAX_TOKEN_SIZE);
@@ -53775,7 +53787,7 @@ function createFauxCore(options) {
const models2 = modelDefinitions.map((definition) => ({
id: definition.id,
name: definition.name ?? definition.id,
api,
api: api2,
provider: provider3,
baseUrl: DEFAULT_BASE_URL,
reasoning: definition.reasoning ?? false,
@@ -53787,7 +53799,7 @@ function createFauxCore(options) {
}));
const resolveResponse = async (step, context, streamOptions, requestModel) => {
const resolved = typeof step === "function" ? await step(context, streamOptions, state2, requestModel) : step;
return withUsageEstimate(cloneMessage(resolved, api, provider3, requestModel.id), context, streamOptions, promptCache);
return withUsageEstimate(cloneMessage(resolved, api2, provider3, requestModel.id), context, streamOptions, promptCache);
};
const stream2 = (requestModel, context, streamOptions) => {
const outer = createAssistantMessageEventStream();
@@ -53797,7 +53809,7 @@ function createFauxCore(options) {
try {
await streamOptions?.onResponse?.({ status: 200, headers: {} }, requestModel);
if (!step) {
let message2 = createErrorMessage(new Error("No more faux responses queued"), api, provider3, requestModel.id);
let message2 = createErrorMessage(new Error("No more faux responses queued"), api2, provider3, requestModel.id);
message2 = withUsageEstimate(message2, context, streamOptions, promptCache);
outer.push({ type: "error", reason: "error", error: message2 });
outer.end(message2);
@@ -53826,7 +53838,7 @@ function createFauxCore(options) {
const message = await resolveResponse(step, context, streamOptions, requestModel);
await streamWithDeltas(outer, message, minTokenSize, maxTokenSize, tokensPerSecond, streamOptions?.signal);
} catch (error) {
const message = createErrorMessage(error, api, provider3, requestModel.id);
const message = createErrorMessage(error, api2, provider3, requestModel.id);
outer.push({ type: "error", reason: "error", error: message });
outer.end(message);
}
@@ -53856,12 +53868,12 @@ function createFauxCore(options) {
try {
entry2.final = await resolveResponse(entry2.step, entry2.context, submissionOptions, entry2.model);
} catch (error) {
entry2.final = createErrorMessage(error, api, provider3, entry2.model.id);
entry2.final = createErrorMessage(error, api2, provider3, entry2.model.id);
}
}
await streamWithDeltas(outer, entry2.final, minTokenSize, maxTokenSize, tokensPerSecond, fetchOptions?.signal);
} catch (error) {
const message = createErrorMessage(error, api, provider3, requestModel.id);
const message = createErrorMessage(error, api2, provider3, requestModel.id);
outer.push({ type: "error", reason: "error", error: message });
outer.end(message);
}
@@ -53882,7 +53894,7 @@ function createFauxCore(options) {
return models2.find((candidate) => candidate.id === requestedModelId);
}
return {
api,
api: api2,
provider: provider3,
models: models2,
stream: stream2,
@@ -54245,14 +54257,14 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
}], close = [async () => {
opened = false;
}];
const handle = (api) => new Proxy({}, { get: (_, key) => {
const handle = (api2) => new Proxy({}, { get: (_, key) => {
if (typeof key === "symbol" || key === "then") return void 0;
const value2 = providers.get(api.name);
if (!value2) throw new Error(`No provider for ${api.name}`);
const value2 = providers.get(api2.name);
if (!value2) throw new Error(`No provider for ${api2.name}`);
const member = value2[key];
return typeof member === "function" ? (...args) => {
const current = providers.get(api.name);
if (!current) throw new Error(`No provider for ${api.name}`);
const current = providers.get(api2.name);
if (!current) throw new Error(`No provider for ${api2.name}`);
return Reflect.apply(current[key], current, args);
} : member;
} });
@@ -54266,11 +54278,11 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
exec: (command, options) => exec(command, { ...options, extension: name }),
git,
chain: chain2,
implement: (api, value2) => {
staged.set(api.name, value2);
implement: (api2, value2) => {
staged.set(api2.name, value2);
},
need: handle,
want: (api) => providers.has(api.name) ? handle(api) : void 0,
want: (api2) => providers.has(api2.name) ? handle(api2) : void 0,
resource: (key, create) => {
const id = `${name}/${key}`;
if (!resources.has(id)) resources.set(id, create());
@@ -54341,6 +54353,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -54406,7 +54419,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state2 }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state2 }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
@@ -54415,6 +54433,166 @@ Last good: ${fallback}` : failed;
} };
}
// harness-api.ts
var Concluded = defineDoc({ kind: "conversations.concluded", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ ids: [] }) });
var Started2 = defineDoc({ kind: "board.started", version: 1, scope: "conversation", history: "latest", fork: "initial", checkpointWhen: () => false, initial: () => ({}) });
var fail = (status, message) => {
throw Object.assign(new Error(message), { status });
};
var integer = (value2, fallback) => {
if (value2 === null && fallback !== void 0) return fallback;
const n = Number(value2);
if (!Number.isSafeInteger(n) || n < 0) fail(400, "Invalid cursor or conversation");
return n;
};
function harnessApi(harness2, models2, extensions2, context) {
const streams = /* @__PURE__ */ new Map();
const log = eventLog(() => harness2).eventLog;
const send = (id, value2) => Deno.core.ops.op_harness_reply(id, value2);
async function entries2(conversation, after) {
const found = [];
let cursor;
do {
const page3 = await conversation.entries({ minEntryId: after + 1, order: "ascending" }, 256, cursor, context);
found.push(...page3.items);
cursor = page3.next;
} while (cursor);
return found;
}
async function follow(input, read2, cursor) {
let active = true, queue2 = Promise.resolve();
const scan2 = () => queue2 = queue2.then(async () => {
if (!active) return;
for (; ; ) {
const page3 = await read2(cursor);
if (!active || !page3.length) return;
for (const entry2 of page3) {
cursor = entry2.id;
send(input.id, entry2);
}
}
});
const observation2 = harness2.observeCommits({ advance: (publication) => {
if (publication.changes.some((change) => change.type === "entry")) void scan2();
}, end: () => {
void stop2();
} });
const stop2 = async () => {
if (!active) return;
active = false;
observation2.detach();
await queue2;
send(input.id, null);
streams.delete(input.id);
};
streams.set(input.id, stop2);
send(input.id, { status: 200, stream: true });
void Deno.core.ops.op_harness_closed(input.id).then(stop2);
await scan2();
}
async function handle(input) {
const url = new URL(input.path, "http://host.sausage");
const path3 = url.pathname.slice("/harness".length);
const data = input.body ?? {};
const reply = (status, body) => {
send(input.id, { status, body });
send(input.id, null);
};
try {
if (input.method === "GET" && path3 === "/conversations") {
const records = await harness2.commit(async (tx) => {
const all = [];
let cursor;
do {
const page3 = await tx.scanConversations({}, 256, cursor);
all.push(...page3.items);
cursor = page3.next;
} while (cursor);
return all;
}, context);
const concluded = new Set((await harness2.snapshot(Concluded, context))?.ids ?? []);
const conversations = await Promise.all(records.map(async (record2) => {
const agent = await harness2.snapshot(AgentDoc, record2.id, context);
const metadata = await harness2.snapshot(Metadata, record2.id, context);
const live = await harness2.snapshot(LiveDoc, record2.id, context);
const started = await harness2.snapshot(Started2, record2.id, context);
return { ...record2, title: metadata?.title ?? "", agent, starter: started?.by ?? null, concluded: concluded.has(record2.id), running: !!live?.run };
}));
reply(200, { conversations });
return;
}
if (input.method === "GET" && path3 === "/events") {
const cursor = integer(url.searchParams.get("after"), 0);
await follow(input, async (after2) => log.recent({ since: after2, order: "ascending", limit: 256 }), cursor);
return;
}
if (input.method === "GET" && path3 === "/extensions") {
reply(200, extensions2.state());
return;
}
if (input.method === "GET" && path3 === "/families") {
let families = {};
try {
families = JSON.parse(await fs.read("/src/sausage/agents/models.json"));
} catch (error) {
if (error.code !== "not_found") throw error;
}
reply(200, { families });
return;
}
if (input.method === "GET" && path3 === "/models") {
reply(200, { models: models2.getAllModels().map(({ id, name, provider: provider3, contextWindow, maxTokens, reasoning, cost }) => ({ id, name, provider: provider3, contextWindow, maxTokens, reasoning, cost })) });
return;
}
const action = /^\/extensions\/([^/]+)\/actions\/([^/]+)$/.exec(path3);
if (input.method === "POST" && action) {
if (data.conversationId !== void 0 && (!Number.isSafeInteger(data.conversationId) || data.conversationId < 1)) fail(400, "Invalid conversation id");
const controller = new AbortController();
void Deno.core.ops.op_harness_closed(input.id).then(() => controller.abort());
reply(200, { result: await extensions2.invoke(decodeURIComponent(action[1]), decodeURIComponent(action[2]), data.args ?? {}, controller.signal, data.conversationId) });
return;
}
const match = /^\/conversations\/(\d+)\/(entries|stream|submit|stop)$/.exec(path3);
if (!match) fail(404, "Unknown harness route");
const conversation = await harness2.conversation(integer(match[1]), context);
if (!conversation) fail(404, "Unknown conversation");
const after = integer(url.searchParams.get("after"), 0);
if (input.method === "GET" && match[2] === "entries") {
reply(200, { entries: await entries2(conversation, after) });
return;
}
if (input.method === "GET" && match[2] === "stream") {
await follow(input, (cursor) => entries2(conversation, cursor), after);
return;
}
if (input.method === "POST" && match[2] === "submit") {
if (typeof data.requestId !== "string" || !data.requestId || typeof data.content !== "string" || !data.content.trim()) fail(400, "Expected content and requestId");
const submission2 = await conversation.submit({ type: "input", content: data.content, requestId: data.requestId }, context);
reply(202, { submissionId: submission2.id });
return;
}
if (input.method === "POST" && match[2] === "stop") {
for (; ; ) {
const own = (await harness2.inspect(context)).tasks.filter(({ record: record2 }) => record2.conversationId === conversation.id && !record2.owner && !record2.background);
if (!own.length) break;
for (const { record: record2 } of own) {
await harness2.abortTask(record2.id, context);
await harness2.waitForTask(record2.id, context);
}
}
reply(200, { ok: true });
return;
}
fail(404, "Unknown harness route");
} catch (error) {
reply(error.status ?? 500, { error: error.message });
}
}
return { handle, async close() {
await Promise.all([...streams.values()].map((stop2) => stop2()));
} };
}
// main.ts
var proxy = Deno.core.ops.op_model_proxy();
if (!proxy?.base_url || !proxy?.api_key) throw new Error("The llm-proxy credential is required");
@@ -54490,6 +54668,7 @@ if (globalThis.consoleEnabled) watch.start((event) => {
});
var stopRequests = await startRequests(harness, BACKGROUND_CONTEXT);
var extensions = await loadExtensions(harness, registry, storage, models, BACKGROUND_CONTEXT, [CodingTools2, CredentialRequests]);
var api = harnessApi(harness, models, extensions, BACKGROUND_CONTEXT);
harness.resume();
await serve(harness, BACKGROUND_CONTEXT, globalThis.consoleEnabled ? async () => {
await harness.waitForIdle(BACKGROUND_CONTEXT);
@@ -54497,7 +54676,8 @@ await serve(harness, BACKGROUND_CONTEXT, globalThis.consoleEnabled ? async () =>
render(final.value);
await final.stop();
Deno.core.ops.op_output("\n");
} : void 0);
} : void 0, api);
await api.close();
await extensions.close();
await stopRequests();
await watch.stop();
+7 -1
View File
@@ -31602,6 +31602,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -31667,7 +31668,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
+7 -1
View File
@@ -30662,6 +30662,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -30727,7 +30728,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
+7 -1
View File
@@ -31602,6 +31602,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -31667,7 +31668,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
+7 -1
View File
@@ -31602,6 +31602,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -31667,7 +31668,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
+7 -1
View File
@@ -146,6 +146,7 @@ export async function loadExtensions(harness:any, registry:any, storage:any, mod
modules.sort((a,b)=>(a.module.order??0)-(b.module.order??0)||a.name.localeCompare(b.name));
for(const {name,module} of modules) {
const facet = scope(name,staged); next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key:string)=>typeof module.actions?.[key]==='function').map((key:string)=>[key,module.actions[key]]));
facet.extension = typeof module.default==='function'?await module.default(facet.host):module.default;
if(!facet.extension || facet.extension.name!==name)throw new Error(`Extension name must match ${name}`);
}
@@ -179,5 +180,10 @@ export async function loadExtensions(harness:any, registry:any, storage:any, mod
const rid = ops.op_watch([`${repo}/.git/refs/heads/main`,`${repo}/.git/packed-refs`,`${repo}/settings.json`]);
await enqueue();
const pending = (async()=> {while(active){await ops.op_watch_next(rid);if(active)await enqueue();}})();
return {reload:enqueue, state:()=>({...state}), close:async()=>{active=false;Deno.core.close(rid);await pending;await queue;await dispose(facets);}};
return {reload:enqueue, state:()=>({...state}), async invoke(name:string,key:string,args:any,signal:AbortSignal,conversationId?:number) {
const facet = facets.find(f=>f.extension.name===name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error('Unknown user action'),{status:404});
return action(args,{host:facet.host,signal,conversationId,source:'ui'});
}, close:async()=>{active=false;Deno.core.close(rid);await pending;await queue;await dispose(facets);}};
}
+16 -4
View File
@@ -20477,13 +20477,24 @@ async function openStorage(path) {
}
// input.ts
async function serve(harness2, context, afterSubmit) {
async function serve(harness2, context, afterSubmit, api) {
const pending = /* @__PURE__ */ new Set();
const track2 = (work) => {
const task = work.catch((error) => Deno.core.ops.op_output(`${String(error)}
`)).finally(() => pending.delete(task));
pending.add(task);
};
while (true) {
const input = await Deno.core.ops.op_input();
if (input === null || input.type === "stop") {
await harness2.waitForIdle(context);
await Promise.all(pending);
return;
}
if (input.type === "api") {
track2(api.handle(input));
continue;
}
const conversation = await harness2.conversation(input.conversation, context);
if (!conversation) throw new Error(`Unknown conversation: ${input.conversation}`);
const submission2 = await conversation.submit({
@@ -20492,9 +20503,10 @@ async function serve(harness2, context, afterSubmit) {
requestId: input.requestId
}, context);
if (afterSubmit) {
const result = await submission2.wait(context);
await afterSubmit();
if (result.status !== "done") throw new Error(`Run ended: ${JSON.stringify(result)}`);
track2(submission2.wait(context).then(async (result) => {
await afterSubmit();
if (result.status !== "done") throw new Error(`Run ended: ${JSON.stringify(result)}`);
}));
}
}
}
+1 -1
View File
@@ -33,5 +33,5 @@ export async function fixture(clock: any = {}) {
const ultimate=async()=>{const c=await harness.createConversation({ownership:{kind:'ownerless'}},context);await c.configure({model:{provider:'faux',modelId:'faux-1'}},context);await harness.commit(async(tx:any)=>{(await tx.doc(Standing,root.id)).ultimate=c.id},context);return c.id}
const ask=async(id:number,args:any)=>{await type(id,`SEND ${JSON.stringify({to:'user',...args})}`);await settled();const result=(await entries(id)).findLast((e:any)=>e.kind==='pi.tool-result')?.model?.[0];return {error:result?.isError?result.content.map((p:any)=>p.text).join(''):undefined,message:(await log()).messages.findLast((m:any)=>m.from===id&&m.to==='user')}}
const action=(conversationId?:number)=>({host:globalThis.testHost,conversationId,signal:new AbortController().signal})
return {base,faux,host:globalThis.testHost,opened:{harness,root,close:async()=>{await loader.close();await harness.close(context);await storage.close()}},ultimate,type,ask,answer:(args:any)=>question.actions.answer(args,action()),dismiss:(messageId:number)=>question.actions.dismiss({messageId},action()),log,entries,observed,settled,hear:(fn:any)=>heard=fn}
return {base,faux,loader,host:globalThis.testHost,opened:{harness,root,close:async()=>{await loader.close();await harness.close(context);await storage.close()}},ultimate,type,ask,answer:(args:any)=>question.actions.answer(args,action()),dismiss:(messageId:number)=>question.actions.dismiss({messageId},action()),log,entries,observed,settled,hear:(fn:any)=>heard=fn}
}
+8 -2
View File
@@ -32828,6 +32828,7 @@ async function loadExtensions(harness, registry, storage, models, context, built
for (const { name, module } of modules) {
const facet = scope(name, staged);
next2.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -32893,7 +32894,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
@@ -33053,7 +33059,7 @@ async function fixture(clock = {}) {
return { error: result?.isError ? result.content.map((p) => p.text).join("") : void 0, message: (await log()).messages.findLast((m) => m.from === id && m.to === "user") };
};
const action = (conversationId) => ({ host: globalThis.testHost, conversationId, signal: new AbortController().signal });
return { base, faux, host: globalThis.testHost, opened: { harness, root, close: async () => {
return { base, faux, loader, host: globalThis.testHost, opened: { harness, root, close: async () => {
await loader.close();
await harness.close(BACKGROUND_CONTEXT);
await storage.close();
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
// World HTTP clients exercise the live chat API, including disconnects.
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
import { fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
import { fixture } from './goal-questions-fixture';
import { assert, until } from './goal-questions-test-support';
import { harnessApi } from './harness-api';
import { serve } from './input';
const f=await fixture();
const harness=f.opened.harness;
const api=harnessApi(harness,f.host.models(),f.loader,context);
const serving=serve(harness,context,undefined,api);
const curl=async(path:string,body?:any)=>{
const result=await f.host.exec(['/bin/curl.wasm',...(body===undefined?[]:['--json',JSON.stringify(body)]),`http://host.sausage/harness${path}`],{cwd:'/'});
assert(result.code===0,`curl failed: ${result.stderr}`);
return JSON.parse(result.stdout);
};
const id=await f.ultimate();
const question=await f.ask(id,{text:'Choose.',questions:[{text:'Name',kind:'text',ref:'1'}]});
assert(question.message,'Question fixture');
const listed=await curl('/conversations');
assert(listed.conversations.some((c:any)=>c.id===id && c.agent && !c.running),'Conversation list');
const initial=await curl(`/conversations/${id}/entries`);
const after=initial.entries.at(-1).id;
const answer=await curl('/extensions/questions/actions/answer',{args:{messageId:question.message.id,answers:[{question:'Name',answer:'API choice'}]}});
assert(!answer.error,JSON.stringify(answer));
await f.settled();
assert(JSON.stringify(await f.entries(id)).includes('API choice'),'Question answer reaches asker');
assert((await curl('/models')).models.length>0,'Models');
assert((await curl('/families')).families.default,'Families');
assert((await curl('/extensions')).good,'Extension status');
assert(Array.isArray((await curl('/credential-requests')).requests),'Credential requests');
assert((await curl('/unknown')).error,'Unknown route');
assert((await curl('/extensions/questions/actions/answerText',{})).error,'Only user actions are callable');
assert((await curl(`/conversations/${id}/entries?after=bad`)).error,'Invalid cursor');
assert((await curl(`/conversations/${id}/submit`,{content:'bad'})).error,'Missing request id');
const controller=new AbortController();let received='';
const stream=f.host.exec(['/bin/harness-api-client.wasm',`/harness/conversations/${id}/stream?after=${after}`],{cwd:'/',signal:controller.signal,onOutput:(text:string)=>{received+=text;}}).catch(()=>{});
let release!:()=>void;
const held=new Promise<void>(resolve=>release=resolve);
f.hear(async(text:string)=>{if(text==='HTTP input')await held;});
const submitted=await curl(`/conversations/${id}/submit`,{content:'HTTP input',requestId:'http-input'});
assert(submitted.submissionId,'Submission acknowledgement');
const duplicate=await curl(`/conversations/${id}/submit`,{content:'HTTP input',requestId:'http-input'});
assert(duplicate.submissionId===submitted.submissionId,'Idempotent submission');
await until(()=>received.includes('HTTP input'));
const during=await curl('/conversations');
assert(during.conversations.find((c:any)=>c.id===id).running,'Stream works during a turn');
release();await f.settled();
await until(()=>received.includes('pi.assistant'));
controller.abort();await stream;
const updated=await curl(`/conversations/${id}/entries?after=${after}`);
assert(updated.entries.every((e:any)=>e.id>after),'Exclusive cursor');
assert(updated.entries.filter((e:any)=>e.kind==='pi.user'&&JSON.stringify(e).includes('HTTP input')).length===1,'No second input');
let stopping=false;
f.faux.setResponses([async (_transcript:any,options:any)=>{
stopping=true;
await new Promise(resolve=>options.signal.addEventListener('abort',resolve,{once:true}));
return fauxAssistantMessage('Interrupted');
}]);
await curl(`/conversations/${id}/submit`,{content:'Stop this turn',requestId:'stop-input'});
await until(()=>stopping);
assert((await curl(`/conversations/${id}/stop`,{})).ok,'Stop');
await f.settled();
assert(!(await curl('/conversations')).conversations.find((c:any)=>c.id===id).running,'Stop ends active turn');
const eventsController=new AbortController();let events='';
const eventsStream=f.host.exec(['/bin/harness-api-client.wasm','/harness/events?after=0'],{cwd:'/',signal:eventsController.signal,onOutput:(text:string)=>{events+=text;}}).catch(()=>{});
const respond=(transcript:any):any=>{
f.faux.appendResponses([respond]);
const last=transcript.messages.at(-1);
const text=typeof last.content==='string'?last.content:JSON.stringify(last.content);
return last.role==='user'&&text.includes('DELEGATE')?fauxAssistantMessage([fauxToolCall('subagent',{action:'start',agent:'coordinator',task:'facts'})],{stopReason:'toolUse'}):fauxAssistantMessage('Child answer');
};
f.faux.setResponses([respond]);
await f.type(f.opened.root.id,'DELEGATE');await f.settled();
await until(()=>events.includes('subagents.started'));
eventsController.abort();await eventsStream;
let shutdownData='';
const shutdownStream=f.host.exec(['/bin/harness-api-client.wasm','/harness/events?after=0'],{cwd:'/',onOutput:(text:string)=>{shutdownData+=text;}});
await until(()=>shutdownData.includes('subagents.started'));
await f.host.fs.write('/harness-test-done','ok');
await serving;
await api.close();
assert((await shutdownStream).code===0,'Harness shutdown ends streams');
await f.opened.close();
+110
View File
@@ -0,0 +1,110 @@
// The world chat API runs in the same harness as turns and extension actions.
import { AgentDoc, LiveDoc, defineDoc } from '@earendil-works/pi-durable';
import { Metadata, fs } from './extensions';
import { eventLog } from './events';
const Concluded = defineDoc({kind:'conversations.concluded',version:1,scope:'session',checkpointWhen:()=>false,initial:()=>({ids:[]})});
const Started = defineDoc({kind:'board.started',version:1,scope:'conversation',history:'latest',fork:'initial',checkpointWhen:()=>false,initial:()=>({})});
const fail = (status:number,message:string):never => { throw Object.assign(new Error(message),{status}); };
const integer = (value:any, fallback?:number) => {
if (value === null && fallback !== undefined) return fallback;
const n=Number(value);
if (!Number.isSafeInteger(n) || n<0) fail(400,'Invalid cursor or conversation');
return n;
};
export function harnessApi(harness:any, models:any, extensions:any, context:any) {
const streams = new Map<string,()=>Promise<void>>();
const log = eventLog(()=>harness).eventLog;
const send = (id:string,value:any) => Deno.core.ops.op_harness_reply(id,value);
async function entries(conversation:any,after:number) {
const found:any[]=[];
let cursor:any;
do {
const page=await conversation.entries({minEntryId:after+1,order:'ascending'},256,cursor,context);
found.push(...page.items); cursor=page.next;
} while(cursor);
return found;
}
async function follow(input:any, read:(after:number)=>Promise<any[]>, cursor:number) {
// Observe before the snapshot. Serialize scans to close the snapshot/subscription race.
let active=true, queue=Promise.resolve();
const scan=()=>queue=queue.then(async()=>{
if (!active) return;
for (;;) {
const page=await read(cursor);
if(!active || !page.length) return;
for (const entry of page) {
cursor=entry.id; send(input.id,entry);
}
}
});
const observation=harness.observeCommits({advance:(publication:any)=>{if(publication.changes.some((change:any)=>change.type==='entry'))void scan();},end:()=>{void stop();}});
const stop=async()=>{if(!active)return;active=false;observation.detach();await queue;send(input.id,null);streams.delete(input.id);};
streams.set(input.id,stop);
send(input.id,{status:200,stream:true});
void Deno.core.ops.op_harness_closed(input.id).then(stop);
await scan();
}
async function handle(input:any) {
const url=new URL(input.path,'http://host.sausage');
const path=url.pathname.slice('/harness'.length);
const data=input.body??{};
const reply=(status:number,body:any)=>{send(input.id,{status,body});send(input.id,null);};
try {
if(input.method==='GET' && path==='/conversations') {
const records=await harness.commit(async(tx:any)=>{
const all:any[]=[];let cursor:any;
do {const page=await tx.scanConversations({},256,cursor);all.push(...page.items);cursor=page.next;}while(cursor);
return all;
},context);
const concluded=new Set((await harness.snapshot(Concluded,context))?.ids??[]);
const conversations=await Promise.all(records.map(async(record:any)=>{
const agent=await harness.snapshot(AgentDoc,record.id,context);
const metadata=await harness.snapshot(Metadata,record.id,context);
const live=await harness.snapshot(LiveDoc,record.id,context);
const started:any=await harness.snapshot(Started,record.id,context);
return {...record,title:metadata?.title??'',agent,starter:started?.by??null,concluded:concluded.has(record.id),running:!!live?.run};
}));
reply(200,{conversations});return;
}
if(input.method==='GET' && path==='/events') {
const cursor=integer(url.searchParams.get('after'),0);
await follow(input,async(after)=>log.recent({since:after,order:'ascending',limit:256}),cursor);return;
}
if(input.method==='GET' && path==='/extensions') {reply(200,extensions.state());return;}
if(input.method==='GET' && path==='/families') {
let families={};try{families=JSON.parse(await fs.read('/src/sausage/agents/models.json'));}catch(error){if((error as any).code!=='not_found')throw error;}
reply(200,{families});return;
}
if(input.method==='GET' && path==='/models') {reply(200,{models:models.getAllModels().map(({id,name,provider,contextWindow,maxTokens,reasoning,cost}:any)=>({id,name,provider,contextWindow,maxTokens,reasoning,cost}))});return;}
const action=/^\/extensions\/([^/]+)\/actions\/([^/]+)$/.exec(path);
if(input.method==='POST' && action) {
if(data.conversationId!==undefined && (!Number.isSafeInteger(data.conversationId)||data.conversationId<1)) fail(400,'Invalid conversation id');
const controller=new AbortController();
void Deno.core.ops.op_harness_closed(input.id).then(()=>controller.abort());
reply(200,{result:await extensions.invoke(decodeURIComponent(action[1]),decodeURIComponent(action[2]),data.args??{},controller.signal,data.conversationId)});return;
}
const match=/^\/conversations\/(\d+)\/(entries|stream|submit|stop)$/.exec(path);
if(!match) fail(404,'Unknown harness route');
const conversation=await harness.conversation(integer(match![1]),context);
if(!conversation) fail(404,'Unknown conversation');
const after=integer(url.searchParams.get('after'),0);
if(input.method==='GET' && match![2]==='entries') {reply(200,{entries:await entries(conversation,after)});return;}
if(input.method==='GET' && match![2]==='stream') {await follow(input,(cursor)=>entries(conversation,cursor),after);return;}
if(input.method==='POST' && match![2]==='submit') {
if(typeof data.requestId!=='string'||!data.requestId||typeof data.content!=='string'||!data.content.trim()) fail(400,'Expected content and requestId');
const submission=await conversation.submit({type:'input',content:data.content,requestId:data.requestId},context);
reply(202,{submissionId:submission.id});return;
}
if(input.method==='POST' && match![2]==='stop') {
for (;;) {
const own=(await harness.inspect(context)).tasks.filter(({record}:any)=>record.conversationId===conversation.id&&!record.owner&&!record.background);
if(!own.length) break;
for(const {record} of own) {await harness.abortTask(record.id,context);await harness.waitForTask(record.id,context);}
}
reply(200,{ok:true});return;
}
fail(404,'Unknown harness route');
} catch(error) {reply((error as any).status??500,{error:(error as Error).message});}
}
return {handle,async close(){await Promise.all([...streams.values()].map(stop=>stop()));}};
}
+15 -4
View File
@@ -1,20 +1,31 @@
// Channel submissions target any conversation. EOF and Stop close the harness cleanly.
export async function serve(harness: any, context: any, afterSubmit?: () => Promise<void>) {
export async function serve(harness: any, context: any, afterSubmit?: () => Promise<void>, api?: any) {
const pending = new Set<Promise<void>>();
const track = (work: Promise<void>) => {
const task = work.catch(error => Deno.core.ops.op_output(`${String(error)}\n`)).finally(() => pending.delete(task));
pending.add(task);
};
while (true) {
const input = await Deno.core.ops.op_input();
if (input === null || input.type === 'stop') {
await harness.waitForIdle(context);
await Promise.all(pending);
return;
}
if (input.type === 'api') {
track(api.handle(input));
continue;
}
const conversation = await harness.conversation(input.conversation, context);
if (!conversation) throw new Error(`Unknown conversation: ${input.conversation}`);
const submission = await conversation.submit({
type: 'input', content: input.content, requestId: input.requestId,
}, context);
if (afterSubmit) {
const result = await submission.wait(context);
await afterSubmit();
if (result.status !== 'done') throw new Error(`Run ended: ${JSON.stringify(result)}`);
track(submission.wait(context).then(async (result: any) => {
await afterSubmit();
if (result.status !== 'done') throw new Error(`Run ended: ${JSON.stringify(result)}`);
}));
}
}
}
+4 -1
View File
@@ -8,6 +8,7 @@ import { openStorage } from './sqlite';
import { environment, conversationChains } from './environment';
import { CredentialRequests, startRequests } from './credentials';
import { serve } from './input';
import { harnessApi } from './harness-api';
import { loadExtensions } from './extensions';
const proxy = Deno.core.ops.op_model_proxy();
if (!proxy?.base_url || !proxy?.api_key) throw new Error('The llm-proxy credential is required');
@@ -67,6 +68,7 @@ for (const entry of watch.value.entries) {
if (globalThis.consoleEnabled) watch.start((event: any) => { if (event.kind === 'value') render(event.value); });
const stopRequests = await startRequests(harness, context);
const extensions = await loadExtensions(harness, registry, storage, models, context, [CodingTools, CredentialRequests]);
const api = harnessApi(harness, models, extensions, context);
harness.resume();
await serve(harness, context, globalThis.consoleEnabled ? async () => {
await harness.waitForIdle(context);
@@ -74,7 +76,8 @@ await serve(harness, context, globalThis.consoleEnabled ? async () => {
render(final.value);
await final.stop();
Deno.core.ops.op_output('\n');
} : undefined);
} : undefined, api);
await api.close();
await extensions.close();
await stopRequests();
await watch.stop();
+7 -1
View File
@@ -30662,6 +30662,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -30727,7 +30728,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
+7 -1
View File
@@ -30662,6 +30662,7 @@ async function loadExtensions(harness, registry, storage, models, context, built
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -30727,7 +30728,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
+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 && 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 && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js && esbuild watchdog-test.ts --bundle --format=esm --platform=browser --outfile=watchdog-test-bundle.js && esbuild workspaces-test.ts --bundle --format=esm --platform=browser --outfile=workspaces-test-bundle.js && esbuild goal-questions-test.ts --bundle --format=esm --platform=browser --outfile=goal-questions-test-bundle.js && esbuild dream-test.ts --bundle --format=esm --platform=browser --outfile=dream-test-bundle.js && esbuild p4-test.ts --bundle --format=esm --platform=browser --outfile=p4-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 && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js && esbuild watchdog-test.ts --bundle --format=esm --platform=browser --outfile=watchdog-test-bundle.js && esbuild workspaces-test.ts --bundle --format=esm --platform=browser --outfile=workspaces-test-bundle.js && esbuild goal-questions-test.ts --bundle --format=esm --platform=browser --outfile=goal-questions-test-bundle.js && esbuild dream-test.ts --bundle --format=esm --platform=browser --outfile=dream-test-bundle.js && esbuild p4-test.ts --bundle --format=esm --platform=browser --outfile=p4-test-bundle.js && esbuild harness-api-test.ts --bundle --format=esm --platform=browser --outfile=harness-api-test-bundle.js" },
"dependencies": {
"@earendil-works/pi-durable": "1.5.0",
"@earendil-works/pi-ai": "1.5.0",
+7 -1
View File
@@ -31602,6 +31602,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -31667,7 +31668,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
+7 -1
View File
@@ -30662,6 +30662,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -30727,7 +30728,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
+7 -1
View File
@@ -30662,6 +30662,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -30727,7 +30728,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
+7 -1
View File
@@ -30662,6 +30662,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -30727,7 +30728,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
+36 -2
View File
@@ -4,8 +4,8 @@
The `wasi:http` hook supplies the caller from the process table.
It must not accept caller identity from headers, environment variables, or JSON.
Reads are open to every caller.
JSON routes return 200 on success.
Errors use `{"error":"kind","message":"kind"}` with static, secret-free text.
Credential JSON routes return 200 on success.
Credential errors use `{"error":"kind","message":"kind"}` with static, secret-free text.
Missing routes return 404. Invalid parameters return 400. Authorization failures return 403.
Conflicts return 409. The filing budget returns 429.
@@ -65,6 +65,40 @@ Explicit requests, automatic SSH requests, and new ceremonies share a per-proces
The budget permits 32 attempts per 60-second window.
Reads and granted SSH operations do not spend that budget.
## Harness routes in the world
The WASI transport dispatches buffered and streaming responses through one route table.
Harness routes use `http://host.sausage/harness/` and the console's input channel.
The host listener does not expose these routes.
The V8 harness answers queries and invokes actions without a second storage reader.
- `GET /harness/conversations`: `{conversations:[...]}` with records, titles, agents, starters, concluded state, and running state.
Records retain their owner and parent references.
- `GET /harness/conversations/{id}/entries?after=0`: `{entries:[...]}` in ascending entry order.
- `GET /harness/conversations/{id}/stream?after=0`: newline-delimited entry records, then new entries as commits arrive.
- `POST /harness/conversations/{id}/submit`: `{content:"text",requestId:"stable-id"}`.
The response is 202 with `{submissionId:number}`.
Duplicate request IDs use pi-durable's existing idempotent admission.
- `POST /harness/conversations/{id}/stop`: abort the conversation's active turns, including their owned work.
The response is `{ok:true}`. Queued inputs remain.
- `GET /harness/events?after=0`: newline-delimited T43 event records in ascending event order.
- `POST /harness/extensions/{name}/actions/{action}`: `{args:{...},conversationId?:number}`.
Only names in the extension's `userActions` export are callable.
The response is `{result:...}`. Disconnect aborts the action's signal.
- `GET /harness/models`: `{models:[...]}` with public catalog fields, without provider authentication data.
- `GET /harness/families`: `{families:{...}}` from the world's model definitions.
- `GET /harness/extensions`: the loaded extension status, also visible through `/proc/extensions`.
- `GET /harness/credential-requests`: `{requests:[...]}` with pending conversation requests and host approval links.
Approval stays on the host page.
The `after` cursor is an exclusive entry ID. Streams replay retained records before live records.
Commit observation starts before replay, so a concurrent commit cannot create a gap.
Clients resume from the last record ID after a disconnect.
Disconnect closes the observation. Harness shutdown closes all streams.
Responses use JSON, except streams use `application/x-ndjson`.
Invalid inputs return 400. Missing routes, conversations, and actions return 404.
An unavailable harness returns 503. Action failures return 500 with the action's error message.
## Decisions
- `POST /cred/requests/{id}/challenge`: `{"decision":"approve","overrides":{...}}`.
+99
View File
@@ -0,0 +1,99 @@
//! Route harness requests into its V8 inbox and preserve incremental NDJSON replies.
use bytes::Bytes;
use http::{Request, Response};
use http_body_util::{BodyExt, Full};
use serde_json::{Value, json};
use std::{
pin::Pin,
task::{Context, Poll},
};
use tokio::sync::mpsc;
struct Body(mpsc::UnboundedReceiver<Value>);
impl http_body::Body for Body {
type Data = Bytes;
type Error = wasmtime_wasi_http::Error;
fn poll_frame(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<http_body::Frame<Bytes>, Self::Error>>> {
self.0.poll_recv(cx).map(|value| {
value.map(|value| {
let mut bytes = serde_json::to_vec(&value).expect("harness JSON");
bytes.push(b'\n');
Ok(http_body::Frame::data(Bytes::from(bytes)))
})
})
}
}
fn json_response(status: u16, value: Value) -> Response<wasmtime_wasi_http::WasiBody> {
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(
Full::new(Bytes::from(value.to_string()))
.map_err(|never| match never {})
.boxed_unsync(),
)
.unwrap()
}
pub(super) async fn handle(
api: &super::Api,
caller: crate::cred::Caller,
mut request: Request<Bytes>,
) -> Response<wasmtime_wasi_http::WasiBody> {
if request.method() == http::Method::GET
&& request.uri().path() == "/harness/credential-requests"
{
*request.uri_mut() = "/cred/requests?state=pending".parse().unwrap();
let response = super::handle(api, caller, request).await;
let mut value: Value = serde_json::from_slice(response.body()).expect("credential JSON");
if let Some(requests) = value.as_array_mut() {
for request in requests {
request["approvalUrl"] = json!(format!(
"{}/approve/{}",
api.approval_origin,
request["id"].as_str().unwrap()
));
}
value = json!({"requests":value});
}
return json_response(response.status().as_u16(), value);
}
let sender = api.harness.lock().unwrap().clone();
let Some(sender) = sender else {
return json_response(503, json!({"error":"harness_unavailable"}));
};
let body = if request.body().is_empty() {
Value::Null
} else {
match serde_json::from_slice(request.body()) {
Ok(body) => body,
Err(_) => return json_response(400, json!({"error":"invalid_json"})),
}
};
let (reply, mut receiver) = mpsc::unbounded_channel();
let message = crate::harness::input::Input::Api {
id: uuid::Uuid::new_v4().to_string(),
method: request.method().to_string(),
path: request.uri().path_and_query().unwrap().to_string(),
body,
reply,
};
if sender.send(message).is_err() {
return json_response(503, json!({"error":"harness_unavailable"}));
}
let Some(first) = receiver.recv().await else {
return json_response(503, json!({"error":"harness_stopped"}));
};
let status = first["status"].as_u64().unwrap_or(500) as u16;
if first["stream"] == true {
Response::builder()
.status(status)
.header("content-type", "application/x-ndjson")
.body(Body(receiver).boxed_unsync())
.unwrap()
} else {
json_response(status, first["body"].clone())
}
}
+45 -3
View File
@@ -2,7 +2,39 @@
//! Host-owned pages use passkeys and typed proofs for every authorization ceremony.
mod auth;
mod cargo;
pub(crate) use cargo::stream;
mod harness;
/// Dispatch body-producing routes before the buffered credential API.
pub(crate) async fn dispatch(
api: &Api,
caller: Caller,
request: Request<Bytes>,
) -> Response<wasmtime_wasi_http::WasiBody> {
use http_body_util::{BodyExt, Full};
enum Handler {
Cargo,
Harness,
}
let routes = [("/cargo", Handler::Cargo), ("/harness/", Handler::Harness)];
for (path, handler) in routes {
if (path.ends_with('/') && request.uri().path().starts_with(path))
|| request.uri().path() == path
{
match handler {
Handler::Cargo if request.method() == http::Method::POST => {
return cargo::stream(api, request);
}
Handler::Harness => return harness::handle(api, caller, request).await,
_ => break,
}
}
}
handle(api, caller, request).await.map(|body| {
Full::new(body)
.map_err(|never| match never {})
.boxed_unsync()
})
}
mod pages;
mod server;
mod ssh;
@@ -22,6 +54,8 @@ use std::{
pub struct Api {
pub cred: Arc<Cred>,
harness: Mutex<Option<crate::harness::input::Sender>>,
approval_origin: String,
pub(crate) builder: std::sync::OnceLock<Arc<crate::builder::Builder>>,
script: cred::quickjs::QuickJs,
pub passkeys: Mutex<Passkeys<rusqlite::Connection>>,
@@ -38,11 +72,13 @@ impl Api {
origin: &str,
) -> anyhow::Result<Self> {
let db = rusqlite::Connection::open(host_dir.join("cred.sqlite"))?;
Ok(Self::new(
let mut api = Self::new(
cred,
Passkeys::new(db, rp_id, origin)?,
EntryKey::open(&host_dir.join("entry.key"))?,
))
);
api.approval_origin = origin.trim_end_matches('/').to_owned();
Ok(api)
}
/// Passkeys owns the RP configuration. Its database must remain host-only.
pub fn new(
@@ -52,6 +88,8 @@ impl Api {
) -> Self {
Self {
cred: cred.into(),
harness: Mutex::new(None),
approval_origin: String::new(),
builder: Default::default(),
script: cred::quickjs::QuickJs::default(),
passkeys: Mutex::new(passkeys),
@@ -60,6 +98,10 @@ impl Api {
journal: Mutex::new(None),
}
}
/// Attach the same inbox that console submissions use.
pub fn attach_harness(&self, sender: crate::harness::input::Sender) {
*self.harness.lock().unwrap() = Some(sender);
}
pub(crate) fn attach_journal(&self, journal: Option<Arc<sausage_vfs::journal::Journal>>) {
*self.journal.lock().unwrap() = journal;
}
+9 -1
View File
@@ -120,6 +120,8 @@ pub async fn run_with_userland(
crate::process::default_script(),
Some(api.clone()),
)?;
let (sender, receiver) = crate::harness::input::channel();
api.attach_harness(sender.clone());
let listener = Listener::start_with_api(api, options)?;
runtime
.network_policy()
@@ -129,7 +131,13 @@ pub async fn run_with_userland(
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(crate::harness::run_console(world, Arc::new(runtime), cred))
.block_on(crate::harness::run_console_with_input(
world,
Arc::new(runtime),
cred,
sender,
receiver,
))
})
.await
.map_err(anyhow::Error::from)
+42
View File
@@ -0,0 +1,42 @@
//! HTTP requests share the harness inbox. Replies never leave the V8 thread as JS values.
use deno_core::{OpState, op2};
use serde_json::Value;
use std::{cell::RefCell, collections::HashMap, rc::Rc};
use tokio::sync::mpsc::UnboundedSender;
pub(super) struct Reply {
pub sender: UnboundedSender<Value>,
pub finished: std::sync::Arc<tokio::sync::Notify>,
}
pub(super) type Replies = HashMap<String, Reply>;
#[op2]
pub fn op_harness_reply(
state: &mut OpState,
#[string] id: String,
#[serde] value: serde_json::Value,
) {
let replies = state.borrow_mut::<Replies>();
if value.is_null() {
if let Some(reply) = replies.remove(&id) {
reply.finished.notify_one();
}
} else if let Some(reply) = replies.get(&id) {
let _ = reply.sender.send(value);
}
}
#[op2]
pub async fn op_harness_closed(state: Rc<RefCell<OpState>>, #[string] id: String) {
let reply = state
.borrow()
.borrow::<Replies>()
.get(&id)
.map(|reply| (reply.sender.clone(), reply.finished.clone()));
if let Some((sender, finished)) = reply {
tokio::select! {
_ = sender.closed() => {},
_ = finished.notified() => {},
}
}
state.borrow_mut().borrow_mut::<Replies>().remove(&id);
}
+22 -2
View File
@@ -15,6 +15,14 @@ pub enum Input {
#[serde(rename = "requestId")]
request_id: String,
},
Api {
id: String,
method: String,
path: String,
body: Value,
#[serde(skip)]
reply: mpsc::UnboundedSender<Value>,
},
Stop,
}
@@ -31,8 +39,20 @@ pub(super) struct Inbox(pub tokio::sync::Mutex<Receiver>);
#[serde]
pub async fn op_input(state: Rc<RefCell<OpState>>) -> Result<serde_json::Value, JsErrorBox> {
let inbox = state.borrow().borrow::<Rc<Inbox>>().clone();
serde_json::to_value(inbox.0.lock().await.recv().await)
.map_err(|error| JsErrorBox::generic(error.to_string()))
let input = inbox.0.lock().await.recv().await;
if let Some(Input::Api { id, reply, .. }) = &input {
state
.borrow_mut()
.borrow_mut::<super::http::Replies>()
.insert(
id.clone(),
super::http::Reply {
sender: reply.clone(),
finished: Default::default(),
},
);
}
serde_json::to_value(input).map_err(|error| JsErrorBox::generic(error.to_string()))
}
pub(super) fn console(sender: Sender) {
+18
View File
@@ -1,6 +1,7 @@
//! Durable V8 harness, host-side SQLite storage, and world tool operations.
mod credentials;
mod environment;
mod http;
pub mod input;
pub(crate) mod loader;
use crate::{exec::Exec, world::World};
@@ -88,6 +89,8 @@ deno_core::extension!(
environment::op_exec,
environment::op_exec_next,
input::op_input,
http::op_harness_reply,
http::op_harness_closed,
environment::op_watch,
environment::op_watch_next,
loader::op_modules,
@@ -181,6 +184,10 @@ pub async fn run_module_with_input(
..Default::default()
});
runtime.op_state().borrow_mut().put(loader);
runtime
.op_state()
.borrow_mut()
.put(http::Replies::default());
runtime
.op_state()
.borrow_mut()
@@ -252,6 +259,17 @@ pub async fn run_console(
cred: Arc<crate::cred::store::Cred>,
) -> anyhow::Result<()> {
let (sender, receiver) = input::channel();
run_console_with_input(world, exec, cred, sender, receiver).await
}
/// Run the console with the inbox shared by the host HTTP API.
pub async fn run_console_with_input(
world: World,
exec: Arc<dyn Exec>,
cred: Arc<crate::cred::store::Cred>,
sender: input::Sender,
receiver: input::Receiver,
) -> anyhow::Result<()> {
input::console(sender.clone());
let signals = tokio::spawn(input::stop_signal(sender)?);
let source = concat!(
+4 -7
View File
@@ -46,13 +46,10 @@ impl WasiHttpHooks for Hooks {
{
let bytes = body.collect().await?.to_bytes();
let request = Request::from_parts(parts, bytes);
if request.method() == http::Method::POST && request.uri().path() == "/cargo" {
return Ok((
crate::api::stream(&router, request),
Box::new(async { Ok(()) }) as Io,
));
}
return Ok(response(crate::api::handle(&router, caller, request).await));
return Ok((
crate::api::dispatch(&router, caller, request).await,
Box::new(async { Ok(()) }) as Io,
));
}
if !matches!(parts.uri.scheme_str(), Some("http" | "https")) {
while let Some(frame) = body.frame().await {
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "harness-api-client"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[dependencies]
wasi = "0.14"
+36
View File
@@ -0,0 +1,36 @@
//! A world HTTP client that forwards each response chunk without curl's body buffer.
use std::io::Write;
use wasi::http::{outgoing_handler, types::*};
fn main() {
let request = OutgoingRequest::new(Fields::new());
request.set_method(&Method::Get).unwrap();
request.set_scheme(Some(&Scheme::Http)).unwrap();
request.set_authority(Some("host.sausage")).unwrap();
request
.set_path_with_query(Some(&std::env::args().nth(1).unwrap()))
.unwrap();
let outgoing = request.body().unwrap();
let future = outgoing_handler::handle(request, None).unwrap();
OutgoingBody::finish(outgoing, None).unwrap();
future.subscribe().block();
let response = future.get().unwrap().unwrap().unwrap();
assert_eq!(response.status(), 200);
let incoming = response.consume().unwrap();
let stream = incoming.stream().unwrap();
let mut output = std::io::stdout().lock();
loop {
stream.subscribe().block();
match stream.read(65536) {
Ok(bytes) => {
output.write_all(&bytes).unwrap();
output.flush().unwrap();
}
Err(wasi::io::streams::StreamError::Closed) => break,
Err(error) => panic!("stream: {error:?}"),
}
}
drop(stream);
let trailers = IncomingBody::finish(incoming);
trailers.subscribe().block();
trailers.get().unwrap().unwrap().unwrap();
}
+190
View File
@@ -0,0 +1,190 @@
//! World HTTP clients read and drive the live V8 harness through WASI HTTP.
use sausage::{harness::run_module_with_input, world::World};
use std::sync::Arc;
use std::{path::Path, process::Command};
const BUNDLE: &str = include_str!("../js/harness-api-test-bundle.js");
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn prepare(dir: &Path) -> anyhow::Result<()> {
let _guard = PREPARE.lock().unwrap();
let disk = dir.join("world");
let repo = disk.join("src/sausage");
if repo.join(".git").exists() {
return Ok(());
}
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
let userland = source.join("userland");
if !userland.join("cwd-init/init.o").exists() {
assert!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success()
);
}
assert!(
Command::new("cargo")
.args([
"build",
"--release",
"-p",
"git",
"-p",
"curl",
"--manifest-path"
])
.arg(userland.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
.current_dir(&userland)
.status()?
.success()
);
assert!(
Command::new("cargo")
.args([
"build",
"--release",
"--target",
"wasm32-wasip2",
"--manifest-path"
])
.arg(source.join("host/tests/harness-api-client/Cargo.toml"))
.arg("--target-dir")
.arg(target)
.args(["-j", "2", "--offline"])
.status()?
.success()
);
std::fs::create_dir_all(disk.join("bin"))?;
std::fs::copy(
target.join("wasm32-wasip2/release/harness-api-client.wasm"),
disk.join("bin/harness-api-client.wasm"),
)?;
std::fs::copy(
target.join("wasm32-wasip2/release/git.wasm"),
disk.join("bin/git.wasm"),
)?;
std::fs::copy(
target.join("wasm32-wasip2/release/curl.wasm"),
disk.join("bin/curl.wasm"),
)?;
for directory in [
"extensions/subagents",
"extensions/messages",
"extensions/lineage",
"extensions/models",
"extensions/goal",
"extensions/questions",
"extensions/automations",
"extensions/core",
"agents",
] {
std::fs::create_dir_all(repo.join(directory))?;
}
for directory in [
"extensions/subagents",
"extensions/messages",
"extensions/lineage",
"extensions/models",
"extensions/goal",
"extensions/questions",
"extensions/automations",
] {
for entry in std::fs::read_dir(source.join(directory))? {
let entry = entry?;
if entry.path().extension().is_some_and(|ext| ext == "ts") {
std::fs::copy(entry.path(), repo.join(directory).join(entry.file_name()))?;
}
}
}
std::fs::create_dir_all(repo.join("extensions/probe"))?;
std::fs::write(
repo.join("extensions/probe/server.ts"),
"export default host => { globalThis.testHost = host; globalThis.subagentService = host.need({name:'subagents'}); globalThis.messageService = host.need({name:'messages'}); globalThis.lineageService = host.need({name:'lineage'}); host.implement({name:'conversations'}, {summaryModel: async () => globalThis.summaryRef, concludedIn: async () => new Set(), generatedTitle: async () => {}, setStanding: async () => {}, setAutomaticSummaries: async () => {}}); return {name:'probe'}; }",
)?;
std::fs::copy(
source.join("extensions/core/agents.ts"),
repo.join("extensions/core/agents.ts"),
)?;
std::fs::write(
repo.join("agents/models.json"),
r#"{"default":{"model":"faux/faux-1"}}"#,
)?;
std::fs::write(
repo.join("agents/scout.md"),
"---\nname: scout\ndescription: Search\nresident: true\ndenyTools: [subagent]\nisolate: none\n---\nScout instructions\n",
)?;
std::fs::write(
repo.join("agents/ultimate.md"),
"---\nname: ultimate\ndescription: Primary\nresident: true\nisolate: none\n---\nPrimary agent\n",
)?;
std::fs::write(
repo.join("agents/coordinator.md"),
"---\nname: coordinator\ndescription: Coordinate\nisolate: none\n---\nCoordinator agent\n",
)?;
for args in [
vec!["init", "-b", "main"],
vec!["add", "."],
vec!["commit", "--no-gpg-sign", "-m", "messages fixture"],
] {
assert!(
Command::new("git")
.args(args)
.current_dir(&repo)
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.status()?
.success()
);
}
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn harness_api_world_client() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let dir = dir.path();
let world = World::open(dir)?;
prepare(dir)?;
let cred = Arc::new(sausage::cred::Cred::open(&world.host_dir)?);
let api = Arc::new(sausage::api::Api::open(
cred.clone(),
&world.host_dir,
"localhost",
"http://localhost:8081",
)?);
let (sender, receiver) = sausage::harness::input::channel();
api.attach_harness(sender.clone());
let exec = sausage::process::Runtime::new_with_hooks(
World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
},
cred.clone(),
sausage::process::default_script(),
Some(api),
)?;
let root = world.root.clone();
let stop = async move {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
loop {
if root.stat("/harness-test-done", true).await.is_ok() {
break;
}
anyhow::ensure!(std::time::Instant::now() < deadline, "API test deadline");
tokio::task::yield_now().await;
}
sender.send(sausage::harness::input::Input::Stop)?;
Ok::<_, anyhow::Error>(())
};
tokio::try_join!(
run_module_with_input(world, Arc::new(exec), cred, receiver, BUNDLE),
stop
)?;
Ok(())
}