mirror of
https://github.com/earendil-works/pi.git
synced 2026-10-02 00:35:27 +08:00
fix(coding-agent): load codemode worker in Windows binary
closes #10204
This commit is contained in:
@@ -142,7 +142,14 @@ jobs:
|
||||
actions: read
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout smoke tests
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ env.SOURCE_REF }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download binary archives
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
@@ -198,6 +205,7 @@ jobs:
|
||||
run: |
|
||||
"${{ steps.binary.outputs.binary }}" --help
|
||||
"${{ steps.binary.outputs.binary }}" --version
|
||||
node scripts/smoke-test-codemode-binary.mjs "${{ steps.binary.outputs.binary }}"
|
||||
|
||||
stage-github-release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Allowed `CodemodeSandbox.workerUrl` to be a string, as required for embedded worker entrypoints in Bun compiled executables ([#10204](https://github.com/earendil-works/pi/issues/10204)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `image()` accepting malformed base64 data and unsupported image types. It now throws a `TypeError` unless the data is valid base64 of a PNG, JPEG, GIF, or WebP image, derives the MIME type from the image signature instead of the declared type, and strips line breaks from wrapped base64 ([#10215](https://github.com/earendil-works/pi/issues/10215)).
|
||||
|
||||
@@ -92,6 +92,10 @@ const sandbox = new CodemodeSandbox({
|
||||
});
|
||||
```
|
||||
|
||||
`workerUrl` accepts a URL or string. For a Bun compiled executable, include the worker as an
|
||||
additional build entrypoint and pass its relative source path as a string, for example
|
||||
`"./src/codemode-worker.ts"`; Bun resolves that form from its embedded module graph.
|
||||
|
||||
## Declarations for the model
|
||||
|
||||
Tools and globals can carry `description`, `inputSchema`, and `outputSchema` (JSON Schema). `renderDeclarations()` turns them into TypeScript declarations for a model-facing tool description:
|
||||
|
||||
@@ -75,7 +75,7 @@ interface ExecutionOptions {
|
||||
memoryLimitBytes: number | undefined;
|
||||
store: Record<string, string>;
|
||||
wasm: Promise<CodemodeWasmModule>;
|
||||
workerUrl: URL;
|
||||
workerUrl: string | URL;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,7 +288,7 @@ export class CodemodeSandbox {
|
||||
private readonly timeoutMs: number;
|
||||
private readonly memoryLimitBytes: number | undefined;
|
||||
private readonly wasm: CodemodeWasmModule | Promise<CodemodeWasmModule> | undefined;
|
||||
private readonly workerUrl: URL;
|
||||
private readonly workerUrl: string | URL;
|
||||
private readonly running = new Set<Execution>();
|
||||
private closed = false;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* Importing this module starts the worker. Hosts that bundle their code (for
|
||||
* example a Bun compiled executable) add a file that imports
|
||||
* `@earendil-works/pi-codemode/worker` as a separate entrypoint and pass its URL
|
||||
* as `workerUrl`.
|
||||
* or embedded-module string specifier as `workerUrl`.
|
||||
*/
|
||||
import { parentPort, workerData } from "node:worker_threads";
|
||||
import { JSException, type JSValueHandle, MAX_STACK_SIZE, QuickJS } from "quickjs-wasi";
|
||||
|
||||
@@ -118,9 +118,10 @@ export interface CodemodeSandboxOptions {
|
||||
/**
|
||||
* Worker entry that imports `@earendil-works/pi-codemode/worker`. Default: this package's own
|
||||
* worker file. Pass it when this package is bundled, since the default is resolved relative to
|
||||
* the module that creates the sandbox.
|
||||
* the module that creates the sandbox. Bun compiled executables require the relative string
|
||||
* specifier of an embedded build entrypoint; other hosts usually use a URL.
|
||||
*/
|
||||
workerUrl?: URL;
|
||||
workerUrl?: string | URL;
|
||||
}
|
||||
|
||||
export interface CodemodeExecuteOptions {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import vm from "node:vm";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { CodemodeSandbox, type CodemodeTool } from "../src/index.ts";
|
||||
@@ -561,6 +562,13 @@ describe("limits and lifetime", () => {
|
||||
expect(result).toMatchObject({ ok: true, value: ["RangeError", true] });
|
||||
});
|
||||
|
||||
it("accepts a worker path string", async () => {
|
||||
const workerPath = fileURLToPath(new URL("../src/runtime/worker.ts", import.meta.url));
|
||||
const sandbox = new CodemodeSandbox({ workerUrl: workerPath });
|
||||
sandboxes.push(sandbox);
|
||||
expect(await sandbox.execute("return 1")).toMatchObject({ ok: true, value: 1 });
|
||||
});
|
||||
|
||||
it("reports a missing worker file as a sandbox error", async () => {
|
||||
const sandbox = new CodemodeSandbox({ workerUrl: new URL("./does-not-exist.js", import.meta.url) });
|
||||
sandboxes.push(sandbox);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
- Fixed new sessions intermittently ignoring the saved default model, or warning that no models are available, when it belongs to an extension-registered native provider with a stored credential ([#9962](https://github.com/earendil-works/pi/issues/9962)).
|
||||
- Fixed the `/mcp` sign-in URL not being clickable when it wraps across lines, by emitting it as a terminal hyperlink with a `Cmd/Ctrl+click to open` line like `/login` ([#10186](https://github.com/earendil-works/pi/issues/10186)).
|
||||
- Fixed codemode `image()` accepting malformed base64 data or unsupported image types, which persisted an invalid image block that made every later provider request fail with HTTP 400 ([#10215](https://github.com/earendil-works/pi/issues/10215)).
|
||||
- Fixed codemode failing to start its script worker from the standalone Windows executable ([#10204](https://github.com/earendil-works/pi/issues/10204)).
|
||||
|
||||
## [0.99.1] - 2026-09-29
|
||||
|
||||
|
||||
@@ -489,21 +489,28 @@ export function getQuickJSWasmPath(): string {
|
||||
return embeddedQuickJSWasmPath ?? createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL of the codemode worker entry (`src/extensions/codemode/worker.ts`), or undefined to use
|
||||
* the worker file that ships next to pi-codemode's own module.
|
||||
* - For Bun binary: the build passes the worker as an extra entrypoint. Bun embeds it at its path
|
||||
* relative to the common directory of all entrypoints (the package root, since the main entry is
|
||||
* dist/bun/cli.js) with a .js extension, and all bundled code sees the executable as import.meta.url.
|
||||
* - For the Node bundle: the build emits codemode-worker.js next to the chunk that contains this module.
|
||||
* - For Node.js (dist/) and tsx (src/): pi-codemode's own worker.
|
||||
*/
|
||||
export function getCodemodeWorkerUrl(): URL | undefined {
|
||||
if (isBunBinary) return new URL("./src/extensions/codemode/worker.js", import.meta.url);
|
||||
if (isBundledNode) return new URL("./codemode-worker.js", import.meta.url);
|
||||
/** Resolve the codemode worker entry for a release runtime. */
|
||||
export function resolveCodemodeWorkerSpecifier(
|
||||
runtime: "bun-binary" | "bundled-node" | "unbundled",
|
||||
moduleUrl: string,
|
||||
): string | URL | undefined {
|
||||
// Bun embeds explicit source entrypoints, but on Windows Bun 1.3 cannot map an absolute
|
||||
// B:\~BUN URL back to one. A relative string with the original source extension works on
|
||||
// every Bun platform.
|
||||
if (runtime === "bun-binary") return "./src/extensions/codemode/worker.ts";
|
||||
if (runtime === "bundled-node") return new URL("./codemode-worker.js", moduleUrl);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the codemode worker entry, or undefined to use the worker that ships next to pi-codemode.
|
||||
* The Bun and Node release builds both pass the worker as an extra entrypoint.
|
||||
*/
|
||||
export function getCodemodeWorkerSpecifier(): string | URL | undefined {
|
||||
const runtime = isBunBinary ? "bun-binary" : isBundledNode ? "bundled-node" : "unbundled";
|
||||
return resolveCodemodeWorkerSpecifier(runtime, import.meta.url);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// App Config (from package.json piConfig)
|
||||
// =============================================================================
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
renderToolSample,
|
||||
toCodemodeIdentifier,
|
||||
} from "@earendil-works/pi-codemode";
|
||||
import { getCodemodeWorkerUrl, getQuickJSWasmPath } from "../../config.ts";
|
||||
import { getCodemodeWorkerSpecifier, getQuickJSWasmPath } from "../../config.ts";
|
||||
import type { ExtensionToolContext } from "../../core/extensions/types.ts";
|
||||
import type { SessionEntry } from "../../core/session-manager.ts";
|
||||
import { combineUsage } from "../../core/usage-totals.ts";
|
||||
@@ -277,7 +277,7 @@ export async function executeCodemode(
|
||||
timeoutMs: sourceOptions.timeoutMs ?? Number.POSITIVE_INFINITY,
|
||||
memoryLimitBytes: CODEMODE_MEMORY_LIMIT_BYTES,
|
||||
wasm: loadQuickJSWasm(getQuickJSWasmPath()),
|
||||
workerUrl: getCodemodeWorkerUrl(),
|
||||
workerUrl: getCodemodeWorkerSpecifier(),
|
||||
});
|
||||
|
||||
let result: CodemodeResult;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Worker entry for the codemode sandbox in bundled builds. The Node bundle and the Bun binary
|
||||
* build this file as a separate entrypoint because pi-codemode's own worker file is not on disk
|
||||
* there; `getCodemodeWorkerUrl()` in config.ts resolves it.
|
||||
* there; `getCodemodeWorkerSpecifier()` in config.ts resolves it.
|
||||
*/
|
||||
import "@earendil-works/pi-codemode/worker";
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { resolveCodemodeWorkerSpecifier } from "../src/config.ts";
|
||||
|
||||
describe("resolveCodemodeWorkerSpecifier", () => {
|
||||
// Regression test for #10204.
|
||||
test("uses a relative source entrypoint in Bun binaries", () => {
|
||||
expect(resolveCodemodeWorkerSpecifier("bun-binary", "file:///B:/~BUN/root/config.js")).toBe(
|
||||
"./src/extensions/codemode/worker.ts",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the emitted worker beside the bundled Node module", () => {
|
||||
expect(resolveCodemodeWorkerSpecifier("bundled-node", "file:///app/chunks/config.js")).toEqual(
|
||||
new URL("file:///app/chunks/codemode-worker.js"),
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the pi-codemode worker when unbundled", () => {
|
||||
expect(resolveCodemodeWorkerSpecifier("unbundled", import.meta.url)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -124,7 +124,7 @@ for platform in "${PLATFORMS[@]}"; do
|
||||
# Bun compiled executables only embed worker scripts when they are passed as
|
||||
# explicit build entrypoints. Bun places them at their path relative to the
|
||||
# common directory of all entrypoints, so the main entry must stay in dist/
|
||||
# for the worker URLs in src/config.ts to resolve.
|
||||
# for the worker specifiers in the runtime to resolve.
|
||||
#
|
||||
# Disable cwd bunfig.toml autoload so project preload scripts cannot crash the
|
||||
# standalone binary before pi starts (see #7684).
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { extname, join, resolve } from "node:path";
|
||||
|
||||
const WORKER_MARKER = "PI_CODEMODE_WORKER_OK";
|
||||
const SUCCESS_MARKER = "PI_CODEMODE_BINARY_SMOKE_OK";
|
||||
const FAILURE_MARKER = "PI_CODEMODE_BINARY_SMOKE_FAILED";
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
function completionChunk(id, delta, finishReason = null, usage) {
|
||||
return {
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created: 0,
|
||||
model: "codemode-smoke",
|
||||
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
||||
...(usage ? { usage } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function sendCompletion(response, chunks) {
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-cache",
|
||||
connection: "close",
|
||||
});
|
||||
for (const chunk of chunks) response.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||
response.end("data: [DONE]\n\n");
|
||||
}
|
||||
|
||||
async function readRequest(request) {
|
||||
let body = "";
|
||||
for await (const chunk of request) body += chunk.toString();
|
||||
return body;
|
||||
}
|
||||
|
||||
function toolResultContainsMarker(body) {
|
||||
const payload = JSON.parse(body);
|
||||
return (
|
||||
Array.isArray(payload.messages) &&
|
||||
payload.messages.some(
|
||||
(message) => message?.role === "tool" && JSON.stringify(message.content).includes(WORKER_MARKER),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const binaryArg = process.argv[2];
|
||||
if (!binaryArg || process.argv.length !== 3) {
|
||||
throw new Error("Usage: node scripts/smoke-test-codemode-binary.mjs <pi-binary>");
|
||||
}
|
||||
const binary = resolve(binaryArg);
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "pi-codemode-binary-smoke-"));
|
||||
let requestCount = 0;
|
||||
const server = createServer(async (request, response) => {
|
||||
if (request.method !== "POST" || !request.url?.endsWith("/chat/completions")) {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
const body = await readRequest(request);
|
||||
requestCount++;
|
||||
const id = `chatcmpl-codemode-smoke-${requestCount}`;
|
||||
if (requestCount === 1) {
|
||||
sendCompletion(response, [
|
||||
completionChunk(id, {
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_codemode_smoke",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "codemode",
|
||||
arguments: JSON.stringify({ code: `text("${WORKER_MARKER}")` }),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
completionChunk(id, {}, "tool_calls", { prompt_tokens: 1, completion_tokens: 1 }),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
const marker = toolResultContainsMarker(body) ? SUCCESS_MARKER : FAILURE_MARKER;
|
||||
sendCompletion(response, [
|
||||
completionChunk(id, { role: "assistant", content: marker }),
|
||||
completionChunk(id, {}, "stop", { prompt_tokens: 1, completion_tokens: 1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise((resolveListen, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolveListen);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Smoke-test server did not bind to a TCP port");
|
||||
await writeFile(
|
||||
join(tempDir, "models.json"),
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
"codemode-smoke": {
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
api: "openai-completions",
|
||||
apiKey: "smoke-test",
|
||||
models: [{ id: "codemode-smoke" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const piArgs = [
|
||||
"--provider",
|
||||
"codemode-smoke",
|
||||
"--model",
|
||||
"codemode-smoke",
|
||||
"--tools",
|
||||
"codemode",
|
||||
"--print",
|
||||
"--no-session",
|
||||
"--offline",
|
||||
"--no-context-files",
|
||||
"--no-skills",
|
||||
"--no-prompt-templates",
|
||||
"--no-themes",
|
||||
"Run the codemode binary smoke test.",
|
||||
];
|
||||
const javaScript = extname(binary) === ".js";
|
||||
const child = spawn(javaScript ? process.execPath : binary, javaScript ? [binary, ...piArgs] : piArgs, {
|
||||
cwd: tempDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PI_CODING_AGENT_DIR: tempDir,
|
||||
PI_OFFLINE: "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
const exitCode = await new Promise((resolveExit, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
reject(new Error(`Smoke test timed out after ${TIMEOUT_MS} ms`));
|
||||
}, TIMEOUT_MS);
|
||||
child.once("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
clearTimeout(timer);
|
||||
resolveExit(code);
|
||||
});
|
||||
});
|
||||
if (exitCode !== 0 || !stdout.includes(SUCCESS_MARKER) || requestCount < 2) {
|
||||
throw new Error(
|
||||
`Codemode binary smoke test failed (exit ${exitCode}, requests ${requestCount})\nstdout:\n${stdout}\nstderr:\n${stderr}`,
|
||||
);
|
||||
}
|
||||
process.stdout.write(stdout);
|
||||
} finally {
|
||||
await new Promise((resolveClose) => server.close(resolveClose));
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user