Merge branch 'v3' into v3-t17
This commit is contained in:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
/** Real dream triggers, message reports, and built-in receipts across a harness reopen. */
|
||||
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, defineExtension, defineTool } from '@earendil-works/pi-durable'
|
||||
import { obj } from '@earendil-works/pi-schema'
|
||||
import { loadExtensions } from './extensions'
|
||||
import { openStorage } from './sqlite'
|
||||
const assert=(ok:any,message:string)=>{if(!ok) throw new Error(message)}
|
||||
const until=async(test:()=>any)=>{const deadline=Date.now()+20000;while(!await test()){if(Date.now()>deadline)throw new Error(`Dream deadline: ${JSON.stringify((await entries()).filter((e:any)=>['pi.tool-result','board.reply'].includes(e.kind)))}`);await new Promise(r=>setTimeout(r,0))}}
|
||||
const models=createModels(),faux=fauxProvider({tokensPerSecond:100000});models.setProvider(faux.provider)
|
||||
const registry=createRegistry(),storage=await openStorage(globalThis.storagePath)
|
||||
const harness=await Harness.open(storage,{models,registry,settings:{retry:{enabled:false},get extensions(){return registry.snapshot().installed()}}},context)
|
||||
const root=await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'},cwd:'/src/demo'}})
|
||||
const route=(transcript:any):any=>{
|
||||
faux.appendResponses([route])
|
||||
const last=transcript.messages.findLast((m:any)=>m.role!=='system')
|
||||
const text=typeof last?.content==='string'?last.content:(last?.content??[]).filter((p:any)=>p.type==='text').map((p:any)=>p.text).join('')
|
||||
if(last?.role==='user'&&text.startsWith('CALL ')){const call=JSON.parse(text.slice(5));return fauxAssistantMessage([fauxToolCall(call.name,call.args)],{stopReason:'toolUse'})}
|
||||
if(last?.role==='user'&&text.includes('Dream journal pass')) return fauxAssistantMessage([fauxToolCall('subagent',{action:'start',agent:'dream-pass',task:'Review supplied evidence',background:true,isolate:'none'})],{stopReason:'toolUse'})
|
||||
if(last?.role==='user'&&text==='Review supplied evidence') return fauxAssistantMessage('Evidence #1: keep the review read-only.')
|
||||
if(last?.role==='user'&&/^(Reply|Message|Note) from/.test(text)) return fauxAssistantMessage('Received')
|
||||
if(last?.role==='toolResult'||text.includes('Evidence #1')) return fauxAssistantMessage('Dream journal: keep the review read-only. Evidence #1.')
|
||||
return fauxAssistantMessage('Received')
|
||||
}
|
||||
faux.setResponses([route])
|
||||
// The role requires the normal host read/bash tools. The scripted pass does not invoke them.
|
||||
const baseTools=defineExtension({name:'dream-test-tools',tools:['read','bash'].map(name=>defineTool({name,description:'Unused fixture tool',parameters:obj({}),execute:async()=>{throw new Error('Unexpected fixture tool invocation')}}))})
|
||||
const loader=await loadExtensions(harness,registry,storage,models,context,[baseTools])
|
||||
assert(!loader.state().error,`Extension load: ${loader.state().error}`)
|
||||
const base=`file:///run/sausage/extensions/${loader.state().good}/extensions`
|
||||
const {BuiltInDoc,PauseDoc,ensureAutomation,fired,AUTOMATION_NAME,PAUSE_NAME}=await import(`${base}/dream/built-ins.ts`)
|
||||
const {Next}=await import(`${base}/dream/server.ts`)
|
||||
const {Automations}=await import(`${base}/automations/server.ts`)
|
||||
const {ContextUsageDoc}=await import(`${base}/automations/triggers.ts`)
|
||||
const {Fired}=await import(`${base}/automations/refs.ts`)
|
||||
const {Messages}=await import(`${base}/messages/api.ts`)
|
||||
const primary=(await harness.conversation(await globalThis.dreamSubagents.standing('ultimate',context),context))!
|
||||
const entries=async()=> (await primary.context(context)).entries
|
||||
const ledger=async()=> (await harness.snapshot(Automations,context))?.automations??{}
|
||||
const requested=async()=> (await entries()).filter((e:any)=>e.kind==='dream.requested')
|
||||
const operation=(args:any)=>globalThis.dreamAutomations.operate(args)
|
||||
harness.resume()
|
||||
try {
|
||||
if(globalThis.dreamMode==='reopen') {
|
||||
await ensureAutomation(globalThis.dreamHost)
|
||||
const all=Object.values(await ledger()) as any[]
|
||||
assert(all.length===1&&all[0].name===PAUSE_NAME&&!all[0].enabled,'Reopen must preserve deletion and pause')
|
||||
assert((await requested()).length===2,'Reopen duplicated dream requests')
|
||||
assert((await harness.snapshot(Next,root.id,context)).prepared.handoff==='Replacement handoff','Prepared handoff must survive reopen')
|
||||
assert((await harness.snapshot(Messages,root.id,context)).messages.filter((m:any)=>m.re!==undefined&&m.text.includes('Dream journal')).length===1,'Reopen duplicated the dream report')
|
||||
} else {
|
||||
await until(async()=>Object.keys(await ledger()).length===2)
|
||||
await ensureAutomation(globalThis.dreamHost)
|
||||
const id=(await harness.snapshot(BuiltInDoc,root.id,context)).id
|
||||
const pause=(await harness.snapshot(PauseDoc,root.id,context)).id
|
||||
assert(Object.keys(await ledger()).length===2,'Repeated ensure duplicated built-ins')
|
||||
assert((await ledger())[id].name===AUTOMATION_NAME&&(await ledger())[id].condition.above===70,'Compaction trigger')
|
||||
assert((await ledger())[pause].condition.idleFor===8*3600_000&&(await ledger())[pause].condition.above===50,'Pause trigger')
|
||||
await harness.commit(async(tx:any)=>Object.assign(await tx.doc(ContextUsageDoc,primary.id),{percent:71,tokens:710,window:1000}),context)
|
||||
await until(async()=> (await requested()).filter((e:any)=>e.data.automation?.id===id).length===1)
|
||||
await harness.commit((tx:any)=>globalThis.dreamHost.emit(tx,Fired,{id,fire:1}),context)
|
||||
await harness.commit((tx:any)=>fired(globalThis.dreamHost,tx,id,1),context)
|
||||
await harness.waitForIdle(context)
|
||||
const deliveries=await harness.commit((tx:any)=>tx.scanTasks({kind:'dream.deliver'},100),context)
|
||||
for(const task of deliveries.items) await harness.waitForTask(task.id,context)
|
||||
assert((await requested()).length===1,'One fire must add exactly one dream.requested entry')
|
||||
const request=(await storage.scanSubmissions({conversationId:primary.id},100,undefined,context)).items.find((s:any)=>s.requestId===`dream.requested:${id}:1`)
|
||||
assert(request?.type==='write','The fire adds a passive write')
|
||||
await globalThis.dreamService.operate({action:'start'})
|
||||
await until(async()=> (await requested()).length===2)
|
||||
for(const args of [{learned:['one'],handoff:'x'},{learned:['a','b','c'],handoff:'x'.repeat(12001)}]) {
|
||||
let failed=false;try{await globalThis.dreamService.operate({action:'prepare',...args})}catch{failed=true}assert(failed,'Invalid preparation must fail')
|
||||
}
|
||||
await globalThis.dreamService.operate({action:'prepare',learned:['I learned one.','I keep two.','I propose three.'],handoff:'First handoff'})
|
||||
await globalThis.dreamService.operate({action:'prepare',learned:['a','b','c'],handoff:'Replacement handoff'})
|
||||
await until(async()=> (await entries()).filter((e:any)=>e.kind==='dream.prepared').length===2)
|
||||
await (await primary.submit({type:'input',content:`CALL ${JSON.stringify({name:'branch',args:{repo:'/src/demo',prompt:'Dream journal pass'}})}`},context)).wait(context)
|
||||
await until(async()=> (await entries()).some((e:any)=>e.kind==='board.reply'&&e.data.text.includes('Dream journal')))
|
||||
await harness.waitForIdle(context)
|
||||
const report=(await entries()).find((e:any)=>e.kind==='board.reply'&&e.data.text.includes('Dream journal'))
|
||||
const branch=(await harness.conversation(report.data.from,context))!
|
||||
const reports=(await branch.context(context)).entries.filter((e:any)=>e.kind==='subagents.report')
|
||||
assert(reports.length===1&&reports[0].data.agent==='dream-pass'&&JSON.stringify(reports).includes('Evidence #1'),`The dream-pass must report its evidence: ${JSON.stringify((await branch.context(context)).entries.filter((e:any)=>['pi.tool-result','subagents.report'].includes(e.kind)))}`)
|
||||
await operation({action:'pause',id:pause})
|
||||
await harness.commit(async(tx:any)=>{delete (await tx.doc(PauseDoc,root.id)).id},context)
|
||||
await ensureAutomation(globalThis.dreamHost)
|
||||
assert((await harness.snapshot(PauseDoc,root.id,context)).id===pause&&!(await ledger())[pause].enabled,'A lost receipt must adopt the paused automation')
|
||||
await operation({action:'delete',id})
|
||||
await ensureAutomation(globalThis.dreamHost)
|
||||
assert((await harness.snapshot(BuiltInDoc,root.id,context)).deleted&&Object.keys(await ledger()).length===1,'Deletion must persist')
|
||||
}
|
||||
} finally {await loader.close();await harness.close(context)}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);}};
|
||||
}
|
||||
|
||||
@@ -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)}`);
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/** The seven Ultimate goal cases use overlaid documents and the loaded server modules. */
|
||||
import type { ConversationId, GenerationHooks, Tx } from '@earendil-works/pi-durable'
|
||||
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context'
|
||||
import { expect, it } from './goal-questions-test-support'
|
||||
const { reminderTask } = await import(`${globalThis.goalBase}/goal/reminder.ts`)
|
||||
const {default:goalExtension,act,actions,end,Goals,openGoal,start,TRIES} = await import(`${globalThis.goalBase}/goal/server.ts`)
|
||||
import type { Host } from './extensions.ts'
|
||||
|
||||
const ULTIMATE = 5 as ConversationId
|
||||
const screenshot = { type: 'image', data: 'aGk=', mimeType: 'image/png' }
|
||||
|
||||
/** Like the harness's document overlay, a proxy at every level: structuredClone refuses it. */
|
||||
const overlay = <T extends object>(value: T): T =>
|
||||
new Proxy(value, {
|
||||
get: (target, key) => {
|
||||
const found = Reflect.get(target, key) as unknown
|
||||
return found && typeof found === 'object' ? overlay(found) : found
|
||||
},
|
||||
})
|
||||
|
||||
/** A transaction over overlaid documents and a few entries of @ultimate's conversation; @ultimate is conversation 5. */
|
||||
function fakeTx(
|
||||
newer: { id: number; conversationId: number; kind?: string; model: object[] }[] = [],
|
||||
) {
|
||||
const docs = new Map<string, object>()
|
||||
const entries = [
|
||||
...newer,
|
||||
{ id: 30, conversationId: ULTIMATE, model: [{ role: 'user', content: 'no images' }] },
|
||||
{
|
||||
id: 20,
|
||||
conversationId: ULTIMATE,
|
||||
model: [{ role: 'toolResult', content: [{ type: 'text', text: 'shot' }, screenshot] }],
|
||||
},
|
||||
{ id: 10, conversationId: 9, model: [{ role: 'user', content: [screenshot] }] },
|
||||
]
|
||||
const tasks: { name: string; input: { kind: string; content: string; to: number } }[] = []
|
||||
return {
|
||||
tasks,
|
||||
createTask: async ({ definition }: { definition: { name: string } }, input: never) => {
|
||||
tasks.push({ name: definition.name, input })
|
||||
},
|
||||
doc: async (
|
||||
{ definition: def }: { definition: { kind: string; initial: () => object } },
|
||||
id: number,
|
||||
) => {
|
||||
const key = `${def.kind}:${id}`
|
||||
if (!docs.has(key))
|
||||
docs.set(
|
||||
key,
|
||||
def.kind === 'ultimate.standing-agents' ? { ultimate: ULTIMATE } : def.initial(),
|
||||
)
|
||||
return overlay(docs.get(key)!)
|
||||
},
|
||||
entry: async (id: number) => entries.find((entry) => entry.id === id),
|
||||
scanEntries: async ({ conversationId }: { conversationId: number }) => ({
|
||||
items: entries.filter((entry) => entry.conversationId === conversationId),
|
||||
}),
|
||||
} as unknown as Tx & { tasks: typeof tasks }
|
||||
}
|
||||
|
||||
type Reminder = {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
subscribers?: (number | string)[]
|
||||
key?: string
|
||||
}
|
||||
|
||||
/** A host over `tx` with an automations ledger, and `sync()`, which runs the queued reminder tasks. */
|
||||
function fakeHost(tx: ReturnType<typeof fakeTx>) {
|
||||
const ledger = new Map<string, Reminder>()
|
||||
let next = 1
|
||||
const service = {
|
||||
// As the real service: a repeated key returns the automation it made.
|
||||
create: async (args: Omit<Reminder, 'id' | 'enabled'>) => {
|
||||
const made = [...ledger.values()].find((item) => args.key && item.key === args.key)
|
||||
if (made) return made
|
||||
const id = `A${next++}`
|
||||
ledger.set(id, { id, ...args, enabled: true })
|
||||
return ledger.get(id)
|
||||
},
|
||||
delete: async (id: string) => void ledger.delete(id),
|
||||
pause: async (id: string) => void (ledger.get(id)!.enabled = false),
|
||||
resume: async (id: string) => void (ledger.get(id)!.enabled = true),
|
||||
operate: async (args: any) => args.action === 'list' ? structuredClone([...ledger.values()]) : (service as any)[args.action](args.action === 'create' ? Object.fromEntries(Object.entries(args).filter(([key]) => key !== 'action')) : args.id),
|
||||
}
|
||||
const host = {
|
||||
harness: () => ({ commit: (fn: (tx: Tx) => unknown) => fn(tx) }),
|
||||
need: () => service,
|
||||
want: () => service,
|
||||
doc: () => undefined,
|
||||
invoke: async () => structuredClone([...ledger.values()]),
|
||||
} as unknown as Host
|
||||
const sync = async () => {
|
||||
const runtime = { commit: (fn: () => unknown) => fn() }
|
||||
for (const task of tx.tasks.splice(0).filter((item) => item.name === 'goal.reminder'))
|
||||
await reminderTask(host, Goals).definition.phases.sync(
|
||||
{ input: task.input } as never,
|
||||
runtime as never,
|
||||
BACKGROUND_CONTEXT,
|
||||
)
|
||||
}
|
||||
return { host, ledger, service, sync }
|
||||
}
|
||||
|
||||
it('keeps one goal open at a time and ends it once', async () => {
|
||||
const tx = fakeTx()
|
||||
await expect(start(tx, ' ', 0)).rejects.toThrow('nonempty')
|
||||
expect(await start(tx, ' Ship the sidebar ', 0)).toMatchObject({
|
||||
id: 1,
|
||||
text: 'Ship the sidebar',
|
||||
})
|
||||
await expect(start(tx, 'Another', 0)).rejects.toThrow('Goal 1 is still open')
|
||||
expect(await end(tx, { at: 1, by: 'user', outcome: 'ended' })).toMatchObject({
|
||||
ended: { by: 'user' },
|
||||
})
|
||||
await expect(end(tx, { at: 2, by: 'user', outcome: 'ended' })).rejects.toThrow('No goal')
|
||||
expect((await start(tx, 'Next', 3)).id).toBe(2)
|
||||
})
|
||||
|
||||
it('blocks a criterion after repeated failed tries and finishes with the outcome', async () => {
|
||||
const tx = fakeTx()
|
||||
await expect(act(tx, ULTIMATE, { action: 'met', n: 1 }, 0)).rejects.toThrow('No hands-off goal')
|
||||
await start(tx, 'Goal', 0)
|
||||
await act(tx, ULTIMATE, { action: 'criteria', criteria: ['Loads in 1s', 'Tests pass'] }, 0)
|
||||
await expect(act(tx, ULTIMATE, { action: 'met', n: 3 }, 0)).rejects.toThrow('No criterion 3')
|
||||
for (let i = 1; i < TRIES; i++) await act(tx, ULTIMATE, { action: 'failed', n: 1 }, 0)
|
||||
expect((await openGoal(tx))!.criteria[0]).toMatchObject({ status: 'open', tries: TRIES - 1 })
|
||||
const last = await act(tx, ULTIMATE, { action: 'failed', n: 1 }, 0)
|
||||
expect(last.answer).toContain('blocked')
|
||||
expect((await openGoal(tx))!.criteria[0].status).toBe('blocked')
|
||||
// Rewording keeps what was learned about an unchanged criterion.
|
||||
await act(tx, ULTIMATE, { action: 'criteria', criteria: ['Loads in 1s', 'Lint passes'] }, 0)
|
||||
expect((await openGoal(tx))!.criteria.map((item) => item.status)).toEqual(['blocked', 'open'])
|
||||
await expect(act(tx, ULTIMATE, { action: 'finish', summary: 'x' }, 0)).rejects.toThrow('outcome')
|
||||
const done = await act(
|
||||
tx,
|
||||
ULTIMATE,
|
||||
{ action: 'finish', outcome: 'blocked', summary: 'Stuck' },
|
||||
4,
|
||||
)
|
||||
expect(done.ended).toMatchObject({
|
||||
ended: { by: 'ultimate', outcome: 'blocked', summary: 'Stuck' },
|
||||
})
|
||||
expect(await openGoal(tx)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('records decisions with the screenshots of its own conversation only', async () => {
|
||||
const tx = fakeTx()
|
||||
await start(tx, 'Goal', 0)
|
||||
await expect(
|
||||
act(tx, ULTIMATE, { action: 'decide', question: 'Go?', picked: 'Yes' }, 0),
|
||||
).rejects.toThrow('reason')
|
||||
await act(
|
||||
tx,
|
||||
ULTIMATE,
|
||||
{
|
||||
action: 'decide',
|
||||
question: 'Approve?',
|
||||
picked: 'Yes',
|
||||
reason: 'Meets 1',
|
||||
conversation: 7,
|
||||
images: 'latest',
|
||||
},
|
||||
1,
|
||||
)
|
||||
await expect(
|
||||
act(tx, ULTIMATE, { action: 'decide', question: 'q', picked: 'p', reason: 'r', images: 10 }, 2),
|
||||
).rejects.toThrow('has no images')
|
||||
expect((await openGoal(tx))!.decisions).toEqual([
|
||||
{ at: 1, question: 'Approve?', picked: 'Yes', reason: 'Meets 1', conversation: 7, images: 20 },
|
||||
])
|
||||
})
|
||||
|
||||
it('never takes an earlier turn\'s screenshot for images: "latest"', async () => {
|
||||
const tx = fakeTx([{ id: 40, kind: 'pi.user', conversationId: ULTIMATE, model: [] }])
|
||||
await start(tx, 'Goal', 0)
|
||||
const decide = { action: 'decide', question: 'q', picked: 'p', reason: 'r' } as const
|
||||
await expect(act(tx, ULTIMATE, { ...decide, images: 'latest' }, 1)).rejects.toThrow(
|
||||
'no images in this turn',
|
||||
)
|
||||
await act(tx, ULTIMATE, { ...decide, images: 20 }, 2)
|
||||
expect((await openGoal(tx))!.decisions).toMatchObject([{ images: 20 }])
|
||||
})
|
||||
|
||||
it("notes the open goal on @ultimate's runs only, and lets only @ultimate keep it", async () => {
|
||||
const tx = fakeTx()
|
||||
const extension = goalExtension({
|
||||
doc: () => undefined,
|
||||
need: () => undefined,
|
||||
want: () => undefined,
|
||||
} as unknown as Host)
|
||||
const hooks = extension.hooks![0].handlers as GenerationHooks
|
||||
const before = (conversationId: number, kind = 'pi.user') =>
|
||||
hooks.beforeInput({ kind }, { tx, conversationId } as never, BACKGROUND_CONTEXT)
|
||||
expect(await before(ULTIMATE)).toBeUndefined()
|
||||
await start(tx, 'Make it fast', 0)
|
||||
expect(await before(9)).toBeUndefined()
|
||||
const note = (await before(ULTIMATE))!.entries![0]
|
||||
expect(note.kind).toBe('goal.note')
|
||||
expect(note.model![0].content).toContain(
|
||||
'Hands-off goal 1, granted by the user, who is away: Make it fast',
|
||||
)
|
||||
|
||||
const tool = extension.tools!.find((candidate) => candidate.name === 'goal')!
|
||||
const run = (conversationId: number, args: object) =>
|
||||
tool.execute(
|
||||
args as never,
|
||||
{ conversationId, commit: (fn: (tx: Tx) => unknown) => fn(tx) } as never,
|
||||
undefined as never,
|
||||
)
|
||||
await expect(run(9, { action: 'met', n: 1 })).rejects.toThrow('Only @ultimate')
|
||||
expect(await run(ULTIMATE, { action: 'criteria', criteria: ['Fast'] })).toEqual({
|
||||
content: [{ type: 'text', text: 'Goal 1 has 1 criteria.' }],
|
||||
})
|
||||
expect((await tx.doc(Goals, 1 as ConversationId)).goals[0].criteria).toHaveLength(1)
|
||||
|
||||
// Finishing ends the goal and creates the report's delivery in the same commit; the note stops.
|
||||
await run(ULTIMATE, { action: 'finish', outcome: 'met', summary: 'Done' })
|
||||
expect(tx.tasks).toEqual([
|
||||
{ name: 'goal.deliver', input: expect.objectContaining({ to: ULTIMATE, kind: 'goal.report' }) },
|
||||
{ name: 'goal.reminder', input: { goal: 1 } },
|
||||
])
|
||||
expect(await before(ULTIMATE)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a reminder while the goal is open: paused while waiting on the user, gone once ended', async () => {
|
||||
const tx = fakeTx()
|
||||
const { host, ledger, service, sync } = fakeHost(tx)
|
||||
const extension = goalExtension(host)
|
||||
const tool = extension.tools!.find((candidate) => candidate.name === 'goal')!
|
||||
const run = (args: object) =>
|
||||
tool.execute(
|
||||
args as never,
|
||||
{ conversationId: ULTIMATE, commit: (fn: (tx: Tx) => unknown) => fn(tx) } as never,
|
||||
undefined as never,
|
||||
)
|
||||
await actions.start({ text: 'Make it fast' }, { host } as never)
|
||||
// A crash after creating, before linking, left one; and the goal was paused meanwhile. The task run adopts it by
|
||||
// its key instead of making a second one, and pauses it.
|
||||
await service.create({
|
||||
name: 'Goal reminder · goal 1',
|
||||
subscribers: ['@ultimate'],
|
||||
key: 'goal.reminder:1',
|
||||
})
|
||||
;(await openGoal(tx))!.waiting = { reason: 'Which account?', at: 0 }
|
||||
await sync()
|
||||
expect([...ledger.values()]).toEqual([
|
||||
{
|
||||
id: 'A1',
|
||||
name: 'Goal reminder · goal 1',
|
||||
subscribers: ['@ultimate'],
|
||||
key: 'goal.reminder:1',
|
||||
enabled: false,
|
||||
},
|
||||
])
|
||||
expect((await openGoal(tx))!.reminder).toBe('A1')
|
||||
await run({ action: 'resume' })
|
||||
await sync()
|
||||
expect(ledger.get('A1')!.enabled).toBe(true)
|
||||
// The user renamed it: it stays linked by its id.
|
||||
ledger.get('A1')!.name = 'Night check'
|
||||
|
||||
// Its fire tells @ultimate what to check; other inputs do not.
|
||||
const hooks = extension.hooks![0].handlers as GenerationHooks
|
||||
const note = async (input: object) =>
|
||||
(await hooks.beforeInput(input as never, { tx, conversationId: ULTIMATE }, BACKGROUND_CONTEXT))!
|
||||
.entries![0].model![0].content as string
|
||||
expect(await note({ kind: 'automations.fired', data: { id: 'A1' } })).toContain(
|
||||
'This is the goal reminder',
|
||||
)
|
||||
expect(await note({ kind: 'pi.user' })).not.toContain('This is the goal reminder')
|
||||
expect(await note({ kind: 'pi.user', model: [{ role: 'user', content: '[automation A1 fired] Night check' }] })).toContain('This is the goal reminder')
|
||||
expect(await note({ kind: 'pi.user', model: [{ role: 'user', content: '[automation A2 fired] Other check' }] })).not.toContain('This is the goal reminder')
|
||||
|
||||
await expect(run({ action: 'pause' })).rejects.toThrow('reason')
|
||||
await expect(run({ action: 'resume' })).rejects.toThrow('not paused')
|
||||
await run({ action: 'pause', reason: 'Which account to deploy to?' })
|
||||
await sync()
|
||||
expect(ledger.get('A1')!.enabled).toBe(false)
|
||||
expect((await openGoal(tx))!.waiting).toMatchObject({ reason: 'Which account to deploy to?' })
|
||||
expect(await note({ kind: 'pi.user' })).toContain('Waiting on the user')
|
||||
await run({ action: 'resume' })
|
||||
await sync()
|
||||
expect(ledger.get('A1')!.enabled).toBe(true)
|
||||
expect((await openGoal(tx))!.waiting).toBeUndefined()
|
||||
|
||||
await run({ action: 'finish', outcome: 'met', summary: 'Done' })
|
||||
await sync()
|
||||
expect(ledger.size).toBe(0)
|
||||
// A start and end whose tasks run only after both committed make nothing.
|
||||
await actions.start({ text: 'Again' }, { host } as never)
|
||||
await actions.end(null, { host } as never)
|
||||
await sync()
|
||||
expect(ledger.size).toBe(0)
|
||||
})
|
||||
|
||||
it('grants nothing through a start card still queued after the user ended its goal', async () => {
|
||||
const tx = fakeTx()
|
||||
const { host } = fakeHost(tx)
|
||||
const extension = goalExtension(host)
|
||||
await actions.start({ text: 'Make it fast' }, { host } as never)
|
||||
await actions.end(null, { host } as never)
|
||||
const [started, report] = tx.tasks
|
||||
.filter((task) => task.name === 'goal.deliver')
|
||||
.map((task) => task.input)
|
||||
expect(started).toMatchObject({
|
||||
kind: 'goal.started',
|
||||
content: 'The user started hands-off goal 1.',
|
||||
})
|
||||
expect(report).toMatchObject({ kind: 'goal.report' })
|
||||
expect(report.content).toContain('ask them as usual')
|
||||
// The queued start runs now: no goal is open, so no note says to decide for the user.
|
||||
const hooks = extension.hooks![0].handlers as GenerationHooks
|
||||
expect(
|
||||
await hooks.beforeInput(
|
||||
{ kind: 'goal.started' },
|
||||
{ tx, conversationId: ULTIMATE },
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toBeUndefined()
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Real-loader fixture for the question-card cases. */
|
||||
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 { openStorage } from './sqlite'
|
||||
import { loadExtensions } from './extensions'
|
||||
import { assert, until } from './goal-questions-test-support'
|
||||
let next=0
|
||||
export async function fixture(clock: any = {}) {
|
||||
const models=createModels(), faux=fauxProvider({tokensPerSecond:100000}); models.setProvider(faux.provider)
|
||||
const registry=createRegistry(), storage=await openStorage(`${globalThis.storagePath}.questions-${next++}`)
|
||||
const harness=await Harness.open(storage,{models,registry,...clock,settings:{get extensions(){return registry.snapshot().installed()}}},context)
|
||||
const root=await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'}}})
|
||||
const loader=await loadExtensions(harness,registry,storage,models,context)
|
||||
assert(!loader.state().error,loader.state().error)
|
||||
const base=`file:///run/sausage/extensions/${loader.state().good}/extensions`
|
||||
const question=await import(/* @vite-ignore */ `${base}/questions/server.ts`)
|
||||
const {Messages}=await import(/* @vite-ignore */ `${base}/messages/api.ts`)
|
||||
const {Standing}=await import(/* @vite-ignore */ `${base}/subagents/api.ts`)
|
||||
const observed:string[]=[];let heard:any
|
||||
const route=async(transcript:any)=>{
|
||||
faux.appendResponses([route]);const last=transcript.messages.at(-1),text=typeof last.content==='string'?last.content:last.content.filter((p:any)=>p.type==='text').map((p:any)=>p.text).join('\n')
|
||||
for(const m of transcript.messages) if(m.role==='user') observed.push(typeof m.content==='string'?m.content:m.content.filter((p:any)=>p.type==='text').map((p:any)=>p.text).join('\n'))
|
||||
await heard?.(text)
|
||||
return last.role==='user'&&text.startsWith('SEND ')?fauxAssistantMessage([fauxToolCall('message',JSON.parse(text.slice(5)))],{stopReason:'toolUse'}):fauxAssistantMessage('Sent.')
|
||||
}
|
||||
faux.setResponses([route]);harness.resume()
|
||||
const log=async()=>await harness.snapshot(Messages,root.id,context)
|
||||
const settled=async()=>{await until(async()=>(await harness.commit((tx:any)=>tx.scanTasks({},1000),context)).items.every((t:any)=>t.state.status==='terminal'));await harness.waitForIdle(context)}
|
||||
const entries=async(id:number)=>(await harness.commit((tx:any)=>tx.scanEntries({conversationId:id},1000),context)).items.toSorted((a:any,b:any)=>a.id-b.id)
|
||||
const type=async(id:number,content:string)=>{const c=await harness.conversation(id,context);await(await c.submit({type:'input',content},context)).wait(context)}
|
||||
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,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}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
/** Minimal assertions for the imported Ultimate cases, without Node or Vitest. */
|
||||
export const cases: {name:string;run:()=>any}[]=[]
|
||||
export const it=(name:string,run:()=>any)=>cases.push({name,run})
|
||||
export const describe=(_name:string,run:()=>any)=>run()
|
||||
export const cleanups: (()=>any)[]=[]
|
||||
export const afterEach=(run:()=>any)=>cleanups.push(run)
|
||||
expect.objectContaining=(value:any)=>({__object:true,value})
|
||||
expect.stringContaining=(value:string)=>({__string:true,value})
|
||||
export const assert=(value:any,message:string)=>{if(!value)throw new Error(message)}
|
||||
const same=(a:any,b:any):boolean=>b?.__object || b?.__string ? subset(a,b) : b?.__contains ? b.values.every((v:any)=>a.includes(v)) : Array.isArray(b) ? Array.isArray(a)&&a.length===b.length&&b.every((v:any,i:number)=>same(a[i],v)) : b&&typeof b==='object' ? a&&Object.keys(a).length===Object.keys(b).length&&Object.entries(b).every(([k,v])=>same(a[k],v)) : a===b
|
||||
const subset=(a:any,b:any):boolean=>b?.__object ? subset(a,b.value) : b?.__string ? a.includes(b.value) : b && typeof b==='object' ? Object.entries(b).every(([k,v])=>subset(a?.[k],v)) : a===b
|
||||
export function expect(value:any,negate=false):any {
|
||||
const check=(ok:boolean)=>assert(negate?!ok:ok,`Expected ${String(value)}${negate?' not':''} to match`)
|
||||
const result:any={
|
||||
toBe:(b:any)=>check(value===b),toEqual:(b:any)=>check(same(value,b)),toHaveLength:(n:number)=>check(value.length===n),
|
||||
toMatchObject:(b:any)=>check(subset(value,b)),toMatch:(b:RegExp)=>check(b.test(value)),toContain:(b:any)=>check(value.includes(b)),
|
||||
toBeUndefined:()=>check(value===undefined),toBeDefined:()=>check(value!==undefined),toBeGreaterThanOrEqual:(b:number)=>check(value>=b),
|
||||
toThrow:(b:any)=>{let error;try{value()}catch(e){error=e}check(error!==undefined && (b instanceof RegExp?b.test(String(error)):String(error).includes(b)))},
|
||||
}
|
||||
Object.defineProperty(result,'not',{get:()=>expect(value,!negate)})
|
||||
Object.defineProperty(result,'rejects',{get:()=>({toThrow:async(b:string)=>{let error;try{await value}catch(e){error=e}check(error!==undefined&&String(error).includes(b))}})})
|
||||
return result
|
||||
}
|
||||
expect.arrayContaining=(values:any[])=>({__contains:true,values})
|
||||
export async function until(condition:()=>any) {
|
||||
const deadline=Date.now()+5000
|
||||
while(!await condition()) {assert(Date.now()<deadline,'Condition deadline');await new Promise(resolve=>setTimeout(resolve,0))}
|
||||
}
|
||||
export const setImmediate=(callback:any)=>setTimeout(callback,0)
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Ultimate goal and question-card cases execute in the world loader. */
|
||||
import {fixture} from './goal-questions-fixture'
|
||||
import {cases,cleanups} from './goal-questions-test-support'
|
||||
const initial=await fixture()
|
||||
globalThis.goalBase=initial.base
|
||||
await initial.opened.close()
|
||||
await import('./goal-cases')
|
||||
await import('./questions-cases')
|
||||
await import('./goal-reminder-cases')
|
||||
for(const test of cases){try{await test.run();Deno.core.ops.op_output(`PASS ${test.name}\n`)}finally{for(const cleanup of cleanups) await cleanup()}}
|
||||
@@ -0,0 +1,32 @@
|
||||
/** A goal reminder fires once through the real automation scheduler and injected clock. */
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
|
||||
import {fixture} from './goal-questions-fixture'
|
||||
import {it,assert,until} from './goal-questions-test-support'
|
||||
it('fires one goal reminder through automations',async()=>{
|
||||
let now=Date.now();const timers=new Set<any>()
|
||||
const f=await fixture({now:()=>now,setTimer:(callback:any,delay:number)=>{
|
||||
if(delay<1000){const id=setTimeout(callback,delay);return()=>clearTimeout(id)}
|
||||
const timer={at:now+delay,callback};timers.add(timer);return()=>timers.delete(timer)
|
||||
}})
|
||||
try {
|
||||
const id=await f.ultimate();const {harness}=f.opened
|
||||
const goal=await import(/* @vite-ignore */ `${f.base}/goal/server.ts`)
|
||||
const {Automations}=await import(/* @vite-ignore */ `${f.base}/automations/server.ts`)
|
||||
await goal.actions.start({text:'Ship the goal'},{host:f.host})
|
||||
const get=()=>harness.snapshot(goal.Goals,f.opened.root.id,context)
|
||||
await until(async()=>Boolean((await get()).goals[0].reminder))
|
||||
const reminder=(await get()).goals[0].reminder
|
||||
const ledger=()=>harness.snapshot(Automations,context)
|
||||
await until(async()=>{const a=(await ledger()).automations[reminder];return (await harness.getTask(a.scheduler,context)).state.status==='sleeping'})
|
||||
const deadline=(await ledger()).automations[reminder].nextAt
|
||||
assert(deadline-(await ledger()).automations[reminder].createdAt===1800000,'The reminder waits 30 minutes')
|
||||
await until(()=>[...timers].some(timer=>timer.at===deadline))
|
||||
now=deadline
|
||||
for(const timer of [...timers])if(timer.at<=now){timers.delete(timer);timer.callback()}
|
||||
await until(async()=>(await ledger()).automations[reminder].fires===1)
|
||||
await until(async()=>(await f.entries(id)).some((e:any)=>e.kind==='goal.note'&&e.model[0].content.includes('This is the goal reminder')))
|
||||
assert((await ledger()).automations[reminder].fires===1,'One reminder fire')
|
||||
await goal.actions.end(null,{host:f.host})
|
||||
await until(async()=>!(await ledger()).automations[reminder])
|
||||
} finally {await f.opened.close()}
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
+95
-56
@@ -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;
|
||||
@@ -31684,7 +31690,7 @@ function answerFor(rules, transcript) {
|
||||
const last = transcript.messages.findLast((message) => message.role !== "system");
|
||||
const rule = rules.find((rule2) => rule2.role === role && (!rule2.match.kind || rule2.match.kind === last?.role) && textOf(last?.content).includes(rule2.match.text));
|
||||
if (!rule) throw new Error(`No scripted rule for ${role}: ${JSON.stringify(last)}`);
|
||||
return { rule: rule.id, answer: rule.answer };
|
||||
return { rule: rule.id, answer: typeof rule.answer === "function" ? rule.answer(transcript) : rule.answer };
|
||||
}
|
||||
function scriptedModel(rules, report2) {
|
||||
const faux = fauxProvider({ tokensPerSecond: 1e5 });
|
||||
@@ -31707,6 +31713,11 @@ function testReplay() {
|
||||
const first = answerFor(rules, { messages: [system, input] });
|
||||
const replay = answerFor(JSON.parse(JSON.stringify(rules)), { messages: [system, { role: "assistant", content: [] }, input] });
|
||||
if (JSON.stringify(first) !== JSON.stringify(replay)) throw new Error("Scripted replay changed the answer");
|
||||
const dynamic = [{ id: "land", role: "worker", match: { text: "build" }, answer: (transcript) => ({ tools: [{ name: "land", args: { id: /Started subagent (\d+)/.exec(JSON.stringify(transcript.messages))?.[1] } }] }) }];
|
||||
const history = { role: "toolResult", content: "Started subagent 42." };
|
||||
const selected = answerFor(dynamic, { messages: [system, history, input] });
|
||||
const restarted = answerFor(dynamic, { messages: [system, { role: "assistant", content: [] }, history, input] });
|
||||
if (JSON.stringify(selected) !== JSON.stringify(restarted) || selected.answer.tools?.[0].args.id !== "42") throw new Error("Dynamic tool identity changed on replay");
|
||||
const toolRules = [{ id: "worker-done", role: "worker", match: { text: "committed" }, answer: { text: "done" } }];
|
||||
if (answerFor(toolRules, { messages: [system, { role: "toolResult", content: [{ type: "text", text: "committed" }] }] }).rule !== "worker-done") throw new Error("Tool result rule did not match");
|
||||
}
|
||||
@@ -31717,14 +31728,17 @@ var assert = (ok, message) => {
|
||||
};
|
||||
testReplay();
|
||||
if (globalThis.p3Mode !== "unit") {
|
||||
const workerId = (transcript) => Number(/Started subagent (\d+)/.exec(JSON.stringify(transcript.messages))?.[1]);
|
||||
const rules = [
|
||||
{ id: "ultimate-branch", role: "ultimate", match: { kind: "user", text: "launch branch" }, answer: { tools: [{ name: "branch", args: { repo: "/src/demo", prompt: "branch probe" } }] } },
|
||||
{ id: "ultimate-branch", role: "ultimate", match: { kind: "user", text: "launch scenario" }, answer: { tools: [{ name: "branch", args: { repo: "/src/demo", isolated: true, prompt: "begin" } }] } },
|
||||
{ id: "ultimate-probe", role: "ultimate", match: { kind: "user", text: "launch branch" }, answer: { tools: [{ name: "branch", args: { repo: "/src/demo", prompt: "branch probe" } }] } },
|
||||
{ id: "coordinator-probe", role: "coordinator", match: { kind: "user", text: "branch probe" }, answer: { text: "branch complete" } },
|
||||
{ id: "ultimate-receipt", role: "ultimate", match: { text: "" }, answer: { text: "Receipt noted." } },
|
||||
{ id: "coordinator-echo", role: "coordinator", match: { kind: "user", text: "Receipt noted." }, answer: { text: "Acknowledged." } },
|
||||
{ id: "start-worker", role: "coordinator", match: { kind: "user", text: "begin" }, answer: { tools: [{ name: "subagent", args: { action: "start", agent: "worker", isolate: "none", task: "build feature", background: true } }] } },
|
||||
{ id: "start-worker", role: "coordinator", match: { kind: "user", text: "begin" }, answer: { tools: [{ name: "subagent", args: { action: "start", agent: "worker", isolate: "worktree", task: "build feature", background: true } }] } },
|
||||
{ id: "start-reviewer", role: "coordinator", match: { kind: "user", text: "worker complete" }, answer: { tools: [{ name: "subagent", args: { action: "start", agent: "reviewer", isolate: "none", task: "review feature", background: true } }] } },
|
||||
{ id: "coordinator-done", role: "coordinator", match: { kind: "user", text: "approve" }, answer: { text: "scenario complete" } },
|
||||
{ id: "land-worker", role: "coordinator", match: { kind: "user", text: "approve" }, answer: (transcript) => ({ tools: [{ name: "subagent", args: { action: "land", id: workerId(transcript) } }] }) },
|
||||
{ id: "submit", role: "coordinator", match: { text: "Landed commits" }, answer: { tools: [{ name: "workspace", args: { action: "submit", divergence: "None" } }] } },
|
||||
{ id: "coordinator-wait", role: "coordinator", match: { text: "" }, answer: { text: "Wait for child report." } },
|
||||
{ id: "worker-write", role: "worker", match: { kind: "user", text: "build feature" }, answer: { tools: [{ name: "write-feature", args: {} }] } },
|
||||
{ id: "worker-commit", role: "worker", match: { text: "feature written" }, answer: { tools: [{ name: "commit-feature", args: {} }] } },
|
||||
@@ -31740,30 +31754,50 @@ if (globalThis.p3Mode !== "unit") {
|
||||
return registry.snapshot().installed();
|
||||
} } }, BACKGROUND_CONTEXT);
|
||||
const root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "faux", modelId: "faux-1" }, instructions: "P3 role:ultimate", cwd: "/src/demo" } });
|
||||
const git2 = async (...args) => {
|
||||
const result = await exec(["/bin/git.wasm", ...args], { cwd: "/src/demo", env: [["GIT_AUTHOR_NAME", "Test"], ["GIT_AUTHOR_EMAIL", "test@example.com"], ["GIT_COMMITTER_NAME", "Test"], ["GIT_COMMITTER_EMAIL", "test@example.com"]] });
|
||||
if (globalThis.p3Mode === "kill-forward") {
|
||||
const install = registry.install.bind(registry);
|
||||
registry.install = (extension) => install(extension.name !== "workspaces" ? extension : { ...extension, tasks: extension.tasks.map((task) => task.definition.name !== "workspaces.lander" ? task : { definition: { ...task.definition, phases: { ...task.definition.phases, land: async (record2, runtime, ctx) => {
|
||||
const commit = runtime.commit.bind(runtime);
|
||||
return task.definition.phases.land(record2, { ...runtime, commit: async (...args) => {
|
||||
const result = await commit(...args);
|
||||
const index = await harness.snapshot(globalThis.p3Workspaces.doc, root.id, BACKGROUND_CONTEXT);
|
||||
if (index.queue.some((entry2) => entry2.landed)) {
|
||||
await fs.write("/forward-recorded.tmp", JSON.stringify(index.queue));
|
||||
await fs.rename("/forward-recorded.tmp", "/forward-recorded");
|
||||
await awaitWithContext(new Promise(() => {
|
||||
}), ctx);
|
||||
}
|
||||
return result;
|
||||
} }, ctx);
|
||||
} } } }) });
|
||||
}
|
||||
const git2 = async (cwd, ...args) => {
|
||||
const result = await exec(["/bin/git.wasm", ...args], { cwd, env: [["GIT_AUTHOR_NAME", "Test"], ["GIT_AUTHOR_EMAIL", "test@example.com"], ["GIT_COMMITTER_NAME", "Test"], ["GIT_COMMITTER_EMAIL", "test@example.com"]] });
|
||||
assert(result.code === 0, `World git ${args}: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
};
|
||||
const tools = defineExtension({ name: "p3-tools", tools: [
|
||||
defineTool({ name: "write-feature", description: "Write the demo feature", parameters: obj({}), replay: "safe", execute: async (_args, _api, ctx) => {
|
||||
defineTool({ name: "write-feature", description: "Write the isolated feature", parameters: obj({}), replay: "safe", execute: async (_args, api, ctx) => {
|
||||
const cwd = (await api.agent(ctx)).cwd;
|
||||
assert(cwd.startsWith("/worktrees/") && await fs.read(`${cwd}/README.md`) === "P3 demo\n", "Worker must start in a prepared checkout");
|
||||
if (globalThis.p3Mode === "kill-worker") {
|
||||
await fs.write("/worker-running", "yes");
|
||||
await awaitWithContext(new Promise(() => {
|
||||
}), ctx);
|
||||
}
|
||||
await fs.write("/src/demo/feature.txt", "P3 feature\n");
|
||||
await fs.write(`${cwd}/feature.txt`, "P3 feature\n");
|
||||
return { output: [{ type: "text", text: "feature written" }] };
|
||||
} }),
|
||||
defineTool({ name: "commit-feature", description: "Commit the demo feature", parameters: obj({}), replay: "safe", execute: async () => {
|
||||
await git2("add", "feature.txt");
|
||||
if ((await git2("status", "--porcelain")).stdout.trim()) await git2("commit", "--no-gpg-sign", "-m", "P3 feature");
|
||||
defineTool({ name: "commit-feature", description: "Commit the isolated feature", parameters: obj({}), replay: "safe", execute: async (_args, api, ctx) => {
|
||||
const cwd = (await api.agent(ctx)).cwd;
|
||||
await git2(cwd, "add", "feature.txt");
|
||||
if ((await git2(cwd, "status", "--porcelain")).stdout.trim()) await git2(cwd, "commit", "--no-gpg-sign", "-m", "P3 feature");
|
||||
return { output: [{ type: "text", text: "feature committed" }] };
|
||||
} }),
|
||||
defineTool({ name: "review-feature", description: "Verify the committed demo feature", parameters: obj({}), replay: "safe", execute: async () => {
|
||||
assert(await fs.read("/src/demo/feature.txt") === "P3 feature\n", "Reviewer lost feature");
|
||||
const result = await git2("show", "HEAD:feature.txt");
|
||||
assert(result.stdout === "P3 feature\n", "Reviewer did not read the committed feature");
|
||||
defineTool({ name: "review-feature", description: "Verify the worker commit before landing", parameters: obj({}), replay: "safe", execute: async (_args, _api, ctx) => {
|
||||
const index = await harness.snapshot(globalThis.p3Workspaces.doc, root.id, ctx);
|
||||
const worker = Object.values(index.items).find((item) => item.source.startsWith("/worktrees/"));
|
||||
assert(worker && (await git2(worker.path, "show", "HEAD:feature.txt")).stdout === "P3 feature\n", "Reviewer must approve the worker commit");
|
||||
return { output: [{ type: "text", text: "feature verified" }] };
|
||||
} })
|
||||
] });
|
||||
@@ -31771,54 +31805,59 @@ if (globalThis.p3Mode !== "unit") {
|
||||
assert(!loader.state().error, `Extension load: ${loader.state().error}`);
|
||||
try {
|
||||
if (globalThis.p3Mode === "load-all") {
|
||||
assert((await root.agent(BACKGROUND_CONTEXT)).tools.some((tool) => tool.name === "branch"), "Every extension must load, including messages");
|
||||
} else if (globalThis.p3Mode === "branch" || globalThis.p3Mode === "branch-resume") {
|
||||
assert((await root.agent(BACKGROUND_CONTEXT)).tools.some((tool) => tool.name === "branch"), "Every extension must load");
|
||||
} else {
|
||||
const ultimate = await harness.conversation(await globalThis.p3Subagents.standing("ultimate", BACKGROUND_CONTEXT), BACKGROUND_CONTEXT);
|
||||
harness.resume();
|
||||
if (globalThis.p3Mode === "branch") await (await ultimate.submit({ type: "input", content: "launch branch", requestId: "p3-branch" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
|
||||
const replies = async () => (await ultimate.context(BACKGROUND_CONTEXT)).entries.filter((entry2) => entry2.kind === "board.reply");
|
||||
const deadline = Date.now() + 3e4;
|
||||
while ((await replies()).length === 0) {
|
||||
if (Date.now() > deadline) throw new Error(`No branch reply: ${JSON.stringify((await ultimate.context(BACKGROUND_CONTEXT)).entries)}`);
|
||||
await new Promise((resolve2) => setTimeout(resolve2, 0));
|
||||
const probe = globalThis.p3Mode.startsWith("branch");
|
||||
if (globalThis.p3Mode === "branch" || globalThis.p3Mode.startsWith("kill-")) {
|
||||
await (await ultimate.submit({ type: "input", content: probe ? "launch branch" : "launch scenario", requestId: "p3-launch" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
|
||||
const entries3 = (await ultimate.context(BACKGROUND_CONTEXT)).entries;
|
||||
assert(!JSON.stringify(entries3).includes('"isError":true'), `Branch tool failed: ${JSON.stringify(entries3)}`);
|
||||
}
|
||||
await harness.waitForIdle(BACKGROUND_CONTEXT);
|
||||
while (Deno.core.ops.op_sql("all", "SELECT id FROM tasks WHERE json_extract(kind, '$') IN ('board.route', 'board.delivery') AND status NOT IN ('terminal', 'faulted')", []).length) {
|
||||
if (Date.now() > deadline) throw new Error("Branch routing did not settle");
|
||||
await new Promise((resolve2) => setTimeout(resolve2, 0));
|
||||
}
|
||||
const received = await replies();
|
||||
assert(received.length === 1 && received[0].data.text === "branch complete" && !received[0].data.failed, `Expected one coordinator answer: ${JSON.stringify(received)}`);
|
||||
const branch = await harness.conversation(received[0].data.from, BACKGROUND_CONTEXT);
|
||||
assert((await branch.agent(BACKGROUND_CONTEXT)).instructions === "P3 role:coordinator", "Branch must use the coordinator definition");
|
||||
assert((await conversationChains(harness, BACKGROUND_CONTEXT)(branch.id)).includes(String(ultimate.id)), "D63 chain must include ultimate");
|
||||
const faults = Deno.core.ops.op_sql("all", "SELECT id, record FROM tasks WHERE status = 'faulted'", []);
|
||||
assert(faults.length === 0, `Faulted branch tasks: ${JSON.stringify(faults)}`);
|
||||
} else {
|
||||
if (globalThis.p3Mode === "kill-worker") {
|
||||
const coordinator2 = await globalThis.p3Models.start({ agent: "coordinator", cwd: "/src/demo" }, BACKGROUND_CONTEXT);
|
||||
await fs.write("/coordinator", String(coordinator2.id));
|
||||
await coordinator2.submit({ type: "input", content: "begin", requestId: "p3-begin" }, BACKGROUND_CONTEXT);
|
||||
}
|
||||
harness.resume();
|
||||
if (globalThis.p3Mode === "kill-worker") await new Promise(() => {
|
||||
if (globalThis.p3Mode.startsWith("kill-")) await new Promise(() => {
|
||||
});
|
||||
const coordinator = await harness.conversation(Number(await fs.read("/coordinator")), BACKGROUND_CONTEXT);
|
||||
const deadline = Date.now() + 3e4;
|
||||
while (!JSON.stringify((await coordinator.context(BACKGROUND_CONTEXT)).entries).includes("scenario complete")) {
|
||||
if (Date.now() > deadline) throw new Error(`P3 did not finish: ${JSON.stringify((await coordinator.context(BACKGROUND_CONTEXT)).entries)}`);
|
||||
await new Promise((resolve2) => setTimeout(resolve2, 0));
|
||||
const deadline = Date.now() + 6e4;
|
||||
const wait = async (predicate, message) => {
|
||||
while (!await predicate()) {
|
||||
assert(Date.now() < deadline, `${message}: ${JSON.stringify(Deno.core.ops.op_sql("all", "SELECT id, kind, status, record FROM tasks WHERE status != 'terminal'", []))}`);
|
||||
await new Promise((resolve2) => setTimeout(resolve2, 0));
|
||||
}
|
||||
};
|
||||
const replies = async () => (await ultimate.context(BACKGROUND_CONTEXT)).entries.filter((entry2) => entry2.kind === "board.reply");
|
||||
await wait(async () => (await replies()).length > 0, "No branch reply");
|
||||
const received = await replies();
|
||||
const coordinator = await harness.conversation(received[0].data.from, BACKGROUND_CONTEXT);
|
||||
assert((await coordinator.agent(BACKGROUND_CONTEXT)).instructions === "P3 role:coordinator", "Branch must use coordinator definition");
|
||||
assert((await conversationChains(harness, BACKGROUND_CONTEXT)(coordinator.id)).includes(String(ultimate.id)), "D63 must include ultimate");
|
||||
if (!probe) {
|
||||
await wait(async () => Object.keys((await harness.snapshot(globalThis.p3Workspaces.doc, root.id, BACKGROUND_CONTEXT)).results).length === 1, "No landing");
|
||||
await wait(async () => (await harness.snapshot(globalThis.p3Workspaces.doc, root.id, BACKGROUND_CONTEXT)).queue.length === 0, "Lander did not report");
|
||||
}
|
||||
await harness.waitForIdle(BACKGROUND_CONTEXT);
|
||||
await wait(async () => Deno.core.ops.op_sql("all", "SELECT id FROM tasks WHERE status NOT IN ('terminal', 'faulted')", []).length === 0, "Background tasks did not settle");
|
||||
const entries2 = (await coordinator.context(BACKGROUND_CONTEXT)).entries;
|
||||
const reports = entries2.filter((entry2) => entry2.kind === "subagents.report");
|
||||
assert(reports.length === 2, `Expected two child reports: ${JSON.stringify(entries2)}`);
|
||||
assert(new Set(reports.map((report2) => report2.data.sender)).size === 2, "Expected one report from each child");
|
||||
for (const answer2 of ["worker complete", "approve"]) assert(reports.filter((report2) => JSON.stringify(report2).includes(answer2)).length === 1, `Expected exactly one report for ${answer2}`);
|
||||
assert(JSON.stringify(entries2).includes("scenario complete"), "Coordinator did not finish");
|
||||
const faults = Deno.core.ops.op_sql("all", "SELECT id FROM tasks WHERE status = 'faulted'", []);
|
||||
if (probe) assert(received.length === 1 && received[0].data.text === "branch complete" && !received[0].data.failed, "Expected one branch answer");
|
||||
else {
|
||||
const reports = entries2.filter((entry2) => entry2.kind === "subagents.report");
|
||||
assert(reports.length === 2 && new Set(reports.map((entry2) => entry2.data.sender)).size === 2, `Expected one report per child: ${JSON.stringify(reports)}`);
|
||||
for (const answer2 of ["worker complete", "approve"]) assert(reports.filter((entry2) => JSON.stringify(entry2).includes(answer2)).length === 1, `Duplicate child answer: ${answer2}`);
|
||||
const index = await harness.snapshot(globalThis.p3Workspaces.doc, root.id, BACKGROUND_CONTEXT);
|
||||
const worker = Object.values(index.items).find((item) => item.source.startsWith("/worktrees/"));
|
||||
assert(worker?.state === "removed", "Worker checkout must be released");
|
||||
assert(!await fs.stat(worker.path).then(() => true, () => false), "Released checkout must not exist");
|
||||
assert(Object.values(index.results).every((result) => result.outcome === "landed"), `Landing failed: ${JSON.stringify(index)}`);
|
||||
assert((await globalThis.p3Host.eventLog.recent({ events: [{ kind: "event.workspaces.landed" }] })).length === 1, "Expected one landed event");
|
||||
const ultimateEntries = (await ultimate.context(BACKGROUND_CONTEXT)).entries;
|
||||
const landed = Object.values(index.results)[0];
|
||||
const cards = ultimateEntries.filter((entry2) => entry2.kind === "board.message" && entry2.data.text.startsWith("[lander] Submission") && entry2.data.text.includes(" landed as "));
|
||||
assert(cards.length === 1 && cards[0].data.from === coordinator.id && cards[0].data.text.includes(landed.sha), `Expected one landing card through messages: ${JSON.stringify(cards)}`);
|
||||
const repliesNow = await replies();
|
||||
assert(repliesNow.length === 1 && !repliesNow[0].data.failed && new Set(repliesNow.map((entry2) => entry2.data.id)).size === repliesNow.length, `Duplicate board.reply: ${JSON.stringify(repliesNow)}`);
|
||||
assert(entries2.filter((entry2) => entry2.kind === "workspaces.lander-report").length === 1, "Expected one lander report");
|
||||
}
|
||||
const faults = Deno.core.ops.op_sql("all", "SELECT id, record FROM tasks WHERE status = 'faulted'", []);
|
||||
assert(faults.length === 0, `Faulted tasks: ${JSON.stringify(faults)}`);
|
||||
await fs.write("/p3-idle", "yes");
|
||||
}
|
||||
} finally {
|
||||
await loader.close();
|
||||
|
||||
+83
-65
@@ -1,4 +1,4 @@
|
||||
/** P3 exit scaffold: real world git, scripted coordination, and worker SIGKILL recovery. */
|
||||
/** P3 exit contract: one branch, two child answers, and one landing across three crash boundaries. */
|
||||
import { BACKGROUND_CONTEXT as context, awaitWithContext } from '@earendil-works/chord/context';
|
||||
import { createModels } from '@earendil-works/pi-ai/models';
|
||||
import { Harness, createRegistry, defineExtension, defineTool } from '@earendil-works/pi-durable';
|
||||
@@ -10,14 +10,17 @@ import { scriptedModel, testReplay, type Rule } from './scripted-model';
|
||||
const assert = (ok: any, message: string) => { if (!ok) throw new Error(message); };
|
||||
testReplay();
|
||||
if (globalThis.p3Mode !== 'unit') {
|
||||
const workerId = (transcript: any) => Number(/Started subagent (\d+)/.exec(JSON.stringify(transcript.messages))?.[1]);
|
||||
const rules: Rule[] = [
|
||||
{ id: 'ultimate-branch', role: 'ultimate', match: { kind: 'user', text: 'launch branch' }, answer: { tools: [{ name: 'branch', args: { repo: '/src/demo', prompt: 'branch probe' } }] } },
|
||||
{ id: 'ultimate-branch', role: 'ultimate', match: { kind: 'user', text: 'launch scenario' }, answer: { tools: [{ name: 'branch', args: { repo: '/src/demo', isolated: true, prompt: 'begin' } }] } },
|
||||
{ id: 'ultimate-probe', role: 'ultimate', match: { kind: 'user', text: 'launch branch' }, answer: { tools: [{ name: 'branch', args: { repo: '/src/demo', prompt: 'branch probe' } }] } },
|
||||
{ id: 'coordinator-probe', role: 'coordinator', match: { kind: 'user', text: 'branch probe' }, answer: { text: 'branch complete' } },
|
||||
{ id: 'ultimate-receipt', role: 'ultimate', match: { text: '' }, answer: { text: 'Receipt noted.' } },
|
||||
{ id: 'coordinator-echo', role: 'coordinator', match: { kind: 'user', text: 'Receipt noted.' }, answer: { text: 'Acknowledged.' } },
|
||||
{ id: 'start-worker', role: 'coordinator', match: { kind: 'user', text: 'begin' }, answer: { tools: [{ name: 'subagent', args: { action: 'start', agent: 'worker', isolate: 'none', task: 'build feature', background: true } }] } },
|
||||
{ id: 'start-worker', role: 'coordinator', match: { kind: 'user', text: 'begin' }, answer: { tools: [{ name: 'subagent', args: { action: 'start', agent: 'worker', isolate: 'worktree', task: 'build feature', background: true } }] } },
|
||||
{ id: 'start-reviewer', role: 'coordinator', match: { kind: 'user', text: 'worker complete' }, answer: { tools: [{ name: 'subagent', args: { action: 'start', agent: 'reviewer', isolate: 'none', task: 'review feature', background: true } }] } },
|
||||
{ id: 'coordinator-done', role: 'coordinator', match: { kind: 'user', text: 'approve' }, answer: { text: 'scenario complete' } },
|
||||
{ id: 'land-worker', role: 'coordinator', match: { kind: 'user', text: 'approve' }, answer: transcript => ({ tools: [{ name: 'subagent', args: { action: 'land', id: workerId(transcript) } }] }) },
|
||||
{ id: 'submit', role: 'coordinator', match: { text: 'Landed commits' }, answer: { tools: [{ name: 'workspace', args: { action: 'submit', divergence: 'None' } }] } },
|
||||
{ id: 'coordinator-wait', role: 'coordinator', match: { text: '' }, answer: { text: 'Wait for child report.' } },
|
||||
{ id: 'worker-write', role: 'worker', match: { kind: 'user', text: 'build feature' }, answer: { tools: [{ name: 'write-feature', args: {} }] } },
|
||||
{ id: 'worker-commit', role: 'worker', match: { text: 'feature written' }, answer: { tools: [{ name: 'commit-feature', args: {} }] } },
|
||||
@@ -30,30 +33,49 @@ if (globalThis.p3Mode !== 'unit') {
|
||||
const storage = await openStorage(globalThis.storagePath), registry = createRegistry();
|
||||
const harness = await Harness.open(storage, { models, registry, settings: { retry: { enabled: false }, get extensions() { return registry.snapshot().installed(); } } }, context);
|
||||
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' }, instructions: 'P3 role:ultimate', cwd: '/src/demo' } });
|
||||
const git = async (...args: string[]) => {
|
||||
const result = await exec(['/bin/git.wasm', ...args], { cwd: '/src/demo', env: [['GIT_AUTHOR_NAME', 'Test'], ['GIT_AUTHOR_EMAIL', 'test@example.com'], ['GIT_COMMITTER_NAME', 'Test'], ['GIT_COMMITTER_EMAIL', 'test@example.com']] });
|
||||
// Stop after the durable lander commit records landed.sha, before its event/report commit.
|
||||
if (globalThis.p3Mode === 'kill-forward') {
|
||||
const install = registry.install.bind(registry);
|
||||
registry.install = (extension: any) => install(extension.name !== 'workspaces' ? extension : { ...extension, tasks: extension.tasks.map((task: any) => task.definition.name !== 'workspaces.lander' ? task : { definition: { ...task.definition, phases: { ...task.definition.phases, land: async (record: any, runtime: any, ctx: any) => {
|
||||
const commit = runtime.commit.bind(runtime);
|
||||
return task.definition.phases.land(record, { ...runtime, commit: async (...args: any[]) => {
|
||||
const result = await commit(...args);
|
||||
const index = await harness.snapshot(globalThis.p3Workspaces.doc, root.id, context);
|
||||
if (index.queue.some((entry: any) => entry.landed)) {
|
||||
await fs.write('/forward-recorded.tmp', JSON.stringify(index.queue));
|
||||
await fs.rename('/forward-recorded.tmp', '/forward-recorded');
|
||||
await awaitWithContext(new Promise(() => {}), ctx);
|
||||
}
|
||||
return result;
|
||||
} }, ctx);
|
||||
} } } }) });
|
||||
}
|
||||
const git = async (cwd: string, ...args: string[]) => {
|
||||
const result = await exec(['/bin/git.wasm', ...args], { cwd, env: [['GIT_AUTHOR_NAME', 'Test'], ['GIT_AUTHOR_EMAIL', 'test@example.com'], ['GIT_COMMITTER_NAME', 'Test'], ['GIT_COMMITTER_EMAIL', 'test@example.com']] });
|
||||
assert(result.code === 0, `World git ${args}: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
};
|
||||
const tools = defineExtension({ name: 'p3-tools', tools: [
|
||||
defineTool({ name: 'write-feature', description: 'Write the demo feature', parameters: obj({}), replay: 'safe', execute: async (_args, _api, ctx) => {
|
||||
defineTool({ name: 'write-feature', description: 'Write the isolated feature', parameters: obj({}), replay: 'safe', execute: async (_args, api, ctx) => {
|
||||
const cwd = (await api.agent(ctx)).cwd;
|
||||
assert(cwd.startsWith('/worktrees/') && await fs.read(`${cwd}/README.md`) === 'P3 demo\n', 'Worker must start in a prepared checkout');
|
||||
if (globalThis.p3Mode === 'kill-worker') {
|
||||
await fs.write('/worker-running', 'yes');
|
||||
await awaitWithContext(new Promise(() => {}), ctx);
|
||||
}
|
||||
await fs.write('/src/demo/feature.txt', 'P3 feature\n');
|
||||
await fs.write(`${cwd}/feature.txt`, 'P3 feature\n');
|
||||
return { output: [{ type: 'text', text: 'feature written' }] };
|
||||
} }),
|
||||
defineTool({ name: 'commit-feature', description: 'Commit the demo feature', parameters: obj({}), replay: 'safe', execute: async () => {
|
||||
await git('add', 'feature.txt');
|
||||
// A replay after a committed tool side effect must not create a second commit.
|
||||
if ((await git('status', '--porcelain')).stdout.trim()) await git('commit', '--no-gpg-sign', '-m', 'P3 feature');
|
||||
defineTool({ name: 'commit-feature', description: 'Commit the isolated feature', parameters: obj({}), replay: 'safe', execute: async (_args, api, ctx) => {
|
||||
const cwd = (await api.agent(ctx)).cwd;
|
||||
await git(cwd, 'add', 'feature.txt');
|
||||
if ((await git(cwd, 'status', '--porcelain')).stdout.trim()) await git(cwd, 'commit', '--no-gpg-sign', '-m', 'P3 feature');
|
||||
return { output: [{ type: 'text', text: 'feature committed' }] };
|
||||
} }),
|
||||
defineTool({ name: 'review-feature', description: 'Verify the committed demo feature', parameters: obj({}), replay: 'safe', execute: async () => {
|
||||
assert(await fs.read('/src/demo/feature.txt') === 'P3 feature\n', 'Reviewer lost feature');
|
||||
const result = await git('show', 'HEAD:feature.txt');
|
||||
assert(result.stdout === 'P3 feature\n', 'Reviewer did not read the committed feature');
|
||||
defineTool({ name: 'review-feature', description: 'Verify the worker commit before landing', parameters: obj({}), replay: 'safe', execute: async (_args, _api, ctx) => {
|
||||
const index = await harness.snapshot(globalThis.p3Workspaces.doc, root.id, ctx);
|
||||
const worker = Object.values(index.items).find((item: any) => item.source.startsWith('/worktrees/')) as any;
|
||||
assert(worker && (await git(worker.path, 'show', 'HEAD:feature.txt')).stdout === 'P3 feature\n', 'Reviewer must approve the worker commit');
|
||||
return { output: [{ type: 'text', text: 'feature verified' }] };
|
||||
} }),
|
||||
] });
|
||||
@@ -61,62 +83,58 @@ if (globalThis.p3Mode !== 'unit') {
|
||||
assert(!loader.state().error, `Extension load: ${loader.state().error}`);
|
||||
try {
|
||||
if (globalThis.p3Mode === 'load-all') {
|
||||
assert((await root.agent(context)).tools.some((tool: any) => tool.name === 'branch'), 'Every extension must load, including messages');
|
||||
} else if (globalThis.p3Mode === 'branch' || globalThis.p3Mode === 'branch-resume') {
|
||||
assert((await root.agent(context)).tools.some((tool: any) => tool.name === 'branch'), 'Every extension must load');
|
||||
} else {
|
||||
const ultimate = (await harness.conversation(await globalThis.p3Subagents.standing('ultimate', context), context))!;
|
||||
harness.resume();
|
||||
if (globalThis.p3Mode === 'branch') await (await ultimate.submit({ type: 'input', content: 'launch branch', requestId: 'p3-branch' }, context)).wait(context);
|
||||
const probe = globalThis.p3Mode.startsWith('branch');
|
||||
if (globalThis.p3Mode === 'branch' || globalThis.p3Mode.startsWith('kill-')) {
|
||||
await (await ultimate.submit({ type: 'input', content: probe ? 'launch branch' : 'launch scenario', requestId: 'p3-launch' }, context)).wait(context);
|
||||
const entries = (await ultimate.context(context)).entries;
|
||||
assert(!JSON.stringify(entries).includes('"isError":true'), `Branch tool failed: ${JSON.stringify(entries)}`);
|
||||
}
|
||||
if (globalThis.p3Mode.startsWith('kill-')) await new Promise(() => {});
|
||||
const deadline = Date.now() + 60000;
|
||||
const wait = async (predicate: () => Promise<boolean>, message: string) => {
|
||||
while (!await predicate()) {
|
||||
assert(Date.now() < deadline, `${message}: ${JSON.stringify(Deno.core.ops.op_sql('all', "SELECT id, kind, status, record FROM tasks WHERE status != 'terminal'", []))}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
};
|
||||
const replies = async () => (await ultimate.context(context)).entries.filter((entry: any) => entry.kind === 'board.reply');
|
||||
const deadline = Date.now() + 30000;
|
||||
while ((await replies()).length === 0) {
|
||||
if (Date.now() > deadline) throw new Error(`No branch reply: ${JSON.stringify((await ultimate.context(context)).entries)}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await wait(async () => (await replies()).length > 0, 'No branch reply');
|
||||
const received = await replies();
|
||||
const coordinator = (await harness.conversation(received[0].data.from, context))!;
|
||||
assert((await coordinator.agent(context)).instructions === 'P3 role:coordinator', 'Branch must use coordinator definition');
|
||||
assert((await conversationChains(harness, context)(coordinator.id)).includes(String(ultimate.id)), 'D63 must include ultimate');
|
||||
if (!probe) {
|
||||
await wait(async () => Object.keys((await harness.snapshot(globalThis.p3Workspaces.doc, root.id, context)).results).length === 1, 'No landing');
|
||||
await wait(async () => (await harness.snapshot(globalThis.p3Workspaces.doc, root.id, context)).queue.length === 0, 'Lander did not report');
|
||||
}
|
||||
await harness.waitForIdle(context);
|
||||
// Routes are background tasks. Idle alone does not prove that they settled without a fault.
|
||||
while (Deno.core.ops.op_sql('all', "SELECT id FROM tasks WHERE json_extract(kind, '$') IN ('board.route', 'board.delivery') AND status NOT IN ('terminal', 'faulted')", []).length) {
|
||||
if (Date.now() > deadline) throw new Error('Branch routing did not settle');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await wait(async () => Deno.core.ops.op_sql('all', "SELECT id FROM tasks WHERE status NOT IN ('terminal', 'faulted')", []).length === 0, 'Background tasks did not settle');
|
||||
const entries = (await coordinator.context(context)).entries;
|
||||
if (probe) assert(received.length === 1 && received[0].data.text === 'branch complete' && !received[0].data.failed, 'Expected one branch answer');
|
||||
else {
|
||||
const reports = entries.filter((entry: any) => entry.kind === 'subagents.report');
|
||||
assert(reports.length === 2 && new Set(reports.map((entry: any) => entry.data.sender)).size === 2, `Expected one report per child: ${JSON.stringify(reports)}`);
|
||||
for (const answer of ['worker complete', 'approve']) assert(reports.filter((entry: any) => JSON.stringify(entry).includes(answer)).length === 1, `Duplicate child answer: ${answer}`);
|
||||
const index = await harness.snapshot(globalThis.p3Workspaces.doc, root.id, context);
|
||||
const worker = Object.values(index.items).find((item: any) => item.source.startsWith('/worktrees/')) as any;
|
||||
assert(worker?.state === 'removed', 'Worker checkout must be released');
|
||||
assert(!await fs.stat(worker.path).then(() => true, () => false), 'Released checkout must not exist');
|
||||
assert(Object.values(index.results).every((result: any) => result.outcome === 'landed'), `Landing failed: ${JSON.stringify(index)}`);
|
||||
assert((await globalThis.p3Host.eventLog.recent({ events: [{ kind: 'event.workspaces.landed' }] })).length === 1, 'Expected one landed event');
|
||||
const ultimateEntries = (await ultimate.context(context)).entries;
|
||||
const landed = Object.values(index.results)[0] as any;
|
||||
const cards = ultimateEntries.filter((entry: any) => entry.kind === 'board.message' && entry.data.text.startsWith('[lander] Submission') && entry.data.text.includes(' landed as '));
|
||||
assert(cards.length === 1 && cards[0].data.from === coordinator.id && cards[0].data.text.includes(landed.sha), `Expected one landing card through messages: ${JSON.stringify(cards)}`);
|
||||
const repliesNow = await replies();
|
||||
assert(repliesNow.length === 1 && !repliesNow[0].data.failed && new Set(repliesNow.map((entry: any) => entry.data.id)).size === repliesNow.length, `Duplicate board.reply: ${JSON.stringify(repliesNow)}`);
|
||||
assert(entries.filter((entry: any) => entry.kind === 'workspaces.lander-report').length === 1, 'Expected one lander report');
|
||||
}
|
||||
const received = await replies();
|
||||
assert(received.length === 1 && received[0].data.text === 'branch complete' && !received[0].data.failed, `Expected one coordinator answer: ${JSON.stringify(received)}`);
|
||||
const branch = (await harness.conversation(received[0].data.from, context))!;
|
||||
assert((await branch.agent(context)).instructions === 'P3 role:coordinator', 'Branch must use the coordinator definition');
|
||||
assert((await conversationChains(harness, context)(branch.id)).includes(String(ultimate.id)), 'D63 chain must include ultimate');
|
||||
const faults = Deno.core.ops.op_sql('all', "SELECT id, record FROM tasks WHERE status = 'faulted'", []);
|
||||
assert(faults.length === 0, `Faulted branch tasks: ${JSON.stringify(faults)}`);
|
||||
} else {
|
||||
if (globalThis.p3Mode === 'kill-worker') {
|
||||
const coordinator = await globalThis.p3Models.start({ agent: 'coordinator', cwd: '/src/demo' }, context);
|
||||
await fs.write('/coordinator', String(coordinator.id));
|
||||
await coordinator.submit({ type: 'input', content: 'begin', requestId: 'p3-begin' }, context);
|
||||
}
|
||||
harness.resume();
|
||||
if (globalThis.p3Mode === 'kill-worker') await new Promise(() => {});
|
||||
const coordinator = (await harness.conversation(Number(await fs.read('/coordinator')), context))!;
|
||||
// waitForIdle does not wait for background children. Poll the durable outcome first.
|
||||
const deadline = Date.now() + 30000;
|
||||
while (!JSON.stringify((await coordinator.context(context)).entries).includes('scenario complete')) {
|
||||
if (Date.now() > deadline) throw new Error(`P3 did not finish: ${JSON.stringify((await coordinator.context(context)).entries)}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
await harness.waitForIdle(context);
|
||||
const entries = (await coordinator.context(context)).entries;
|
||||
const reports = entries.filter((entry: any) => entry.kind === 'subagents.report');
|
||||
assert(reports.length === 2, `Expected two child reports: ${JSON.stringify(entries)}`);
|
||||
assert(new Set(reports.map((report: any) => report.data.sender)).size === 2, 'Expected one report from each child');
|
||||
for (const answer of ['worker complete', 'approve']) assert(reports.filter((report: any) => JSON.stringify(report).includes(answer)).length === 1, `Expected exactly one report for ${answer}`);
|
||||
assert(JSON.stringify(entries).includes('scenario complete'), 'Coordinator did not finish');
|
||||
const faults = Deno.core.ops.op_sql('all', "SELECT id FROM tasks WHERE status = 'faulted'", []);
|
||||
assert(faults.length === 0, `Faulted tasks: ${JSON.stringify(faults)}`);
|
||||
await fs.write('/p3-idle', 'yes');
|
||||
assert(faults.length === 0, `Faulted tasks: ${JSON.stringify(faults)}`);
|
||||
}
|
||||
} finally { await loader.close(); await harness.close(context); }
|
||||
}
|
||||
// branch_round_trip covers @ultimate's branch call, one board.reply, D63, and an idle reopen.
|
||||
// The worker SIGKILL scaffold remains separate until the T52 scenario connects the landing path.
|
||||
// PENDING T52: worker isolate:worktree, subagent land, workspace submit, and worktree release.
|
||||
// Kill point 2 (ignored until T52): SIGKILL while the lander check waits for /check-release.
|
||||
// Restart, release the marker, and run to idle.
|
||||
// Kill point 3 (ignored until T52): SIGKILL after the target fast-forward log entry.
|
||||
// Restart and run to idle. Assert one target commit, workspaces.landed event, and landing card.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
/** P4 fixture provider for the real console: all project work uses the installed bash tool. */
|
||||
import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
|
||||
|
||||
const main = `fn greeting(name: &str) -> String { format!("hello {name}") }
|
||||
fn main() {
|
||||
let Some(name) = std::env::args().nth(1) else { std::process::exit(7) };
|
||||
println!("{}", serde_json::json!({"greeting": greeting(&name)}));
|
||||
}
|
||||
#[test]
|
||||
fn greeting_test() { assert_eq!(greeting("world"), "hello world", "P4_expected_failure"); }
|
||||
`;
|
||||
const write = (path: string, text: string) => `cat > ${path} <<'P4_FILE'\n${text}\nP4_FILE\n`;
|
||||
const phases = [
|
||||
`set -e
|
||||
mkdir -p /src/demo-cli/src /src/demo-node /tmp
|
||||
cd /src/demo-cli
|
||||
if test -d target; then cargo clean; fi
|
||||
${write('Cargo.toml', '[package]\nname="demo-cli"\nversion="0.1.0"\nedition="2024"\n[workspace]\n')}
|
||||
${write('src/main.rs', main.replace('"hello world", "P4_expected_failure"', '"hello wrong", "P4_expected_failure"'))}
|
||||
cargo add serde_json
|
||||
cargo fmt
|
||||
set +e
|
||||
cargo test -- --nocapture > /tmp/p4-fail.stdout 2> /tmp/p4-fail.stderr
|
||||
code=$?
|
||||
set -e
|
||||
cat /tmp/p4-fail.stdout /tmp/p4-fail.stderr
|
||||
test "$code" = 101
|
||||
case "$(cat /tmp/p4-fail.stderr)" in *P4_expected_failure*) ;; *) exit 1 ;; esac
|
||||
printf 'P4_PHASE_0_OK\n'`,
|
||||
`set -e
|
||||
cd /src/demo-cli
|
||||
${write('src/main.rs', main)}
|
||||
cargo fmt
|
||||
cargo test -- --nocapture
|
||||
cargo build --release
|
||||
test -f target/wasm32-wasip2/release/demo-cli.wasm
|
||||
sh -c '/src/demo-cli/target/wasm32-wasip2/release/demo-cli.wasm world' > /tmp/p4-cli.json
|
||||
case "$(cat /tmp/p4-cli.json)" in *'"greeting":"hello world"'*) ;; *) exit 1 ;; esac
|
||||
set +e
|
||||
sh -c '/src/demo-cli/target/wasm32-wasip2/release/demo-cli.wasm'
|
||||
code=$?
|
||||
set -e
|
||||
test "$code" = 7
|
||||
printf 'P4_PHASE_1_OK\n'`,
|
||||
`set -e
|
||||
${write('/src/demo-node/package.json', '{"scripts":{"test":"node test.cjs"}}')}
|
||||
${write('/src/demo-node/test.cjs', 'const fs = require("node:fs");\nrequire("node:assert/strict").equal(2 + 2, 4);\nfs.writeFileSync("result.txt", "P4 npm test passed\\n");')}
|
||||
id=$(box create --ttl 600)
|
||||
printf '%s\n' "$id" > /tmp/p4-box-id
|
||||
trap 'box rm "$id"' EXIT
|
||||
box put "$id" /src/demo-node /work
|
||||
box exec "$id" --timeout 120 -- sh -c 'cd /work && npm test'
|
||||
box get "$id" /work/result.txt /src/demo-node/result.txt
|
||||
test "$(cat /src/demo-node/result.txt)" = 'P4 npm test passed'
|
||||
box rm "$id"
|
||||
trap - EXIT
|
||||
printf 'P4_PHASE_2_OK\n'`,
|
||||
`set -e
|
||||
undo list --conversation 1 > /tmp/p4-undo.json
|
||||
box ls > /tmp/p4-boxes
|
||||
printf 'P4_PHASE_3_OK\n'`,
|
||||
];
|
||||
export default function p4(host: any) {
|
||||
// Replace the console's transport before resume. No real model request can occur.
|
||||
const faux = fauxProvider({ provider: 'openai', models: [{ id: 'gpt-6.1-sol' }], tokensPerSecond: 1000000 });
|
||||
const respond = (transcript: any): any => {
|
||||
faux.appendResponses([respond]);
|
||||
const messages = transcript.messages;
|
||||
const input = messages.findLast((message: any) => message.role === 'user');
|
||||
const text = (message: any) => typeof message.content === 'string' ? message.content : (message.content ?? []).filter((part: any) => part.type === 'text').map((part: any) => part.text).join('\n');
|
||||
const round = /P4 start (\d+)/.exec(text(input))?.[1];
|
||||
if (!round) throw new Error(`Unexpected P4 input: ${JSON.stringify(input)}`);
|
||||
const last = messages.findLast((message: any) => message.role !== 'system');
|
||||
let phase = 0;
|
||||
if (last.role !== 'user') {
|
||||
const prior = /p4-\d+-(\d+)/.exec(last.toolCallId)?.[1];
|
||||
if (last.isError || prior === undefined || !text(last).includes(`P4_PHASE_${prior}_OK`)) throw new Error(`P4 bash failed: ${JSON.stringify(last)}`);
|
||||
phase = Number(prior) + 1;
|
||||
}
|
||||
if (phase === phases.length) return fauxAssistantMessage(`P4_COMPLETE_${round}`);
|
||||
return fauxAssistantMessage([fauxToolCall('bash', { command: phases[phase], timeout: 300 }, { id: `p4-${round}-${phase}` })], { stopReason: 'toolUse' });
|
||||
};
|
||||
faux.setResponses([respond]);
|
||||
host.models().setProvider(faux.provider);
|
||||
return { name: 'p4' };
|
||||
}
|
||||
@@ -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" },
|
||||
"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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
|
||||
/** Ultimate server question cases, with a real world fixture. */
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
|
||||
import {expect,it,describe,afterEach} from './goal-questions-test-support'
|
||||
import {fixture as openFixture} from './goal-questions-fixture'
|
||||
const {answerText,userActions} = await import(`${globalThis.goalBase}/questions/server.ts`)
|
||||
const {checkQuestions,gist} = await import(`${globalThis.goalBase}/messages/questions.ts`)
|
||||
const hosts:any[]=[]
|
||||
afterEach(async()=>{for(const h of hosts.splice(0)) await h.close()})
|
||||
async function fixture(){const f=await openFixture();hosts.push(f.opened);return f}
|
||||
const decisions = [
|
||||
{
|
||||
text: 'Scope',
|
||||
kind: 'choice',
|
||||
ref: '1',
|
||||
options: [
|
||||
{ label: 'all of it', key: 'a', recommended: true },
|
||||
{ label: 'the format only', key: 'b' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Suites',
|
||||
kind: 'multi',
|
||||
ref: '2',
|
||||
options: [
|
||||
{ label: 'unit', key: 'a', recommended: true },
|
||||
{ label: 'e2e', key: 'b' },
|
||||
{ label: 'lint', key: 'c', recommended: true },
|
||||
],
|
||||
},
|
||||
{ text: 'Land it?', kind: 'confirm', ref: '3' },
|
||||
{ text: 'How finished is it?', kind: 'scale', ref: '4', scale: { min: 1, max: 5 } },
|
||||
{ text: 'Name', kind: 'text', ref: '5' },
|
||||
]
|
||||
|
||||
describe('questions on a message', () => {
|
||||
it('are stored as the tool validated them, only on reply-expected messages to the user', async () => {
|
||||
const f = await fixture()
|
||||
const id = await f.ultimate()
|
||||
const sent = await f.ask(id, { text: 'Decide these.', questions: decisions })
|
||||
expect(sent.error).toBeUndefined()
|
||||
expect(sent.message).toMatchObject({ state: 'delivered', questions: decisions })
|
||||
const oneWay = await f.ask(id, { text: 'FYI', reply: false, questions: decisions })
|
||||
expect(oneWay.error).toContain('reply-expected message to the user')
|
||||
expect(oneWay.message?.text).toBe('Decide these.')
|
||||
// To a conversation, never: questions are the user's card.
|
||||
const other = await f.opened.harness.createConversation(
|
||||
{ ownership: { kind: 'ownerless' } },
|
||||
context,
|
||||
)
|
||||
const toConversation = await f.ask(id, { to: `#${other.id}`, text: 'Hi', questions: decisions })
|
||||
expect(toConversation.error).toContain('reply-expected message to the user')
|
||||
expect((await f.log()).messages.filter((m) => m.questions)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('refuses questions the card could not answer, naming the question', async () => {
|
||||
const f = await fixture()
|
||||
const id = await f.ultimate()
|
||||
const refused = async (questions: unknown, reason: string) => {
|
||||
const { error } = await f.ask(id, { text: 'Decide.', questions })
|
||||
expect(error).toContain(reason)
|
||||
}
|
||||
await refused([], 'at least one question')
|
||||
await refused(
|
||||
[{ text: 'One?', kind: 'choice', options: [{ label: 'only' }] }],
|
||||
'question 1 needs at least two options',
|
||||
)
|
||||
await refused(
|
||||
[{ text: 'One?', kind: 'choice', ref: '3', options: [{ label: 'a' }, { label: ' a ' }] }],
|
||||
'question 3 repeats an option label',
|
||||
)
|
||||
await refused(
|
||||
[
|
||||
{
|
||||
text: 'One?',
|
||||
kind: 'choice',
|
||||
options: [
|
||||
{ label: 'x', key: 'a' },
|
||||
{ label: 'y', key: 'a' },
|
||||
],
|
||||
},
|
||||
],
|
||||
'repeats an option key',
|
||||
)
|
||||
await refused(
|
||||
[{ text: 'One?', kind: 'choice', options: [{ label: 'x' }, { label: 'Something else' }] }],
|
||||
'offers "Something else…" itself',
|
||||
)
|
||||
await refused([{ text: ' ', kind: 'confirm' }], 'question 1 has no text')
|
||||
await refused(
|
||||
[{ text: 'One?', kind: 'choice', options: [{ label: 'x' }, { label: ' ' }] }],
|
||||
'has an option without a label',
|
||||
)
|
||||
await refused(
|
||||
[{ text: 'Which?', kind: 'multi', options: [{ label: 'unit, fast' }, { label: 'e2e' }] }],
|
||||
'its labels hold no ", "',
|
||||
)
|
||||
await refused([{ text: 'Rate', kind: 'scale' }], 'needs scale')
|
||||
await refused([{ text: 'Rate', kind: 'scale', scale: { min: 1, max: 20 } }], 'at most 10 steps')
|
||||
await refused(
|
||||
[
|
||||
{ text: 'Same', kind: 'confirm', ref: '1' },
|
||||
{ text: 'Same', kind: 'confirm', ref: '2' },
|
||||
],
|
||||
"repeats another question's text",
|
||||
)
|
||||
await refused(
|
||||
[
|
||||
{ text: 'A', kind: 'confirm', ref: '1' },
|
||||
{ text: 'B', kind: 'confirm', ref: '1' },
|
||||
],
|
||||
"repeats another question's ref",
|
||||
)
|
||||
// A schema miss is the tool's own error.
|
||||
await refused([{ text: 'One?', kind: 'ranking' }], 'kind')
|
||||
expect((await f.log()).messages.filter((m) => m.to === 'user')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('answers in the message numbering for every kind, with a note after the answers', async () => {
|
||||
const f = await fixture()
|
||||
const id = await f.ultimate()
|
||||
const { message } = await f.ask(id, { text: 'Decide these.', questions: decisions })
|
||||
expect(userActions).toEqual(['answer', 'dismiss'])
|
||||
await expect(
|
||||
f.answer({ messageId: message!.id, answers: [{ question: 'Unknown', answer: 'Yes' }] }),
|
||||
).rejects.toThrow('Unknown question')
|
||||
await expect(
|
||||
f.answer({
|
||||
messageId: message!.id,
|
||||
answers: [
|
||||
{ question: 'Scope', answer: 'all of it' },
|
||||
{ question: 'Scope', answer: 'the format only' },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow('Unknown question')
|
||||
await expect(f.answer({ messageId: message!.id, answers: [] })).rejects.toThrow(
|
||||
'Invalid answers',
|
||||
)
|
||||
const results = await Promise.allSettled([
|
||||
f.answer({
|
||||
messageId: message!.id,
|
||||
answers: [
|
||||
{ question: 'Scope', answer: 'the format only' },
|
||||
{ question: 'Suites', answer: 'unit, lint' },
|
||||
{ question: 'Land it?', answer: 'Yes' },
|
||||
{ question: 'How finished is it?', answer: '4' },
|
||||
{ question: 'Name', answer: 'Questions' },
|
||||
],
|
||||
note: 'Also rename the tab.',
|
||||
}),
|
||||
f.answer({ messageId: message!.id, answers: [{ question: 'Scope', answer: 'all of it' }] }),
|
||||
])
|
||||
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1)
|
||||
await f.settled()
|
||||
const reply = (await f.log()).messages.find((m) => m.re === message!.id)!
|
||||
expect(reply.text).toBe(
|
||||
'1b. Scope: the format only\n\n2a,c. Suites: unit, lint\n\n3: Yes\n\n4: 4\n\n5: Questions\n\nNote: Also rename the tab.',
|
||||
)
|
||||
expect((await f.log()).messages.find((m) => m.id === message!.id)?.state).toBe('answered')
|
||||
if (!f.observed.some((text) => text.includes('2a,c. Suites: unit, lint')))
|
||||
throw new Error(`Answer absent from model input: ${JSON.stringify({observed:f.observed,entries:await f.entries(id)})}`)
|
||||
await expect(
|
||||
f.answer({ messageId: message!.id, answers: [{ question: 'Scope', answer: 'all of it' }] }),
|
||||
).rejects.toThrow('These questions are no longer open')
|
||||
})
|
||||
|
||||
it('sends a note on its own, and free text for a message without questions', async () => {
|
||||
const f = await fixture()
|
||||
const id = await f.ultimate()
|
||||
const { message } = await f.ask(id, { text: 'Decide these.', questions: decisions })
|
||||
await expect(f.answer({ messageId: message!.id, answers: [], note: ' ' })).rejects.toThrow(
|
||||
'Invalid answers',
|
||||
)
|
||||
await f.answer({ messageId: message!.id, answers: [], note: 'Later, after the demo.' })
|
||||
await f.settled()
|
||||
expect((await f.log()).messages.find((m) => m.re === message!.id)?.text).toBe(
|
||||
'Later, after the demo.',
|
||||
)
|
||||
const plain = (await f.ask(id, { text: 'What should the tab be called?' })).message!
|
||||
expect(plain.questions).toBeUndefined()
|
||||
await expect(f.answer({ messageId: plain.id, text: ' ' })).rejects.toThrow('Invalid answers')
|
||||
await expect(f.answer({ messageId: plain.id, text: 'x', note: 'y' })).rejects.toThrow(
|
||||
'Invalid answers',
|
||||
)
|
||||
const { id: reply } = await f.answer({ messageId: plain.id, text: ' Questions ' })
|
||||
expect((await f.log()).messages.find((m) => m.id === reply)).toMatchObject({
|
||||
from: 'user',
|
||||
re: plain.id,
|
||||
text: 'Questions',
|
||||
})
|
||||
await expect(f.answer({ messageId: plain.id, text: 'Again' })).rejects.toThrow(
|
||||
'These questions are no longer open',
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps @ultimate's open message open until its card answers it, naming it by its questions", async () => {
|
||||
const f = await fixture()
|
||||
const id = await f.ultimate()
|
||||
const { message } = await f.ask(id, { text: 'Decide these.', questions: decisions })
|
||||
await f.type(
|
||||
f.opened.root.id,
|
||||
`SEND ${JSON.stringify({ to: `#${id}`, text: 'Unrelated update' })}`,
|
||||
)
|
||||
await f.settled()
|
||||
expect((await f.log()).messages.find((m) => m.id === message!.id)?.state).toBe('delivered')
|
||||
await f.type(id, 'Something else entirely')
|
||||
const typed = await f.entries(id)
|
||||
const note = typed.findLast((entry) => entry.kind === 'board.open')!
|
||||
expect(note.data).toEqual({ ids: [message!.id] })
|
||||
expect(note.model?.[0]?.content).toMatch(
|
||||
new RegExp(
|
||||
`^Open questions to the user.*\\n- message ${message!.id}: questions 1\\. Scope; 2\\. Suites; 3\\. Land it\\?; 4\\. How finished is it\\?; 5\\. Name \\(asked just now\\)\\n`,
|
||||
's',
|
||||
),
|
||||
)
|
||||
expect(typed[typed.indexOf(note) + 1]).toMatchObject({ kind: 'pi.user' })
|
||||
await f.answer({ messageId: message!.id, answers: [{ question: 'Land it?', answer: 'No' }] })
|
||||
expect((await f.log()).messages.find((m) => m.id === message!.id)?.state).toBe('answered')
|
||||
await f.settled()
|
||||
const count = (await f.entries(id)).filter((entry) => entry.kind === 'board.open').length
|
||||
await f.type(id, 'Thanks')
|
||||
expect((await f.entries(id)).filter((entry) => entry.kind === 'board.open')).toHaveLength(count)
|
||||
})
|
||||
|
||||
it('dismisses one open question with a one-way note that starts no run, leaving the others open', async () => {
|
||||
const f = await fixture()
|
||||
const id = await f.ultimate()
|
||||
const first = (await f.ask(id, { text: 'Keep it running?', questions: [decisions[2]] }))
|
||||
.message!
|
||||
const second = (await f.ask(id, { text: 'Which layout?' })).message!
|
||||
const before = (await f.entries(id)).filter((entry) => entry.kind === 'pi.assistant').length
|
||||
const { id: note } = await f.dismiss(first.id)
|
||||
await f.settled()
|
||||
const log = (await f.log()).messages
|
||||
expect(log.find((m) => m.id === first.id)?.state).toBe('dismissed')
|
||||
expect(log.find((m) => m.id === second.id)?.state).toBe('delivered')
|
||||
expect(log.find((m) => m.id === note)).toMatchObject({
|
||||
from: 'user',
|
||||
to: id,
|
||||
re: first.id,
|
||||
reply: false,
|
||||
state: 'delivered',
|
||||
text: expect.stringContaining('(message ' + first.id + '): questions 3. Land it?'),
|
||||
})
|
||||
const after = await f.entries(id)
|
||||
expect(after.findLast((entry) => entry.kind === 'board.message')?.data).toMatchObject({
|
||||
id: note,
|
||||
delivery: 'note',
|
||||
})
|
||||
expect(after.filter((entry) => entry.kind === 'pi.assistant')).toHaveLength(before)
|
||||
await expect(f.dismiss(first.id)).rejects.toThrow('These questions are no longer open')
|
||||
await expect(f.answer({ messageId: first.id, text: 'Yes' })).rejects.toThrow(
|
||||
'These questions are no longer open',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// The messages.actions.settle case awaits the later actions port. T49 exposes no settlement API.
|
||||
describe('answer text', () => {
|
||||
it('names options by letter, several by letters, typed and rated answers by number, and unnumbered ones by text', () => {
|
||||
expect(
|
||||
answerText(
|
||||
[
|
||||
{ question: 'Scope', answer: 'all of it' },
|
||||
{ question: 'Suites', answer: 'e2e' },
|
||||
{ question: 'Name', answer: 'only the keyboard one' },
|
||||
],
|
||||
decisions,
|
||||
),
|
||||
).toBe('1a. Scope: all of it\n\n2b. Suites: e2e\n\n5: only the keyboard one')
|
||||
// A typed "Something else…" names no option: the number alone.
|
||||
expect(answerText([{ question: 'Scope', answer: 'neither' }], decisions)).toBe('1: neither')
|
||||
expect(
|
||||
answerText(
|
||||
[{ question: 'Land it?', answer: 'Yes' }],
|
||||
[{ text: 'Land it?', kind: 'confirm' }],
|
||||
),
|
||||
).toBe('Land it?: Yes')
|
||||
expect(answerText([], decisions, 'Just a thought.')).toBe('Just a thought.')
|
||||
})
|
||||
|
||||
it('drops fields that do not apply to a kind and trims what it keeps', () => {
|
||||
expect(
|
||||
checkQuestions([
|
||||
{
|
||||
text: ' Land it? ',
|
||||
kind: 'confirm',
|
||||
options: [{ label: 'x' }, { label: 'y' }],
|
||||
placeholder: 'no',
|
||||
},
|
||||
{
|
||||
text: 'Rate',
|
||||
kind: 'scale',
|
||||
scale: { min: 1, max: 3, low: ' low ', high: '' },
|
||||
placeholder: 'no',
|
||||
},
|
||||
{ text: 'Name', kind: 'text', placeholder: ' e.g. Questions ' },
|
||||
{
|
||||
text: 'Pick',
|
||||
kind: 'choice',
|
||||
options: [
|
||||
{ label: ' a ', detail: ' why ', recommended: false, preview: ' ' },
|
||||
{ label: 'b', key: 'b', recommended: true },
|
||||
],
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{ text: 'Land it?', kind: 'confirm' },
|
||||
{ text: 'Rate', kind: 'scale', scale: { min: 1, max: 3, low: 'low' } },
|
||||
{ text: 'Name', kind: 'text', placeholder: 'e.g. Questions' },
|
||||
{
|
||||
text: 'Pick',
|
||||
kind: 'choice',
|
||||
options: [
|
||||
{ label: 'a', detail: 'why' },
|
||||
{ label: 'b', key: 'b', recommended: true },
|
||||
],
|
||||
},
|
||||
])
|
||||
// Only a whole "Other" / "Something else" is the card's own option; a label that starts so is a real one.
|
||||
expect(
|
||||
checkQuestions([
|
||||
{
|
||||
text: 'Scope',
|
||||
kind: 'choice',
|
||||
options: [{ label: 'This repo' }, { label: 'Other repos too' }],
|
||||
},
|
||||
])[0].options!.map((o) => o.label),
|
||||
).toEqual(['This repo', 'Other repos too'])
|
||||
})
|
||||
|
||||
it('names a message by its questions in the open-questions note, else by its asking line', () => {
|
||||
expect(gist({ text: 'Decide.', questions: decisions })).toBe(
|
||||
'questions 1. Scope; 2. Suites; 3. Land it?; 4. How finished is it?; 5. Name',
|
||||
)
|
||||
expect(gist({ text: 'Done.\n\nShould I submit it?' })).toBe('Should I submit it?')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Stateless role/input rules for deterministic faux-model replay in integration tests. */
|
||||
import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
|
||||
export type Answer = { text?: string; tools?: { name: string; args: any }[] };
|
||||
export type Rule = { id: string; role: string; match: { kind?: string; text: string }; answer: Answer };
|
||||
export type Rule = { id: string; role: string; match: { kind?: string; text: string }; answer: Answer | ((transcript: any) => Answer) };
|
||||
export const textOf = (content: any): string => typeof content === 'string' ? content : (content ?? []).filter((part: any) => part.type === 'text').map((part: any) => part.text).join('\n');
|
||||
export function answerFor(rules: Rule[], transcript: any) {
|
||||
const system = JSON.stringify(transcript.messages.filter((message: any) => message.role === 'system'));
|
||||
@@ -9,7 +9,7 @@ export function answerFor(rules: Rule[], transcript: any) {
|
||||
const last = transcript.messages.findLast((message: any) => message.role !== 'system');
|
||||
const rule = rules.find(rule => rule.role === role && (!rule.match.kind || rule.match.kind === last?.role) && textOf(last?.content).includes(rule.match.text));
|
||||
if (!rule) throw new Error(`No scripted rule for ${role}: ${JSON.stringify(last)}`);
|
||||
return { rule: rule.id, answer: rule.answer };
|
||||
return { rule: rule.id, answer: typeof rule.answer === 'function' ? rule.answer(transcript) : rule.answer };
|
||||
}
|
||||
export function scriptedModel(rules: Rule[], report: (rule: string) => void) {
|
||||
const faux = fauxProvider({ tokensPerSecond: 100000 });
|
||||
@@ -33,6 +33,11 @@ export function testReplay() {
|
||||
const first = answerFor(rules, { messages: [system, input] });
|
||||
const replay = answerFor(JSON.parse(JSON.stringify(rules)), { messages: [system, { role: 'assistant', content: [] }, input] });
|
||||
if (JSON.stringify(first) !== JSON.stringify(replay)) throw new Error('Scripted replay changed the answer');
|
||||
const dynamic: Rule[] = [{ id: 'land', role: 'worker', match: { text: 'build' }, answer: transcript => ({ tools: [{ name: 'land', args: { id: /Started subagent (\d+)/.exec(JSON.stringify(transcript.messages))?.[1] } }] }) }];
|
||||
const history = { role: 'toolResult', content: 'Started subagent 42.' };
|
||||
const selected = answerFor(dynamic, { messages: [system, history, input] });
|
||||
const restarted = answerFor(dynamic, { messages: [system, { role: 'assistant', content: [] }, history, input] });
|
||||
if (JSON.stringify(selected) !== JSON.stringify(restarted) || selected.answer.tools?.[0].args.id !== '42') throw new Error('Dynamic tool identity changed on replay');
|
||||
const toolRules: Rule[] = [{ id: 'worker-done', role: 'worker', match: { text: 'committed' }, answer: { text: 'done' } }];
|
||||
if (answerFor(toolRules, { messages: [system, { role: 'toolResult', content: [{ type: 'text', text: 'committed' }] }] }).rule !== 'worker-done') throw new Error('Tool result rule did not match');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,161 @@
|
||||
/** Scripted acceptance cases run through the world extension loader and real guest Git. */
|
||||
import { BACKGROUND_CONTEXT as context, awaitWithContext } 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, defineExtension, defineTool } from '@earendil-works/pi-durable';
|
||||
import { obj, str } from '@earendil-works/pi-schema';
|
||||
import { loadExtensions, fs } from './extensions';
|
||||
import { openStorage } from './sqlite';
|
||||
const assert=(value:any,message:string)=>{if(!value)throw new Error(message)};
|
||||
const models=createModels(),faux=fauxProvider({tokensPerSecond:100000});models.setProvider(faux.provider);
|
||||
const registry=createRegistry(),storage=await openStorage(globalThis.storagePath);
|
||||
const harness=await Harness.open(storage,{models,registry,settings:{get extensions(){return registry.snapshot().installed()}}},context);
|
||||
const root=await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'},cwd:'/project'}});
|
||||
if(globalThis.workspacesMode==='kill') {
|
||||
const install=registry.install.bind(registry);
|
||||
registry.install=(extension:any)=>install(extension.name!=='workspaces'?extension:{...extension,tasks:extension.tasks.map((task:any)=>task.definition.name!=='workspaces.lander'?task:{definition:{...task.definition,phases:{...task.definition.phases,land:async(record:any,runtime:any,ctx:any)=>{
|
||||
const commit=runtime.commit.bind(runtime);
|
||||
await taskDefinition(record,runtime,ctx);
|
||||
async function taskDefinition(record:any,rt:any,c:any){
|
||||
await task.definition.phases.land(record,{...rt,commit:async(...args:any[])=>{
|
||||
await commit(...args);
|
||||
const index=await harness.snapshot(globalThis.workspaceService.doc,root.id,context);
|
||||
if(index.queue.some((entry:any)=>entry.landed)) {
|
||||
await fs.write('/push-ready','yes');await awaitWithContext(new Promise(()=>{}),c);
|
||||
}
|
||||
}},c);
|
||||
}
|
||||
}}}})});
|
||||
}
|
||||
const probe=defineExtension({name:'guest',tools:[defineTool({name:'implement',description:'Write and commit in the prepared checkout',parameters:obj({name:str()}),replay:'safe',execute:async(args:any,api:any,ctx:any)=>{
|
||||
const cwd=(await api.agent(ctx)).cwd;
|
||||
assert(cwd.startsWith('/worktrees/'),'First tool needs isolated cwd');
|
||||
assert((await fs.stat(`${cwd}/base.txt`)).kind==='file','Checkout must exist before first tool');
|
||||
const workspace=await globalThis.workspaceService.get(api.conversationId,ctx);
|
||||
assert(workspace.state==='ready','Preparation must finish');
|
||||
await fs.write(`${cwd}/${args.name}`,'feature');
|
||||
for(const args of [['add','.'],['-c','user.name=Test','-c','user.email=test@example.com','commit','--no-gpg-sign','-m','feature']])await globalThis.workspaceHost.git(cwd,...args);
|
||||
return {output:[{type:'text',text:'committed'}]};
|
||||
}})]});
|
||||
const route=(transcript:any):any=>{
|
||||
faux.appendResponses([route]);
|
||||
const last=transcript.messages.findLast((message:any)=>message.role!=='system');
|
||||
const text=typeof last.content==='string'?last.content:last.content.filter((part:any)=>part.type==='text').map((part:any)=>part.text).join('');
|
||||
if(last.role==='user'&&text.startsWith('CALL ')){const {tool,args}=JSON.parse(text.slice(5));return fauxAssistantMessage([fauxToolCall(tool,args)],{stopReason:'toolUse'});}
|
||||
if(last.role==='user'&&text==='IMPLEMENT')return fauxAssistantMessage([fauxToolCall('implement',{name:'feature.txt'})],{stopReason:'toolUse'});
|
||||
if(last.role==='user'&&text==='BROKEN')return fauxAssistantMessage([fauxToolCall('implement',{name:'broken.txt'})],{stopReason:'toolUse'});
|
||||
return fauxAssistantMessage('Done');
|
||||
};faux.setResponses([route]);
|
||||
const loader=await loadExtensions(harness,registry,storage,models,context,[probe]);
|
||||
assert(!loader.state().error,`Load: ${loader.state().error}`);
|
||||
const service=globalThis.workspaceService,host=globalThis.workspaceHost;
|
||||
const call=async(tool:string,args:any)=>{
|
||||
await(await root.submit({type:'input',content:`CALL ${JSON.stringify({tool,args})}`},context)).wait(context);
|
||||
const result=(await root.context(context)).entries.filter((entry:any)=>entry.kind==='pi.tool-result').at(-1)?.model?.[0] as any;
|
||||
assert(!result?.isError,`Tool error: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
};
|
||||
const submit=async(id:number)=>{
|
||||
const {Started}=await import(`file:///run/sausage/extensions/${await fs.read('/src/sausage/.git/sausage-last-good')}/extensions/messages/addresses.ts`);
|
||||
await harness.commit(async(tx:any)=>{(await tx.doc(Started,id)).by=root.id},context);
|
||||
const child=await harness.conversation(id,context);
|
||||
await(await child.submit({type:'input',content:`CALL ${JSON.stringify({tool:'workspace',args:{action:'submit',divergence:'None'}})}`},context)).wait(context);
|
||||
const result=(await child.context(context)).entries.filter((entry:any)=>entry.kind==='pi.tool-result').at(-1)?.model?.[0] as any;
|
||||
assert(!result?.isError,`Workspace tool: ${JSON.stringify(result)}`);
|
||||
return JSON.parse(result.content.filter((part:any)=>part.type==='text').map((part:any)=>part.text).join(''));
|
||||
};
|
||||
const children=async()=>{
|
||||
const {Subagents}=await import(`file:///run/sausage/extensions/${await fs.read('/src/sausage/.git/sausage-last-good')}/extensions/subagents/api.ts`);
|
||||
return Object.values((await harness.snapshot(Subagents,root.id,context)).agents) as any[];
|
||||
};
|
||||
const waitQueue=async()=>{
|
||||
const deadline=Date.now()+30000;
|
||||
while((await harness.snapshot(service.doc,root.id,context)).queue.length) {
|
||||
if(Date.now()>deadline)throw new Error(`Lander deadline: ${JSON.stringify(await harness.snapshot(service.doc,root.id,context))}`);
|
||||
await new Promise(r=>setTimeout(r,0));
|
||||
}
|
||||
await harness.waitForIdle(context);
|
||||
};
|
||||
const reports=async(id:number)=>(await(await harness.conversation(id,context)).context(context)).entries.filter((entry:any)=>entry.kind==='workspaces.lander-report');
|
||||
const notes=async()=>{
|
||||
const {Messages}=await import(`file:///run/sausage/extensions/${await fs.read('/src/sausage/.git/sausage-last-good')}/extensions/messages/api.ts`);
|
||||
const deadline=Date.now()+30000;
|
||||
for(;;) {
|
||||
const notes=((await harness.snapshot(Messages,root.id,context)).messages as any[]).filter(message=>message.text.startsWith('[lander] Submission')&&message.text.includes(' landed as '));
|
||||
if(!notes.some(note=>note.state==='sent'))return notes;
|
||||
assert(Date.now()<deadline,`Landing note delivery: ${JSON.stringify(notes)}`);
|
||||
await new Promise(r=>setTimeout(r,0));
|
||||
}
|
||||
};
|
||||
harness.resume();
|
||||
try {
|
||||
if(globalThis.workspacesMode==='resume') {
|
||||
await waitQueue();
|
||||
const index=await harness.snapshot(service.doc,root.id,context);
|
||||
const result=Object.values(index.results)[0] as any;
|
||||
assert(result?.outcome==='landed',`Recovery result ${JSON.stringify(index)}`);
|
||||
const [child]=await children();
|
||||
const report=await reports(child.conversationId);
|
||||
assert(report.length===1,'Restart must report once');
|
||||
assert(report[0].data.sha===result.sha,'Report must retain landed.sha');
|
||||
const delivered=await notes();
|
||||
assert(delivered.length===1&&delivered[0].state==='delivered'&&delivered[0].text.includes(result.sha),`Restart must notify the starter once with landed.sha: ${JSON.stringify(delivered)}`);
|
||||
assert(await host.git('/remote.git','rev-parse','main')===result.sha,`Push must retain landed.sha: ${JSON.stringify(result)}`);
|
||||
assert((await host.git('/project','log','--format=%s')).split('\n').filter((line:string)=>line.startsWith('Merge from')).length===1,'Restart must not merge again');
|
||||
assert((await host.eventLog.recent({events:[{kind:'event.workspaces.landed'}]})).length===1,'Restart must emit once');
|
||||
} else {
|
||||
await call('subagent',{action:'start',agent:'worker',task:globalThis.workspacesMode==='normal'?'BROKEN':'IMPLEMENT'});
|
||||
if(globalThis.workspacesMode==='normal') {
|
||||
const [broken]=await children();
|
||||
const before=globalThis.workspaceExecutions.length;
|
||||
assert(!await service.unavailable(broken.conversationId,context),'Ready VFS gate');
|
||||
assert(globalThis.workspaceExecutions.length===before,'Ready gate must not spawn Git');
|
||||
const checkout=await service.get(broken.conversationId,context);
|
||||
await fs.rename(`${checkout.path}/.git`,`${checkout.path}/.git.saved`);
|
||||
assert((await service.unavailable(broken.conversationId,context)).includes('missing or unregistered'),'Gate must reject missing registration');
|
||||
const blocked=await harness.conversation(broken.conversationId,context);
|
||||
await(await blocked.submit({type:'input',content:`CALL ${JSON.stringify({tool:'implement',args:{name:'guard.txt'}})}`},context)).wait(context);
|
||||
const blockedResult=(await blocked.context(context)).entries.filter((entry:any)=>entry.kind==='pi.tool-result').at(-1)?.model?.[0] as any;
|
||||
assert(blockedResult.isError&&JSON.stringify(blockedResult).includes('missing or unregistered'),'beforeTool must block the guest tool');
|
||||
assert(!await fs.stat(`${checkout.path}/guard.txt`).then(()=>true,(error:any)=>{if(error.code==='not_found')return false;throw error}),'Blocked tool must not write');
|
||||
await fs.rename(`${checkout.path}/.git.saved`,`${checkout.path}/.git`);
|
||||
assert(globalThis.workspaceExecutions.length===before,'Failed VFS gate must not spawn Git');
|
||||
const failed=await submit(broken.conversationId);
|
||||
await waitQueue();
|
||||
const result=(await harness.snapshot(service.doc,root.id,context)).results[failed.id];
|
||||
assert(result.outcome==='failed'&&result.reason.includes('Check sh'),`Check must fail: ${JSON.stringify(result)}`);
|
||||
assert((await reports(broken.conversationId)).length===1,'Failed check must return to submitter');
|
||||
const failedWorkspace=await service.get(broken.conversationId,context);
|
||||
assert((await service.release([broken.conversationId],context)).length===1,'Release must retain unlanded work');
|
||||
await call('subagent',{action:'discard',id:broken.conversationId});
|
||||
assert(await host.git('/project','rev-parse',failedWorkspace.branch),'Discard must retain its branch');
|
||||
await call('subagent',{action:'start',agent:'worker',task:'IMPLEMENT'});
|
||||
const child=(await children()).at(-1);
|
||||
await call('subagent',{action:'land',id:child.conversationId});
|
||||
assert(await fs.read('/project/feature.txt')==='feature','Parent fast-forward');
|
||||
assert((await service.get(child.conversationId,context)).state==='removed','Land must release checkout');
|
||||
await call('subagent',{action:'start',agent:'worker',task:'BROKEN'});
|
||||
}
|
||||
const submitter=(await children()).at(-1).conversationId;
|
||||
const submission=await submit(submitter);
|
||||
if(globalThis.workspacesMode==='kill')await new Promise(()=>{});
|
||||
await waitQueue();
|
||||
const result=(await harness.snapshot(service.doc,root.id,context)).results[submission.id];
|
||||
// feature.txt came from synchronous landing, so this submission passes its real shell check too.
|
||||
assert(result.outcome==='landed',`Landing failed: ${JSON.stringify(result)}`);
|
||||
assert(await host.git('/remote.git','rev-parse','main')===result.sha,`Local bare push: ${JSON.stringify(result)}`);
|
||||
assert((await reports(submitter)).length===1,'Submission report');
|
||||
const delivered=await notes();
|
||||
assert(delivered.length===1&&delivered[0].state==='delivered'&&delivered[0].from===submitter&&delivered[0].to===root.id&&delivered[0].reply===false&&delivered[0].text.includes(result.sha),`Landing note must use the messages API: ${JSON.stringify(delivered)}`);
|
||||
const commands=globalThis.workspaceExecutions.filter((entry:any)=>typeof entry.command==='string'||entry.command[1]==='fetch'||entry.command[1]==='push');
|
||||
const push=commands.findLast((entry:any)=>entry.command[1]==='push');
|
||||
assert(push,'Real push must run');
|
||||
assert(JSON.stringify(push.options.conversations)===JSON.stringify([String(submitter),String(root.id)]),'Push must use the exact submitter chain');
|
||||
for(const entry of commands) {
|
||||
assert(entry.options.task!==undefined,'Lander exec needs its task tag');
|
||||
if(typeof entry.command==='string')assert(entry.options.timeout===600,'Checks need a bounded default timeout');
|
||||
assert(entry.options.conversations[1]===String(root.id),'Lander exec needs the submitter ancestor');
|
||||
assert(entry.options.conversations[0]!==String(root.id),'Lander exec needs the submitter first');
|
||||
}
|
||||
}
|
||||
}finally{await loader.close();await harness.close(context)}
|
||||
+36
-2
@@ -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":{...}}`.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -63,6 +63,6 @@ It does not depend on a user-profile path or a host HOME file.
|
||||
Development commands retain the global Cargo configuration and its kache wrapper.
|
||||
The sandbox uses a private Cargo home and selects kache through `--config build.rustc-wrapper`.
|
||||
|
||||
For `cargo add`, the network phase performs a dry run to populate the registry index.
|
||||
The offline command makes the actual manifest change inside the namespaces.
|
||||
For `cargo add`, the network phase updates the private source copy and fetches the new dependencies, including transitive dependencies.
|
||||
The command then repeats offline inside the namespaces before source publication.
|
||||
The disk cap applies after each completed command, including compiler failures.
|
||||
|
||||
+10
-1
@@ -134,7 +134,8 @@ impl Builder {
|
||||
};
|
||||
let mut fetch = Command::new("cargo");
|
||||
if action == "add" {
|
||||
fetch.args(&request.argv).arg("--dry-run");
|
||||
// Resolve the new manifest before fetch, including transitive dependencies.
|
||||
fetch.args(&request.argv);
|
||||
} else {
|
||||
fetch.arg("fetch");
|
||||
}
|
||||
@@ -144,6 +145,14 @@ impl Builder {
|
||||
} else {
|
||||
run(fetch, tx, deadline).await?.0
|
||||
};
|
||||
if code == 0 && action == "add" {
|
||||
let mut fetch = Command::new("cargo");
|
||||
fetch
|
||||
.arg("fetch")
|
||||
.current_dir(&cwd)
|
||||
.env("CARGO_HOME", ®istry);
|
||||
code = run(fetch, tx, deadline).await?.0;
|
||||
}
|
||||
let mut executables = Vec::new();
|
||||
if code == 0 {
|
||||
let tools = base.join("tools");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! World-loaded dream built-ins and messages survive a harness reopen.
|
||||
use sausage::{harness::run_module, world::World};
|
||||
use std::{path::Path, process::Command};
|
||||
const BUNDLE: &str = include_str!("../js/dream-test-bundle.js");
|
||||
fn git(repo: &Path, args: &[&str]) -> anyhow::Result<String> {
|
||||
let output = 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")
|
||||
.output()?;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"git {args:?}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
Ok(String::from_utf8(output.stdout)?.trim().into())
|
||||
}
|
||||
fn copy_tree(source: &Path, destination: &Path) -> anyhow::Result<()> {
|
||||
std::fs::create_dir_all(destination)?;
|
||||
for entry in std::fs::read_dir(source)? {
|
||||
let entry = entry?;
|
||||
let target = destination.join(entry.file_name());
|
||||
if entry.file_type()?.is_dir() {
|
||||
copy_tree(&entry.path(), &target)?;
|
||||
} else {
|
||||
std::fs::copy(entry.path(), target)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn prepare(dir: &Path) -> anyhow::Result<()> {
|
||||
World::open(dir)?;
|
||||
let disk = dir.join("world");
|
||||
let repo = disk.join("src/sausage");
|
||||
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() {
|
||||
anyhow::ensure!(
|
||||
Command::new("nu")
|
||||
.args(["--no-config-file", "cwd-init/build.nu"])
|
||||
.current_dir(&userland)
|
||||
.status()?
|
||||
.success(),
|
||||
"cwd-init build failed"
|
||||
);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
Command::new("cargo")
|
||||
.args(["build", "--release", "-p", "git", "--manifest-path"])
|
||||
.arg(userland.join("Cargo.toml"))
|
||||
.args(["--target", "wasm32-wasip2", "--target-dir"])
|
||||
.arg(target)
|
||||
.args(["-j", "2", "--locked"])
|
||||
.current_dir(&userland)
|
||||
.status()?
|
||||
.success(),
|
||||
"world git build failed"
|
||||
);
|
||||
std::fs::create_dir_all(disk.join("bin"))?;
|
||||
std::fs::copy(
|
||||
target.join("wasm32-wasip2/release/git.wasm"),
|
||||
disk.join("bin/git.wasm"),
|
||||
)?;
|
||||
copy_tree(&source.join("extensions"), &repo.join("extensions"))?;
|
||||
std::fs::create_dir_all(repo.join("extensions/probe"))?;
|
||||
std::fs::write(
|
||||
repo.join("extensions/probe/server.ts"),
|
||||
"export default host => { globalThis.dreamHost=host; globalThis.dreamService=host.need({name:'dream'}); globalThis.dreamSubagents=host.need({name:'subagents'}); globalThis.dreamAutomations=host.need({name:'automations'}); return {name:'probe'}; }",
|
||||
)?;
|
||||
std::fs::create_dir_all(repo.join("agents"))?;
|
||||
std::fs::write(
|
||||
repo.join("agents/models.json"),
|
||||
r#"{"default":{"model":"faux/faux-1"},"fast":{"model":"faux/faux-1"}}"#,
|
||||
)?;
|
||||
for role in ["ultimate", "coordinator", "worker", "reviewer"] {
|
||||
let denial = if role == "worker" || role == "reviewer" {
|
||||
"denyTools: [subagent]\n"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
std::fs::write(
|
||||
repo.join(format!("agents/{role}.md")),
|
||||
format!(
|
||||
"---\nname: {role}\ndescription: P3 {role}\nisolate: none\n{denial}{}---\nP3 role:{role}\n",
|
||||
if role == "ultimate" {
|
||||
"resident: true\n"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
)?;
|
||||
}
|
||||
std::fs::copy(
|
||||
source.join("agents/dream-pass.md"),
|
||||
repo.join("agents/dream-pass.md"),
|
||||
)?;
|
||||
let demo = disk.join("src/demo");
|
||||
std::fs::create_dir_all(&demo)?;
|
||||
std::fs::write(demo.join("README.md"), "P3 demo\n")?;
|
||||
for repo in [&repo, &demo] {
|
||||
git(repo, &["init", "-b", "main"])?;
|
||||
git(repo, &["add", "."])?;
|
||||
git(repo, &["commit", "--no-gpg-sign", "-m", "P3 fixture"])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn run(dir: &Path, mode: &str) -> anyhow::Result<()> {
|
||||
let world = World::open(dir)?;
|
||||
let exec = sausage::process::exec(World {
|
||||
root: world.root.clone(),
|
||||
host_dir: world.host_dir.clone(),
|
||||
processes: world.processes.clone(),
|
||||
})?;
|
||||
run_module(
|
||||
world,
|
||||
exec,
|
||||
&format!("globalThis.dreamMode={mode:?};\n{BUNDLE}"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn builtins_fire_and_report_across_reopen() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
prepare(dir.path())?;
|
||||
run(dir.path(), "first").await?;
|
||||
run(dir.path(), "reopen").await
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Goal and question-card server cases execute through the world loader.
|
||||
use sausage::{harness::run_module, world::World};
|
||||
use std::{path::Path, process::Command};
|
||||
const BUNDLE: &str = include_str!("../js/goal-questions-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", "--manifest-path"])
|
||||
.arg(userland.join("Cargo.toml"))
|
||||
.args(["--target", "wasm32-wasip2", "--target-dir"])
|
||||
.arg(target)
|
||||
.args(["-j", "2", "--locked"])
|
||||
.current_dir(&userland)
|
||||
.status()?
|
||||
.success()
|
||||
);
|
||||
std::fs::create_dir_all(disk.join("bin"))?;
|
||||
std::fs::copy(
|
||||
target.join("wasm32-wasip2/release/git.wasm"),
|
||||
disk.join("bin/git.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 goal_questions() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let dir = dir.path();
|
||||
let world = World::open(dir)?;
|
||||
prepare(dir)?;
|
||||
let exec = sausage::process::exec(World {
|
||||
root: world.root.clone(),
|
||||
host_dir: world.host_dir.clone(),
|
||||
processes: world.processes.clone(),
|
||||
})?;
|
||||
run_module(world, exec, BUNDLE).await
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "harness-api-client"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
wasi = "0.14"
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
+72
-24
@@ -1,4 +1,4 @@
|
||||
//! P3 exit scaffold with a worker SIGKILL and pending lander crash boundaries.
|
||||
//! P3 exit scenario across worker, check, and recorded fast-forward SIGKILL boundaries.
|
||||
use sausage::{harness::run_module, world::World};
|
||||
use std::{
|
||||
path::Path,
|
||||
@@ -69,6 +69,21 @@ fn prepare(dir: &Path, all_extensions: bool) -> anyhow::Result<()> {
|
||||
target.join("wasm32-wasip2/release/git.wasm"),
|
||||
disk.join("bin/git.wasm"),
|
||||
)?;
|
||||
if !target.join("wasm32-wasip2/release/brush.wasm").exists() {
|
||||
anyhow::ensure!(
|
||||
Command::new("nu")
|
||||
.arg("brush/build.nu")
|
||||
.env("CARGO_TARGET_DIR", target)
|
||||
.current_dir(&userland)
|
||||
.status()?
|
||||
.success(),
|
||||
"brush build failed"
|
||||
);
|
||||
}
|
||||
std::fs::copy(
|
||||
target.join("wasm32-wasip2/release/brush.wasm"),
|
||||
disk.join("bin/sh.wasm"),
|
||||
)?;
|
||||
if all_extensions {
|
||||
copy_tree(&source.join("extensions"), &repo.join("extensions"))?;
|
||||
} else {
|
||||
@@ -82,7 +97,7 @@ fn prepare(dir: &Path, all_extensions: bool) -> anyhow::Result<()> {
|
||||
std::fs::create_dir_all(repo.join("extensions/probe"))?;
|
||||
std::fs::write(
|
||||
repo.join("extensions/probe/server.ts"),
|
||||
"export default host => { globalThis.p3Models=host.need({name:'models'}); globalThis.p3Subagents=host.need({name:'subagents'}); return {name:'probe'}; }",
|
||||
"export default host => { globalThis.p3Models=host.need({name:'models'}); globalThis.p3Subagents=host.need({name:'subagents'}); globalThis.p3Workspaces=host.need({name:'workspaces'}); globalThis.p3Host=host; return {name:'probe'}; }",
|
||||
)?;
|
||||
std::fs::create_dir_all(repo.join("agents"))?;
|
||||
std::fs::write(
|
||||
@@ -107,11 +122,17 @@ fn prepare(dir: &Path, all_extensions: bool) -> anyhow::Result<()> {
|
||||
),
|
||||
)?;
|
||||
}
|
||||
std::fs::write(
|
||||
repo.join("settings.json"),
|
||||
r#"{"lander":{"checks":["sh -c ': > /check-running; until test -f /check-release; do :; done; test -f feature.txt'"]}}"#,
|
||||
)?;
|
||||
let demo = disk.join("src/demo");
|
||||
std::fs::create_dir_all(&demo)?;
|
||||
std::fs::write(demo.join("README.md"), "P3 demo\n")?;
|
||||
for repo in [&repo, &demo] {
|
||||
git(repo, &["init", "-b", "main"])?;
|
||||
git(repo, &["config", "user.name", "Test"])?;
|
||||
git(repo, &["config", "user.email", "test@example.com"])?;
|
||||
git(repo, &["add", "."])?;
|
||||
git(repo, &["commit", "--no-gpg-sign", "-m", "P3 fixture"])?;
|
||||
}
|
||||
@@ -156,10 +177,12 @@ async fn restart_child() -> anyhow::Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
fn worker_sigkill_recovery() -> anyhow::Result<()> {
|
||||
fn recovery(point: &str) -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
prepare(dir.path(), false)?;
|
||||
prepare(dir.path(), true)?;
|
||||
if point != "check" {
|
||||
std::fs::write(dir.path().join("world/check-release"), "yes")?;
|
||||
}
|
||||
let spawn = |mode: &str| {
|
||||
Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "restart_child", "--nocapture"])
|
||||
@@ -167,16 +190,22 @@ fn worker_sigkill_recovery() -> anyhow::Result<()> {
|
||||
.env("P3_MODE", mode)
|
||||
.spawn()
|
||||
};
|
||||
let mut child = spawn("kill-worker")?;
|
||||
let deadline = Instant::now() + Duration::from_secs(45);
|
||||
while !dir.path().join("world/worker-running").exists() {
|
||||
let mut child = spawn(&format!("kill-{point}"))?;
|
||||
let marker = match point {
|
||||
"worker" => "worker-running",
|
||||
"check" => "check-running",
|
||||
"forward" => "forward-recorded",
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let deadline = Instant::now() + Duration::from_secs(120);
|
||||
while !dir.path().join("world").join(marker).exists() {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
anyhow::bail!("Worker child exited before marker: {status}");
|
||||
anyhow::bail!("P3 child exited before {point} marker: {status}");
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
child.kill()?;
|
||||
child.wait()?;
|
||||
anyhow::bail!("Worker did not reach kill point 1");
|
||||
anyhow::bail!("P3 did not reach {point} marker");
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
@@ -184,10 +213,25 @@ fn worker_sigkill_recovery() -> anyhow::Result<()> {
|
||||
let killed = child.wait()?;
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
assert_eq!(killed.signal(), Some(9));
|
||||
// A second reopen proves that idle recovery does not duplicate child reports or commits.
|
||||
let demo = dir.path().join("world/src/demo");
|
||||
assert_eq!(
|
||||
git(&demo, &["rev-list", "--count", "main"])?,
|
||||
if point == "forward" { "3" } else { "1" }
|
||||
);
|
||||
if point == "forward" {
|
||||
let recorded: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(
|
||||
dir.path().join("world/forward-recorded"),
|
||||
)?)?;
|
||||
assert_eq!(
|
||||
recorded[0]["landed"]["sha"].as_str(),
|
||||
Some(git(&demo, &["rev-parse", "main"])?.as_str())
|
||||
);
|
||||
}
|
||||
std::fs::write(dir.path().join("world/check-release"), "yes")?;
|
||||
// A second reopen proves that idle recovery does not duplicate reports, cards, events, or commits.
|
||||
for _ in 0..2 {
|
||||
let mut child = spawn("resume")?;
|
||||
let deadline = Instant::now() + Duration::from_secs(45);
|
||||
let deadline = Instant::now() + Duration::from_secs(120);
|
||||
loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
anyhow::ensure!(status.success(), "P3 recovery failed: {status}");
|
||||
@@ -201,8 +245,14 @@ fn worker_sigkill_recovery() -> anyhow::Result<()> {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
let demo = dir.path().join("world/src/demo");
|
||||
assert_eq!(git(&demo, &["rev-list", "--count", "HEAD"])?, "2");
|
||||
assert_eq!(git(&demo, &["log", "-1", "--format=%s"])?, "P3 feature");
|
||||
assert_eq!(git(&demo, &["rev-list", "--count", "main"])?, "3");
|
||||
assert_eq!(
|
||||
git(&demo, &["log", "--format=%s", "main"])?
|
||||
.lines()
|
||||
.filter(|line| *line == "P3 feature")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(git(&demo, &["status", "--porcelain"])?, "");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(demo.join("feature.txt"))?,
|
||||
@@ -212,16 +262,14 @@ fn worker_sigkill_recovery() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Pending T52: kill point 2 needs the lander check and marker-file gate"]
|
||||
fn lander_check_sigkill_recovery() {
|
||||
// Wait for the check-start marker, SIGKILL, reopen, release /check-release, and run to idle.
|
||||
// Assert one landed event/card and no duplicate board reply (T49).
|
||||
panic!("Pending T52 lander check");
|
||||
fn worker_sigkill_recovery() -> anyhow::Result<()> {
|
||||
recovery("worker")
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Pending T52: kill point 3 needs the target fast-forward log entry"]
|
||||
fn target_fast_forward_sigkill_recovery() {
|
||||
// Detect the durable fast-forward log entry, SIGKILL, reopen, and run to idle.
|
||||
// Assert one target commit, one landed event/card, no faulted tasks, and worktree release.
|
||||
panic!("Pending T52 target fast-forward");
|
||||
fn lander_check_sigkill_recovery() -> anyhow::Result<()> {
|
||||
recovery("check")
|
||||
}
|
||||
#[test]
|
||||
fn target_fast_forward_sigkill_recovery() -> anyhow::Result<()> {
|
||||
recovery("forward")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
//! P4 exit: the real console agent builds Rust and Node projects, then inspects undo history.
|
||||
use anyhow::{Context, ensure};
|
||||
use sausage::world::World;
|
||||
use sausage_vfs::journal::Entry;
|
||||
use std::{
|
||||
fs,
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, Command, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
fn checked(command: &mut Command) -> anyhow::Result<()> {
|
||||
ensure!(command.status()?.success(), "command failed: {command:?}");
|
||||
Ok(())
|
||||
}
|
||||
fn prepare(dir: &Path) -> anyhow::Result<PathBuf> {
|
||||
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let userland = source.join("userland");
|
||||
let target = PathBuf::from(std::env::var("CARGO_TARGET_DIR")?);
|
||||
let bundle = Path::new(env!("CARGO_TARGET_TMPDIR")).join("p4-bundle");
|
||||
let revision = Command::new("git")
|
||||
.current_dir(&source)
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()?;
|
||||
ensure!(revision.status.success(), "fixture revision");
|
||||
if fs::read(bundle.join("revision")).ok().as_deref() != Some(revision.stdout.as_slice()) {
|
||||
if !userland.join("cwd-init/init.o").exists() {
|
||||
checked(Command::new("nu").arg(userland.join("cwd-init/build.nu")))?;
|
||||
}
|
||||
checked(Command::new("cargo").current_dir(&userland).args([
|
||||
"build",
|
||||
"--release",
|
||||
"--locked",
|
||||
"-p",
|
||||
"git",
|
||||
"-p",
|
||||
"world-cargo",
|
||||
"-p",
|
||||
"box-cli",
|
||||
"-p",
|
||||
"undo",
|
||||
"-p",
|
||||
"sausage-coreutils",
|
||||
]))?;
|
||||
checked(Command::new("nu").arg(userland.join("brush/build.nu")))?;
|
||||
fs::create_dir_all(bundle.join("bin"))?;
|
||||
fs::create_dir_all(bundle.join("src"))?;
|
||||
for (binary, name) in [
|
||||
("brush", "sh"),
|
||||
("brush", "bash"),
|
||||
("git", "git"),
|
||||
("cargo", "cargo"),
|
||||
("box", "box"),
|
||||
("undo", "undo"),
|
||||
("cat", "cat"),
|
||||
("mkdir", "mkdir"),
|
||||
] {
|
||||
fs::copy(
|
||||
target.join(format!("wasm32-wasip2/release/{binary}.wasm")),
|
||||
bundle.join(format!("bin/{name}.wasm")),
|
||||
)?;
|
||||
}
|
||||
fs::write(bundle.join("revision"), revision.stdout)?;
|
||||
}
|
||||
World::open_with_userland(dir, Some(&bundle))?;
|
||||
let repo = dir.join("world/src/sausage");
|
||||
fs::create_dir_all(repo.join("extensions/p4"))?;
|
||||
fs::write(
|
||||
repo.join("extensions/p4/server.ts"),
|
||||
include_str!("../js/p4-test-bundle.js"),
|
||||
)?;
|
||||
for args in [
|
||||
vec!["init", "-b", "main"],
|
||||
vec!["add", "."],
|
||||
vec!["commit", "--no-gpg-sign", "-m", "P4 fixture"],
|
||||
] {
|
||||
checked(
|
||||
Command::new("git")
|
||||
.current_dir(&repo)
|
||||
.args(args)
|
||||
.env("GIT_AUTHOR_NAME", "P4 test")
|
||||
.env("GIT_AUTHOR_EMAIL", "p4@example.com")
|
||||
.env("GIT_COMMITTER_NAME", "P4 test")
|
||||
.env("GIT_COMMITTER_EMAIL", "p4@example.com"),
|
||||
)?;
|
||||
}
|
||||
Ok(bundle)
|
||||
}
|
||||
struct Running(Child);
|
||||
impl Drop for Running {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
fn round(dir: &Path, bundle: &Path, number: usize) -> anyhow::Result<()> {
|
||||
let stdout = dir.join(format!("round-{number}.stdout"));
|
||||
let stderr = dir.join(format!("round-{number}.stderr"));
|
||||
let mut child = Running(
|
||||
Command::new(env!("CARGO_BIN_EXE_sausage"))
|
||||
.arg("run")
|
||||
.arg(dir)
|
||||
.arg("--userland")
|
||||
.arg(bundle)
|
||||
.args(["--api-listen", "127.0.0.1:0"])
|
||||
.env("PI_PROXY_BASE_URL", "https://proxy.invalid")
|
||||
.env("PI_PROXY_API_KEY", "faux-token")
|
||||
.env_remove("CREDENTIALS_DIRECTORY")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(fs::File::create(&stdout)?)
|
||||
.stderr(fs::File::create(&stderr)?)
|
||||
.spawn()?,
|
||||
);
|
||||
writeln!(
|
||||
child.0.stdin.take().context("console stdin")?,
|
||||
"P4 start {number}"
|
||||
)?;
|
||||
let deadline = Instant::now() + Duration::from_secs(600);
|
||||
loop {
|
||||
if fs::read_to_string(&stdout)?.contains(&format!("P4_COMPLETE_{number}")) {
|
||||
break;
|
||||
}
|
||||
if let Some(status) = child.0.try_wait()? {
|
||||
anyhow::bail!("console exited: {status}");
|
||||
}
|
||||
ensure!(
|
||||
Instant::now() < deadline,
|
||||
"P4 round {number} exceeded its deadline"
|
||||
);
|
||||
std::thread::yield_now();
|
||||
}
|
||||
checked(Command::new("kill").args(["-TERM", &child.0.id().to_string()]))?;
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
if let Some(status) = child.0.try_wait()? {
|
||||
ensure!(status.success(), "console shutdown: {status}");
|
||||
break;
|
||||
}
|
||||
ensure!(
|
||||
Instant::now() < deadline,
|
||||
"console shutdown exceeded its deadline"
|
||||
);
|
||||
std::thread::yield_now();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn boxes_removed(dir: &Path, bundle: &Path) -> anyhow::Result<()> {
|
||||
let path = dir.join("world/tmp/p4-box-id");
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let id = fs::read_to_string(path)?;
|
||||
let id = id.trim();
|
||||
ensure!(
|
||||
id.starts_with("tc-") && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'),
|
||||
"invalid box ID"
|
||||
);
|
||||
// The agent's EXIT trap is primary cleanup. Recover its box even if the console fails.
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_sausage"))
|
||||
.arg("sh")
|
||||
.arg(dir)
|
||||
.arg("--userland")
|
||||
.arg(bundle)
|
||||
.arg("-c")
|
||||
.arg("box ls")
|
||||
.env_remove("CREDENTIALS_DIRECTORY")
|
||||
.output()?;
|
||||
ensure!(
|
||||
output.status.success(),
|
||||
"box ls: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
if String::from_utf8_lossy(&output.stdout).contains(id) {
|
||||
checked(
|
||||
Command::new(env!("CARGO_BIN_EXE_sausage"))
|
||||
.arg("sh")
|
||||
.arg(dir)
|
||||
.arg("--userland")
|
||||
.arg(bundle)
|
||||
.arg("-c")
|
||||
.arg(format!("box rm {id}"))
|
||||
.env_remove("CREDENTIALS_DIRECTORY"),
|
||||
)?;
|
||||
anyhow::bail!("Agent left {id} active. Test cleanup removed it");
|
||||
}
|
||||
println!("P4 box absent from service inventory: {id}");
|
||||
Ok(())
|
||||
}
|
||||
fn cache_hits(dir: &Path) -> anyhow::Result<u64> {
|
||||
let workspace = fs::read_dir(dir.join("host/builds"))?
|
||||
.next()
|
||||
.context("builder workspace")??
|
||||
.path();
|
||||
let output = Command::new("kache")
|
||||
.arg("stats")
|
||||
.env("KACHE_CACHE_DIR", workspace.join("kache"))
|
||||
.output()?;
|
||||
ensure!(
|
||||
output.status.success(),
|
||||
"kache: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let text = String::from_utf8(output.stdout)?;
|
||||
println!("P4 PRIVATE CACHE:\n{text}");
|
||||
let words: Vec<_> = text.split_whitespace().collect();
|
||||
words
|
||||
.windows(2)
|
||||
.find_map(|pair| {
|
||||
if pair[1] == "of" {
|
||||
pair[0].replace(',', "").parse().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.context("cache hit count")
|
||||
}
|
||||
#[test]
|
||||
fn agent_builds_rust_and_node_twice() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let bundle = prepare(dir.path())?;
|
||||
let mut first_hits = 0;
|
||||
for number in 1..=2 {
|
||||
let outcome = round(dir.path(), &bundle, number);
|
||||
let cleanup = boxes_removed(dir.path(), &bundle);
|
||||
if outcome.is_err() {
|
||||
for stream in ["stdout", "stderr"] {
|
||||
eprintln!(
|
||||
"P4 round {number} {stream}:\n{}",
|
||||
fs::read_to_string(dir.path().join(format!("round-{number}.{stream}")))?
|
||||
);
|
||||
}
|
||||
}
|
||||
outcome?;
|
||||
cleanup?;
|
||||
let world = dir.path().join("world");
|
||||
ensure!(
|
||||
world
|
||||
.join("src/demo-cli/target/wasm32-wasip2/release/demo-cli.wasm")
|
||||
.is_file(),
|
||||
"missing world artifact"
|
||||
);
|
||||
ensure!(
|
||||
fs::read_to_string(world.join("src/demo-cli/Cargo.toml"))?.contains("serde_json"),
|
||||
"cargo add did not reach world"
|
||||
);
|
||||
ensure!(
|
||||
fs::read_to_string(world.join("src/demo-node/result.txt"))? == "P4 npm test passed\n",
|
||||
"Node result missing"
|
||||
);
|
||||
let entries: Vec<Entry> =
|
||||
serde_json::from_slice(&fs::read(world.join("tmp/p4-undo.json"))?)?;
|
||||
for (phase, path) in [
|
||||
(0, "/src/demo-cli/Cargo.toml"),
|
||||
(1, "/src/demo-cli/src/main.rs"),
|
||||
] {
|
||||
let tag = format!("p4-{number}-{phase}");
|
||||
ensure!(
|
||||
entries.iter().any(|entry| entry.tag == tag
|
||||
&& entry.program == "bash"
|
||||
&& entry.changed_paths.iter().any(|p| p == path)),
|
||||
"undo lost {tag}: {path}"
|
||||
);
|
||||
}
|
||||
let hits = cache_hits(dir.path())?;
|
||||
if number == 1 {
|
||||
first_hits = hits;
|
||||
} else {
|
||||
ensure!(
|
||||
hits > first_hits,
|
||||
"second run missed kache: {first_hits} -> {hits}"
|
||||
);
|
||||
println!("P4 CACHE SECOND RUN: {first_hits} -> {hits} hits");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -41,6 +41,45 @@ async fn command(runtime: &Arc<Runtime>, args: &[&str]) -> String {
|
||||
#[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());
|
||||
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
assert!(
|
||||
std::process::Command::new("nu")
|
||||
.args(["--no-config-file", "userland/cwd-init/build.nu"])
|
||||
.current_dir(repo)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
assert!(
|
||||
std::process::Command::new("cargo")
|
||||
.args([
|
||||
"build",
|
||||
"--release",
|
||||
"--locked",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"-p",
|
||||
"git",
|
||||
"-p",
|
||||
"undo",
|
||||
"--target-dir"
|
||||
])
|
||||
.arg(&target)
|
||||
.args(["-j", "2"])
|
||||
.current_dir(repo.join("userland"))
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
assert!(
|
||||
std::process::Command::new("nu")
|
||||
.args(["--no-config-file", "userland/brush/build.nu"])
|
||||
.env("CARGO_TARGET_DIR", &target)
|
||||
.current_dir(repo)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
for since in [false, true] {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let world = World::open(temp.path()).unwrap();
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
//! World worktrees, landing checks, and SIGKILL recovery before push.
|
||||
use sausage::{harness::run_module, world::World};
|
||||
use std::{
|
||||
io::Write,
|
||||
path::Path,
|
||||
process::{Command, Stdio},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
const BUNDLE: &str = include_str!("../js/workspaces-test-bundle.js");
|
||||
fn git(path: &Path, args: &[&str]) -> anyhow::Result<()> {
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(path)
|
||||
.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(())
|
||||
}
|
||||
fn prepare(dir: &Path) -> anyhow::Result<()> {
|
||||
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", "--manifest-path"])
|
||||
.arg(userland.join("Cargo.toml"))
|
||||
.args(["--target", "wasm32-wasip2", "--target-dir"])
|
||||
.arg(target)
|
||||
.args(["-j", "2", "--locked"])
|
||||
.current_dir(&userland)
|
||||
.status()?
|
||||
.success()
|
||||
);
|
||||
if !target.join("wasm32-wasip2/release/brush.wasm").exists() {
|
||||
assert!(
|
||||
Command::new("nu")
|
||||
.arg("brush/build.nu")
|
||||
.env("CARGO_TARGET_DIR", target)
|
||||
.current_dir(&userland)
|
||||
.status()?
|
||||
.success()
|
||||
);
|
||||
}
|
||||
std::fs::create_dir_all(disk.join("bin"))?;
|
||||
for (binary, name) in [("git", "git"), ("brush", "sh")] {
|
||||
std::fs::copy(
|
||||
target.join(format!("wasm32-wasip2/release/{binary}.wasm")),
|
||||
disk.join(format!("bin/{name}.wasm")),
|
||||
)?;
|
||||
}
|
||||
for extension in [
|
||||
"subagents",
|
||||
"models",
|
||||
"workspaces",
|
||||
"messages",
|
||||
"lineage",
|
||||
"conversations",
|
||||
] {
|
||||
let dest = repo.join("extensions").join(extension);
|
||||
std::fs::create_dir_all(&dest)?;
|
||||
for entry in std::fs::read_dir(source.join("extensions").join(extension))? {
|
||||
let entry = entry?;
|
||||
if entry.path().extension().is_some_and(|ext| ext == "ts") {
|
||||
std::fs::copy(entry.path(), dest.join(entry.file_name()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let server = repo.join("extensions/workspaces/server.ts");
|
||||
let code = std::fs::read_to_string(&server)?;
|
||||
std::fs::write(server,code.replace("const service=workspaces(host)","globalThis.workspaceExecutions??=[]; const exec=host.exec; host.exec=(command,options)=>{globalThis.workspaceExecutions.push({command,options});return exec(command,options)}; const service=workspaces(host)"))?;
|
||||
std::fs::create_dir_all(repo.join("extensions/core"))?;
|
||||
std::fs::copy(
|
||||
source.join("extensions/core/agents.ts"),
|
||||
repo.join("extensions/core/agents.ts"),
|
||||
)?;
|
||||
std::fs::create_dir_all(repo.join("extensions/probe"))?;
|
||||
std::fs::write(
|
||||
repo.join("extensions/probe/server.ts"),
|
||||
"export default host => { globalThis.workspaceService=host.need({name:'workspaces'}); globalThis.workspaceHost=host; return {name:'probe'}; }",
|
||||
)?;
|
||||
std::fs::create_dir_all(repo.join("agents"))?;
|
||||
std::fs::write(
|
||||
repo.join("agents/models.json"),
|
||||
r#"{"default":{"model":"faux/faux-1"}}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/worker.md"),
|
||||
"---\nname: worker\ndescription: Implement\nisolate: worktree\n---\nImplement\n",
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("settings.json"),
|
||||
r#"{"lander":{"push":"origin","checks":["sh -c 'test -f feature.txt'"]}}"#,
|
||||
)?;
|
||||
let project = disk.join("project");
|
||||
std::fs::create_dir_all(&project)?;
|
||||
std::fs::write(project.join("base.txt"), "base")?;
|
||||
for path in [&project, &repo] {
|
||||
git(path, &["init", "-b", "main"])?;
|
||||
git(path, &["config", "user.name", "Test"])?;
|
||||
git(path, &["config", "user.email", "test@example.com"])?;
|
||||
git(path, &["add", "."])?;
|
||||
git(path, &["commit", "--no-gpg-sign", "-m", "fixture"])?;
|
||||
}
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args(["clone", "--bare"])
|
||||
.arg(&project)
|
||||
.arg(disk.join("remote.git"))
|
||||
.status()?
|
||||
.success()
|
||||
);
|
||||
git(&project, &["remote", "add", "origin", "/remote.git"])?;
|
||||
Ok(())
|
||||
}
|
||||
// The world Git supports smart HTTP and SSH, not file remotes.
|
||||
struct GitServer(Arc<tiny_http::Server>, Option<std::thread::JoinHandle<()>>);
|
||||
impl Drop for GitServer {
|
||||
fn drop(&mut self) {
|
||||
self.0.unblock();
|
||||
self.1.take().unwrap().join().unwrap();
|
||||
}
|
||||
}
|
||||
fn serve(dir: &Path) -> anyhow::Result<GitServer> {
|
||||
let root = dir.join("world");
|
||||
git(
|
||||
&root.join("remote.git"),
|
||||
&["config", "http.receivepack", "true"],
|
||||
)?;
|
||||
let server =
|
||||
Arc::new(tiny_http::Server::http("127.0.0.1:0").map_err(|e| anyhow::anyhow!("{e}"))?);
|
||||
let port = server.server_addr().to_ip().unwrap().port();
|
||||
git(
|
||||
&root.join("project"),
|
||||
&[
|
||||
"remote",
|
||||
"set-url",
|
||||
"origin",
|
||||
&format!("http://127.0.0.1:{port}/remote.git"),
|
||||
],
|
||||
)?;
|
||||
let serving = server.clone();
|
||||
let thread = std::thread::spawn(move || {
|
||||
while let Ok(mut request) = serving.recv() {
|
||||
let url = request.url().to_owned();
|
||||
let (path, query) = url.split_once('?').unwrap_or((&url, ""));
|
||||
let content_type = request
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("Content-Type"))
|
||||
.map(|h| h.value.as_str().to_owned())
|
||||
.unwrap_or_default();
|
||||
let mut body = Vec::new();
|
||||
request.as_reader().read_to_end(&mut body).unwrap();
|
||||
let mut process = Command::new("git")
|
||||
.arg("http-backend")
|
||||
.env("GIT_PROJECT_ROOT", &root)
|
||||
.env("GIT_HTTP_EXPORT_ALL", "1")
|
||||
.env("REQUEST_METHOD", request.method().as_str())
|
||||
.env("PATH_INFO", path)
|
||||
.env("QUERY_STRING", query)
|
||||
.env("CONTENT_TYPE", content_type)
|
||||
.env("CONTENT_LENGTH", body.len().to_string())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
process.stdin.take().unwrap().write_all(&body).unwrap();
|
||||
let output = process.wait_with_output().unwrap();
|
||||
assert!(output.status.success());
|
||||
let split = output
|
||||
.stdout
|
||||
.windows(4)
|
||||
.position(|p| p == b"\r\n\r\n")
|
||||
.unwrap();
|
||||
let headers = std::str::from_utf8(&output.stdout[..split]).unwrap();
|
||||
let mut response = tiny_http::Response::from_data(output.stdout[split + 4..].to_vec());
|
||||
for line in headers.split("\r\n") {
|
||||
let (name, value) = line.split_once(':').unwrap();
|
||||
if name.eq_ignore_ascii_case("status") {
|
||||
response = response.with_status_code(
|
||||
value
|
||||
.trim()
|
||||
.split(' ')
|
||||
.next()
|
||||
.unwrap()
|
||||
.parse::<u16>()
|
||||
.unwrap(),
|
||||
);
|
||||
} else {
|
||||
response = response
|
||||
.with_header(tiny_http::Header::from_bytes(name, value.trim()).unwrap());
|
||||
}
|
||||
}
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
});
|
||||
Ok(GitServer(server, Some(thread)))
|
||||
}
|
||||
async fn run(dir: &Path, mode: &str) -> anyhow::Result<()> {
|
||||
let world = World::open(dir)?;
|
||||
prepare(dir)?;
|
||||
let exec = sausage::process::exec(World {
|
||||
root: world.root.clone(),
|
||||
host_dir: world.host_dir.clone(),
|
||||
processes: world.processes.clone(),
|
||||
})?;
|
||||
run_module(
|
||||
world,
|
||||
exec,
|
||||
&format!("globalThis.workspacesMode={mode:?};\n{BUNDLE}"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn coordination() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
World::open(dir.path())?;
|
||||
prepare(dir.path())?;
|
||||
let _server = serve(dir.path())?;
|
||||
run(dir.path(), "normal").await
|
||||
}
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn restart_child() -> anyhow::Result<()> {
|
||||
if let Ok(dir) = std::env::var("WORKSPACES_DIR") {
|
||||
run(Path::new(&dir), &std::env::var("WORKSPACES_MODE")?).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
fn sigkill_between_fast_forward_and_push() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
World::open(dir.path())?;
|
||||
prepare(dir.path())?;
|
||||
let _server = serve(dir.path())?;
|
||||
let spawn = |mode: &str| {
|
||||
Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "restart_child", "--nocapture"])
|
||||
.env("WORKSPACES_DIR", dir.path())
|
||||
.env("WORKSPACES_MODE", mode)
|
||||
.spawn()
|
||||
};
|
||||
let mut child = spawn("kill")?;
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
while !dir.path().join("world/push-ready").exists() {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
anyhow::bail!("Child exited before push: {status}");
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
child.kill()?;
|
||||
child.wait()?;
|
||||
anyhow::bail!("Lander did not reach push");
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
child.kill()?;
|
||||
child.wait()?;
|
||||
// A later target commit must not replace the recorded SHA in the push or report.
|
||||
git(
|
||||
&dir.path().join("world/project"),
|
||||
&["commit", "--allow-empty", "--no-gpg-sign", "-m", "later"],
|
||||
)?;
|
||||
for _ in 0..2 {
|
||||
let mut child = spawn("resume")?;
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
assert!(status.success());
|
||||
break;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
child.kill()?;
|
||||
child.wait()?;
|
||||
anyhow::bail!("Recovery timed out");
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user