mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2d8cbf5b9 |
@@ -135,6 +135,32 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Backport electron-builder#10172 to the pinned build tool only.
|
||||
# Remove this step when app-builder-lib is upgraded to a release containing it.
|
||||
- name: Repair temporary keychain password in signing tool
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'KEYCHAIN_FIX'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const manifestPath = require.resolve('app-builder-lib/package.json');
|
||||
if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') {
|
||||
throw new Error('Reassess/remove the signing backport for the new app-builder-lib version');
|
||||
}
|
||||
const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js');
|
||||
let source = fs.readFileSync(sourcePath, 'utf8');
|
||||
const replacements = [
|
||||
['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'],
|
||||
['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'],
|
||||
['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'],
|
||||
];
|
||||
for (const [before, after] of replacements) {
|
||||
if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch');
|
||||
source = source.replace(before, after);
|
||||
}
|
||||
fs.writeFileSync(sourcePath, source);
|
||||
KEYCHAIN_FIX
|
||||
|
||||
- name: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -74,10 +74,7 @@ registrations remain inspectable; a changed destination requires explicit identi
|
||||
|
||||
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
|
||||
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
|
||||
operations; requesting this feature gives update guidance. The registry retries an older destination
|
||||
with capped backoff, so it comes online once updated. Self, duplicate, changed-identity, and
|
||||
authentication failures stay in `error` until an update changes the connection, `enabled`, or
|
||||
`replaceIdentity`, or A restarts.
|
||||
operations; requesting this feature gives update guidance.
|
||||
|
||||
Disposing a routed API releases its observations and session. It does not remove the saved host or
|
||||
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
|
||||
|
||||
@@ -127,7 +127,7 @@ buildNpmPackage {
|
||||
# The Nix output is not a distributable DMG, so leave it unsigned and
|
||||
# disable the hardened runtime that requires a matching signature.
|
||||
CSC_IDENTITY_AUTO_DISCOVERY=false \
|
||||
npm exec --offline -- electron-builder \
|
||||
../../node_modules/.bin/electron-builder \
|
||||
--config electron-builder.yml \
|
||||
--dir \
|
||||
--mac \
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
|
||||
sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU=
|
||||
|
||||
Generated
+1295
-1018
File diff suppressed because it is too large
Load Diff
@@ -717,13 +717,11 @@ describe("terminal emulator runtime in a real browser", () => {
|
||||
{ name: "DA1-zero", bytes: "\x1b[0c" },
|
||||
{ name: "DA2", bytes: "\x1b[>c" },
|
||||
{ name: "DA3", bytes: "\x1b[=c" },
|
||||
{ name: "XTVERSION", bytes: "\x1b[>q" },
|
||||
{ name: "DSR-5", bytes: "\x1b[5n" },
|
||||
{ name: "DSR-6", bytes: "\x1b[6n" },
|
||||
{ name: "DSR-?6", bytes: "\x1b[?6n" },
|
||||
{ name: "DECRQM", bytes: "\x1b[1$p" },
|
||||
{ name: "DECRQM-?", bytes: "\x1b[?1$p" },
|
||||
{ name: "DECRQSS", bytes: "\x1bP$qm\x1b\\" },
|
||||
{ name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" },
|
||||
{ name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" },
|
||||
{ name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" },
|
||||
|
||||
@@ -647,7 +647,6 @@ export class TerminalEmulatorRuntime {
|
||||
terminal.parser.registerCsiHandler({ final: "c" }, () => true);
|
||||
terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true);
|
||||
terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true);
|
||||
terminal.parser.registerCsiHandler({ prefix: ">", final: "q" }, () => true);
|
||||
terminal.parser.registerCsiHandler({ final: "n" }, () => true);
|
||||
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true);
|
||||
terminal.parser.registerCsiHandler({ final: "R" }, () => true);
|
||||
@@ -656,7 +655,6 @@ export class TerminalEmulatorRuntime {
|
||||
{ prefix: "?", intermediates: "$", final: "p" },
|
||||
() => true,
|
||||
);
|
||||
terminal.parser.registerDcsHandler({ intermediates: "$", final: "q" }, () => true);
|
||||
for (const code of [10, 11, 12]) {
|
||||
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
|
||||
}
|
||||
|
||||
@@ -204,16 +204,15 @@ export function createHostTransportFactory(
|
||||
}
|
||||
export function createHostClient(
|
||||
connection: string,
|
||||
config: Omit<DaemonClientConfig, "url"> & HostTransportOptions,
|
||||
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions,
|
||||
budget?: HostTransportBudget,
|
||||
): DaemonClient {
|
||||
const target = parseHostTarget(connection);
|
||||
const { webSocketAgent, transportFactory, ...clientConfig } = config;
|
||||
const { webSocketAgent, ...clientConfig } = config;
|
||||
return new DaemonClient({
|
||||
...clientConfig,
|
||||
url: "ws://host-connector/ws",
|
||||
...(target.password ? { password: target.password } : {}),
|
||||
transportFactory:
|
||||
transportFactory ?? createHostTransportFactory(connection, budget, { webSocketAgent }),
|
||||
transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"@types/node": "24.6.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"electron": "44.2.0",
|
||||
"electron-builder": "26.18.0",
|
||||
"electron-builder": "26.8.1",
|
||||
"typescript": "5.9.3",
|
||||
"unzip-crx-3": "^0.2.0",
|
||||
"vitest": "^4.1.6",
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
|
||||
describe("Claude runtime info before a turn", () => {
|
||||
const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) });
|
||||
|
||||
test("reports the explicitly configured model before the first turn", async () => {
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-haiku-5-5",
|
||||
});
|
||||
try {
|
||||
expect(await session.getRuntimeInfo()).toMatchObject({ model: "claude-haiku-5-5" });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("reports the persisted configured model after rebuilding an idle session", async () => {
|
||||
const session = await client.resumeSession({
|
||||
provider: "claude",
|
||||
sessionId: "runtime-model-before-turn",
|
||||
metadata: { cwd: process.cwd(), model: "claude-haiku-5-5" },
|
||||
});
|
||||
try {
|
||||
expect(await session.getRuntimeInfo()).toMatchObject({
|
||||
sessionId: "runtime-model-before-turn",
|
||||
model: "claude-haiku-5-5",
|
||||
});
|
||||
expect((await session.getRuntimeInfo()).model).toBe("claude-haiku-5-5");
|
||||
expect((await session.getRuntimeInfo()).extra?.runtimeModel).toBeUndefined();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("leaves the model unknown when Claude will choose its default", async () => {
|
||||
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
|
||||
try {
|
||||
expect((await session.getRuntimeInfo()).model).toBeNull();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2460,7 +2460,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const info: AgentRuntimeInfo = {
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
model: this.lastOptionsModel ?? this.config.model ?? null,
|
||||
model: this.lastOptionsModel,
|
||||
modeId: this.currentMode ?? null,
|
||||
...(this.lastRuntimeModel
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { appendFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { SessionStore, SessionStoreEntry } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
const APPEND_CHUNK_LENGTH = 1 << 20;
|
||||
|
||||
export function projectFolderSessionStore(projectFolder: string): SessionStore {
|
||||
const transcriptPath = (sessionId: string) => path.join(projectFolder, `${sessionId}.jsonl`);
|
||||
return {
|
||||
async load({ sessionId }) {
|
||||
const input = createReadStream(transcriptPath(sessionId), { encoding: "utf8" });
|
||||
const lines = createInterface({ input, crlfDelay: Infinity });
|
||||
const entries: SessionStoreEntry[] = [];
|
||||
try {
|
||||
for await (const line of lines) {
|
||||
const entry = parseTranscriptEntry(line);
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
} finally {
|
||||
lines.close();
|
||||
input.destroy();
|
||||
}
|
||||
return entries;
|
||||
},
|
||||
async append({ sessionId }, entries) {
|
||||
let chunk = "";
|
||||
for (const entry of entries) {
|
||||
chunk += `${JSON.stringify(entry)}\n`;
|
||||
if (chunk.length >= APPEND_CHUNK_LENGTH) {
|
||||
await appendFile(transcriptPath(sessionId), chunk, "utf8");
|
||||
chunk = "";
|
||||
}
|
||||
}
|
||||
await appendFile(transcriptPath(sessionId), chunk, "utf8");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseTranscriptEntry(line: string): SessionStoreEntry | null {
|
||||
if (!line.trim()) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
// Skip a line that is not JSON, as the SDK's own transcript reader does.
|
||||
return null;
|
||||
}
|
||||
return isSessionStoreEntry(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function isSessionStoreEntry(value: unknown): value is SessionStoreEntry {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
typeof (value as { type?: unknown }).type === "string"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import path from "node:path";
|
||||
import { parentPort, workerData } from "node:worker_threads";
|
||||
import { forkSession } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { projectFolderSessionStore } from "./rewind-session-store.js";
|
||||
|
||||
export interface ClaudeForkRequest {
|
||||
sessionId: string;
|
||||
upToMessageId: string;
|
||||
transcriptPath: string;
|
||||
}
|
||||
|
||||
export type ClaudeForkReply =
|
||||
| { kind: "forked"; sessionId: string }
|
||||
| {
|
||||
kind: "failed";
|
||||
message: string;
|
||||
stack: string | undefined;
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const port = parentPort!;
|
||||
const request: ClaudeForkRequest = workerData;
|
||||
try {
|
||||
// On its own, the SDK looks for the session under the daemon's CLAUDE_CONFIG_DIR, while a
|
||||
// provider can point Claude Code at another one. Forking through a store rooted at the
|
||||
// transcript's folder reads the session where Claude Code wrote it and writes the fork beside it.
|
||||
const fork = await forkSession(request.sessionId, {
|
||||
upToMessageId: request.upToMessageId,
|
||||
sessionStore: projectFolderSessionStore(path.dirname(request.transcriptPath)),
|
||||
});
|
||||
port.postMessage({ kind: "forked", sessionId: fork.sessionId } satisfies ClaudeForkReply);
|
||||
} catch (cause) {
|
||||
const error = cause instanceof Error ? cause : new Error(String(cause));
|
||||
const failure = { kind: "failed", message: error.message, stack: error.stack } as const;
|
||||
try {
|
||||
port.postMessage({ ...failure, properties: { ...error } } satisfies ClaudeForkReply);
|
||||
} catch (cloneError) {
|
||||
if (!(cloneError instanceof DOMException) || cloneError.name !== "DataCloneError") {
|
||||
throw cloneError;
|
||||
}
|
||||
port.postMessage({ ...failure, properties: {} } satisfies ClaudeForkReply);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { subscribe, unsubscribe } from "node:diagnostics_channel";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
@@ -10,9 +11,11 @@ import {
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { forkSession, type Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import { measureMainThreadWork } from "../../../../test-utils/main-thread-work.js";
|
||||
import {
|
||||
findSteeredMessageParent,
|
||||
revertClaudeConversation,
|
||||
@@ -20,6 +23,7 @@ import {
|
||||
revertClaudeFiles,
|
||||
realClaudeRewindSdk,
|
||||
} from "./rewind.js";
|
||||
import { projectFolderSessionStore } from "./rewind-session-store.js";
|
||||
import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js";
|
||||
|
||||
describe("Claude rewind", () => {
|
||||
@@ -278,8 +282,196 @@ describe("Claude rewind against the Claude SDK", () => {
|
||||
"tool output",
|
||||
]);
|
||||
});
|
||||
|
||||
test("writes the fork beside the original and leaves the original untouched", async () => {
|
||||
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-fork-"));
|
||||
const projectDir = path.join(tempRoot, "projects", "-repo");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const sessionId = randomUUID();
|
||||
const firstUser = randomUUID();
|
||||
const firstReply = randomUUID();
|
||||
const secondUser = randomUUID();
|
||||
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
writeTranscript(transcriptPath, sessionId, [
|
||||
{ type: "user", uuid: firstUser, parentUuid: null, text: "first" },
|
||||
{ type: "assistant", uuid: firstReply, parentUuid: firstUser, text: "first reply" },
|
||||
{ type: "user", uuid: secondUser, parentUuid: firstReply, text: "second" },
|
||||
{ type: "assistant", uuid: randomUUID(), parentUuid: secondUser, text: "second reply" },
|
||||
]);
|
||||
const original = readFileSync(transcriptPath, "utf8");
|
||||
|
||||
const fork = await realClaudeRewindSdk.forkSession(sessionId, {
|
||||
upToMessageId: secondUser,
|
||||
transcriptPath,
|
||||
});
|
||||
|
||||
const forkPath = path.join(projectDir, `${fork.sessionId}.jsonl`);
|
||||
expect(readdirSync(projectDir).sort()).toEqual(
|
||||
[`${sessionId}.jsonl`, `${fork.sessionId}.jsonl`].sort(),
|
||||
);
|
||||
expect(readMessageTexts(forkPath)).toEqual(["first", "first reply", "second"]);
|
||||
const forkSessionIds = readFileSync(forkPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => (JSON.parse(line) as { sessionId: string }).sessionId);
|
||||
expect(forkSessionIds).toEqual([
|
||||
fork.sessionId,
|
||||
fork.sessionId,
|
||||
fork.sessionId,
|
||||
fork.sessionId,
|
||||
]);
|
||||
expect(readFileSync(transcriptPath, "utf8")).toBe(original);
|
||||
});
|
||||
|
||||
test("rejects with the SDK's own error when the transcript is missing", async () => {
|
||||
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-missing-"));
|
||||
const sessionId = randomUUID();
|
||||
|
||||
await expect(
|
||||
realClaudeRewindSdk.forkSession(sessionId, {
|
||||
upToMessageId: randomUUID(),
|
||||
transcriptPath: path.join(tempRoot, `${sessionId}.jsonl`),
|
||||
}),
|
||||
).rejects.toThrow(new Error(`Session ${sessionId} not found`));
|
||||
});
|
||||
|
||||
test("rejects with the name and message of the error the SDK raises", async () => {
|
||||
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-invalid-"));
|
||||
const sessionId = randomUUID();
|
||||
const sdkError = await forkSession(sessionId, { upToMessageId: "not-a-uuid" }).then(
|
||||
() => null,
|
||||
(error: Error) => error,
|
||||
);
|
||||
expect(sdkError).toBeInstanceOf(Error);
|
||||
|
||||
await expect(
|
||||
realClaudeRewindSdk.forkSession(sessionId, {
|
||||
upToMessageId: "not-a-uuid",
|
||||
transcriptPath: path.join(tempRoot, `${sessionId}.jsonl`),
|
||||
}),
|
||||
).rejects.toMatchObject({ name: sdkError?.name, message: sdkError?.message });
|
||||
});
|
||||
|
||||
test("keeps a filesystem error's code when the fork fails in the worker", async () => {
|
||||
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-eisdir-"));
|
||||
const sessionId = randomUUID();
|
||||
const transcriptPath = path.join(tempRoot, `${sessionId}.jsonl`);
|
||||
mkdirSync(transcriptPath);
|
||||
|
||||
await expect(
|
||||
realClaudeRewindSdk.forkSession(sessionId, { upToMessageId: randomUUID(), transcriptPath }),
|
||||
).rejects.toMatchObject({ name: "Error", code: "EISDIR" });
|
||||
});
|
||||
|
||||
test("forks one conversation at a time", async () => {
|
||||
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-queue-"));
|
||||
const projectDir = path.join(tempRoot, "projects", "-repo");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const forks = [randomUUID(), randomUUID()].map((sessionId) => {
|
||||
const user = randomUUID();
|
||||
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
writeTranscript(transcriptPath, sessionId, [
|
||||
{ type: "user", uuid: user, parentUuid: null, text: "first" },
|
||||
{ type: "assistant", uuid: randomUUID(), parentUuid: user, text: "first reply" },
|
||||
]);
|
||||
return { sessionId, upToMessageId: user, transcriptPath };
|
||||
});
|
||||
const events: string[] = [];
|
||||
let started = 0;
|
||||
function recordWorker(message: unknown): void {
|
||||
if (typeof message !== "object" || message === null || !("worker" in message)) return;
|
||||
if (!(message.worker instanceof Worker)) return;
|
||||
const fork = ++started;
|
||||
events.push(`start ${fork}`);
|
||||
message.worker.once("message", () => events.push(`reply ${fork}`));
|
||||
}
|
||||
|
||||
subscribe("worker_threads", recordWorker);
|
||||
try {
|
||||
await Promise.all(
|
||||
forks.map(({ sessionId, upToMessageId, transcriptPath }) =>
|
||||
realClaudeRewindSdk.forkSession(sessionId, { upToMessageId, transcriptPath }),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
unsubscribe("worker_threads", recordWorker);
|
||||
}
|
||||
|
||||
expect(events).toEqual(["start 1", "reply 1", "start 2", "reply 2"]);
|
||||
});
|
||||
|
||||
test("forks a long transcript without blocking the daemon's event loop", async () => {
|
||||
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-long-"));
|
||||
const projectDir = path.join(tempRoot, "projects", "-repo");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const sessionId = randomUUID();
|
||||
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
const turns = 43_000;
|
||||
const lastUser = writeLongTranscript(transcriptPath, sessionId, turns);
|
||||
|
||||
const { result: fork, blockingMs } = await measureMainThreadWork(
|
||||
"claude fork main-thread work",
|
||||
() => realClaudeRewindSdk.forkSession(sessionId, { upToMessageId: lastUser, transcriptPath }),
|
||||
);
|
||||
|
||||
expect(blockingMs).toBeLessThan(50);
|
||||
const forkLines = readFileSync(path.join(projectDir, `${fork.sessionId}.jsonl`), "utf8")
|
||||
.trim()
|
||||
.split("\n");
|
||||
expect(forkLines).toHaveLength(turns * 2);
|
||||
expect(JSON.parse(forkLines[turns * 2 - 2]!).forkedFrom).toEqual({
|
||||
sessionId,
|
||||
messageUuid: lastUser,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude project folder session store", () => {
|
||||
let root: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) rmSync(root, { recursive: true, force: true });
|
||||
root = null;
|
||||
});
|
||||
|
||||
test("appends entries after existing ones, byte for byte, and loads them back", async () => {
|
||||
root = mkdtempSync(path.join(tmpdir(), "claude-rewind-store-"));
|
||||
const store = projectFolderSessionStore(root);
|
||||
const key = { projectKey: "-repo", sessionId: randomUUID() };
|
||||
const transcriptPath = path.join(root, `${key.sessionId}.jsonl`);
|
||||
const existing = { type: "user", uuid: randomUUID(), message: { content: "already there" } };
|
||||
writeFileSync(transcriptPath, `${JSON.stringify(existing)}\n`);
|
||||
const entries = Array.from({ length: 4_000 }, (_, index) => ({
|
||||
type: "assistant",
|
||||
uuid: randomUUID(),
|
||||
message: { content: `reply ${index} ✓ ${"x".repeat(1_000)}` },
|
||||
}));
|
||||
|
||||
await store.append(key, entries);
|
||||
|
||||
expect(readFileSync(transcriptPath, "utf8")).toBe(
|
||||
[existing, ...entries].map((entry) => `${JSON.stringify(entry)}\n`).join(""),
|
||||
);
|
||||
expect(await store.load(key)).toEqual([existing, ...entries]);
|
||||
});
|
||||
});
|
||||
|
||||
function writeLongTranscript(transcriptPath: string, sessionId: string, turns: number): string {
|
||||
const toolOutput = 'line 1: "quoted" output\twith\\escapes\n'.repeat(4);
|
||||
const lines: TranscriptLine[] = [];
|
||||
let parentUuid: string | null = null;
|
||||
let lastUser = "";
|
||||
for (let turn = 0; turn < turns; turn++) {
|
||||
lastUser = randomUUID();
|
||||
const reply = randomUUID();
|
||||
lines.push({ type: "user", uuid: lastUser, parentUuid, text: `question ${turn}` });
|
||||
lines.push({ type: "assistant", uuid: reply, parentUuid: lastUser, text: toolOutput });
|
||||
parentUuid = reply;
|
||||
}
|
||||
writeTranscript(transcriptPath, sessionId, lines);
|
||||
return lastUser;
|
||||
}
|
||||
|
||||
test.runIf(process.platform === "linux")(
|
||||
"releases the transcript descriptor after an early steer match",
|
||||
async () => {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { appendFile, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import {
|
||||
forkSession as claudeForkSession,
|
||||
type Query,
|
||||
type SessionStore,
|
||||
type SessionStoreEntry,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import pLimit from "p-limit";
|
||||
import { resolveWorkerEntrypoint } from "../../../../utils/worker-entrypoint.js";
|
||||
import type { ClaudeForkReply, ClaudeForkRequest } from "./rewind-worker.js";
|
||||
|
||||
export interface ClaudeRewindSdk {
|
||||
forkSession(
|
||||
@@ -16,60 +13,42 @@ export interface ClaudeRewindSdk {
|
||||
): Promise<{ sessionId: string }>;
|
||||
}
|
||||
|
||||
// On its own, the SDK looks for the session under the daemon's CLAUDE_CONFIG_DIR, while a
|
||||
// provider can point Claude Code at another one. Forking through a store rooted at the
|
||||
// transcript's folder reads the session where Claude Code wrote it and writes the fork beside it.
|
||||
const source = import.meta.url.endsWith(".ts");
|
||||
const workerUrl = new URL(source ? "./rewind-worker.ts" : "./rewind-worker.js", import.meta.url);
|
||||
const rewindWorker = resolveWorkerEntrypoint(workerUrl);
|
||||
const forks = pLimit(1);
|
||||
|
||||
export const realClaudeRewindSdk: ClaudeRewindSdk = {
|
||||
forkSession: (sessionId, options) =>
|
||||
claudeForkSession(sessionId, {
|
||||
upToMessageId: options.upToMessageId,
|
||||
sessionStore: projectFolderSessionStore(path.dirname(options.transcriptPath)),
|
||||
}),
|
||||
forks(() =>
|
||||
forkInWorker({
|
||||
sessionId,
|
||||
upToMessageId: options.upToMessageId,
|
||||
transcriptPath: options.transcriptPath,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
function projectFolderSessionStore(projectFolder: string): SessionStore {
|
||||
const transcriptPath = (sessionId: string) => path.join(projectFolder, `${sessionId}.jsonl`);
|
||||
return {
|
||||
async load({ sessionId }) {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(transcriptPath(sessionId), "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
function forkInWorker(request: ClaudeForkRequest): Promise<{ sessionId: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(rewindWorker.entrypoint, {
|
||||
eval: rewindWorker.eval,
|
||||
execArgv: [],
|
||||
workerData: request,
|
||||
});
|
||||
worker.once("message", (reply: ClaudeForkReply) => {
|
||||
void worker.terminate();
|
||||
if (reply.kind === "forked") {
|
||||
resolve({ sessionId: reply.sessionId });
|
||||
return;
|
||||
}
|
||||
return parseTranscriptEntries(text);
|
||||
},
|
||||
async append({ sessionId }, entries) {
|
||||
const lines = entries.map((entry) => `${JSON.stringify(entry)}\n`).join("");
|
||||
await appendFile(transcriptPath(sessionId), lines, "utf8");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseTranscriptEntries(text: string): SessionStoreEntry[] {
|
||||
const entries: SessionStoreEntry[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
// Skip a line that is not JSON, as the SDK's own transcript reader does.
|
||||
continue;
|
||||
}
|
||||
if (isSessionStoreEntry(parsed)) entries.push(parsed);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function isSessionStoreEntry(value: unknown): value is SessionStoreEntry {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
typeof (value as { type?: unknown }).type === "string"
|
||||
);
|
||||
const error = new Error(reply.message);
|
||||
error.stack = reply.stack;
|
||||
reject(Object.assign(error, reply.properties));
|
||||
});
|
||||
worker.once("error", reject);
|
||||
worker.once("exit", (code) => reject(new Error(`Claude rewind worker exited (code ${code})`)));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -784,211 +784,6 @@ test("old capabilities fail before execution and authentication errors recover t
|
||||
expect((await online(paseo)).hosts[0]!.serverId).toBe(b.daemon.getServerId());
|
||||
});
|
||||
|
||||
async function updatableDestination() {
|
||||
let features: { connectedHosts: boolean; hostMultiplexing: boolean } | undefined;
|
||||
let holding = false;
|
||||
const held: (() => void)[] = [];
|
||||
let connections = 0;
|
||||
const http = createServer();
|
||||
const ws = new WebSocketServer({ server: http });
|
||||
ws.on("connection", (socket) => {
|
||||
connections++;
|
||||
socket.on("message", (data) => {
|
||||
const frame = JSON.parse(data.toString());
|
||||
if (frame.type === "ping") socket.send(JSON.stringify({ type: "pong" }));
|
||||
if (frame.type !== "hello") return;
|
||||
const reply = () =>
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "server_info",
|
||||
serverId: "srv_updated",
|
||||
hostname: "updated",
|
||||
version: "0.12.0",
|
||||
...(features ? { features } : {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (holding) held.push(reply);
|
||||
else reply();
|
||||
});
|
||||
});
|
||||
http.listen(0, "127.0.0.1");
|
||||
await once(http, "listening");
|
||||
cleanup.push(async () => {
|
||||
for (const socket of ws.clients) socket.terminate();
|
||||
await new Promise<void>((resolve) => ws.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => http.close(() => resolve()));
|
||||
});
|
||||
const address = http.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing destination port");
|
||||
return {
|
||||
port: address.port,
|
||||
connections: () => connections,
|
||||
hold: () => {
|
||||
holding = true;
|
||||
},
|
||||
update: () => {
|
||||
features = { connectedHosts: true, hostMultiplexing: true };
|
||||
holding = false;
|
||||
for (const reply of held.splice(0)) reply();
|
||||
},
|
||||
};
|
||||
}
|
||||
async function capabilityRetry(paseo: ReturnType<typeof createPaseoApi>): Promise<number> {
|
||||
let retryAt = "";
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const row = (await paseo.hosts.list()).hosts[0];
|
||||
retryAt = row?.nextRetryAt ?? "";
|
||||
return row;
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
.toMatchObject({
|
||||
status: "offline",
|
||||
nextRetryAt: expect.any(String),
|
||||
error: { code: "unsupported_capability" },
|
||||
});
|
||||
return Date.parse(retryAt);
|
||||
}
|
||||
|
||||
test("a destination updated past unsupported_capability keeps its guidance while retrying and comes online, while duplicate_host stays terminal", async () => {
|
||||
const destination = await updatableDestination();
|
||||
const a = await daemon();
|
||||
const { paseo } = await api(a);
|
||||
const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` });
|
||||
await capabilityRetry(paseo);
|
||||
destination.hold();
|
||||
await expect
|
||||
.poll(async () => (await paseo.hosts.list()).hosts[0], { timeout: 5000 })
|
||||
.toMatchObject({ status: "connecting", error: { code: "unsupported_capability" } });
|
||||
await expect(paseo.hosts.client("srv_updated")).rejects.toMatchObject({
|
||||
code: "unsupported_capability",
|
||||
});
|
||||
destination.update();
|
||||
expect((await online(paseo)).hosts[0]).toMatchObject({
|
||||
registrationId: host.registrationId,
|
||||
serverId: "srv_updated",
|
||||
nextRetryAt: null,
|
||||
error: null,
|
||||
});
|
||||
await paseo.hosts.add({ connection: `ws://127.0.0.1:${destination.port}/ws` });
|
||||
await expect
|
||||
.poll(async () => (await paseo.hosts.list()).hosts[1], { timeout: 5000 })
|
||||
.toMatchObject({ status: "error", nextRetryAt: null, error: { code: "duplicate_host" } });
|
||||
});
|
||||
|
||||
test("disabling or removing a host cancels its pending capability retry", async () => {
|
||||
const destination = await updatableDestination();
|
||||
const a = await daemon();
|
||||
const { paseo } = await api(a);
|
||||
const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` });
|
||||
let retryAt = await capabilityRetry(paseo);
|
||||
let connections = destination.connections();
|
||||
await paseo.hosts.update(host.registrationId, { enabled: false });
|
||||
await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500));
|
||||
expect(destination.connections()).toBe(connections);
|
||||
await paseo.hosts.update(host.registrationId, { enabled: true });
|
||||
retryAt = await capabilityRetry(paseo);
|
||||
connections = destination.connections();
|
||||
await paseo.hosts.remove(host.registrationId);
|
||||
await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500));
|
||||
expect(destination.connections()).toBe(connections);
|
||||
});
|
||||
|
||||
import {
|
||||
createDaemonChannel,
|
||||
exportPublicKey,
|
||||
generateKeyPair,
|
||||
openDaemonSecureChannel,
|
||||
type Transport,
|
||||
} from "@getpaseo/relay/e2ee";
|
||||
test("a capability retry over a relay keeps the protected endpoint security it negotiated", async () => {
|
||||
const keys = generateKeyPair();
|
||||
const legacyReceived: unknown[] = [];
|
||||
let connections = 0;
|
||||
let retryClosed = false;
|
||||
const http = createServer();
|
||||
const ws = new WebSocketServer({ server: http });
|
||||
ws.on("connection", (socket) => {
|
||||
connections++;
|
||||
const wire: Transport = {
|
||||
send: (data) => socket.send(data),
|
||||
close: (code, reason) => socket.close(code, reason),
|
||||
onmessage: null,
|
||||
onclose: null,
|
||||
onerror: null,
|
||||
};
|
||||
socket.on("message", (data, isBinary) => {
|
||||
const bytes = Buffer.from(data as Buffer);
|
||||
wire.onmessage?.({
|
||||
data: isBinary
|
||||
? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
|
||||
: bytes.toString(),
|
||||
isBinary,
|
||||
});
|
||||
});
|
||||
socket.on("close", (code, reason) => wire.onclose?.(code, reason.toString()));
|
||||
if (connections > 1) {
|
||||
socket.on("close", () => {
|
||||
retryClosed = true;
|
||||
});
|
||||
void createDaemonChannel(wire, keys, {
|
||||
onmessage: (data) => legacyReceived.push(data),
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const channel = openDaemonSecureChannel(wire, {
|
||||
daemonKeyPair: keys,
|
||||
events: {
|
||||
onmessage: (data) => {
|
||||
if (typeof data !== "string" || JSON.parse(data).type !== "hello") return;
|
||||
void channel.then((secure) =>
|
||||
secure.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "server_info",
|
||||
serverId: "srv_relayed",
|
||||
hostname: "relayed",
|
||||
version: "0.12.0",
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
void channel.catch(() => {});
|
||||
});
|
||||
http.listen(0, "127.0.0.1");
|
||||
await once(http, "listening");
|
||||
cleanup.push(async () => {
|
||||
for (const socket of ws.clients) socket.terminate();
|
||||
await new Promise<void>((resolve) => ws.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => http.close(() => resolve()));
|
||||
});
|
||||
const address = http.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing relay port");
|
||||
const a = await daemon();
|
||||
const { paseo } = await api(a);
|
||||
await paseo.hosts.add({
|
||||
connection: `relay://127.0.0.1:${address.port}/srv_relayed?key=${encodeURIComponent(exportPublicKey(keys.publicKey))}`,
|
||||
});
|
||||
await capabilityRetry(paseo);
|
||||
await expect.poll(() => retryClosed, { timeout: 15000 }).toBe(true);
|
||||
expect(legacyReceived).toEqual([]);
|
||||
});
|
||||
|
||||
test("an unreachable saved target becomes online after its daemon starts without being added again", async () => {
|
||||
const reservation = createServer();
|
||||
reservation.listen(0, "127.0.0.1");
|
||||
|
||||
@@ -7,12 +7,10 @@ import { z } from "zod";
|
||||
import {
|
||||
createHostClient,
|
||||
createHostChannelTransport,
|
||||
createHostTransportFactory,
|
||||
parseHostTarget,
|
||||
HostTransportBudget,
|
||||
} from "@getpaseo/client/node";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { DaemonTransportFactory } from "@getpaseo/client/internal/daemon-client-transport-types";
|
||||
import {
|
||||
HostSummarySchema,
|
||||
HostAddSchema,
|
||||
@@ -237,30 +235,23 @@ export class ConnectedHosts {
|
||||
if (route.registrationId === id)
|
||||
route.close(code, "Host access was disabled, replaced or removed");
|
||||
}
|
||||
private start(
|
||||
row: Saved,
|
||||
resume?: { failures: number; transports: DaemonTransportFactory },
|
||||
): void {
|
||||
private start(row: Saved): void {
|
||||
if (!row.enabled || this.closed) return;
|
||||
const transports =
|
||||
resume?.transports ??
|
||||
createHostTransportFactory(row.connection, this.budget, {
|
||||
webSocketAgent: webSocketProxyAgent,
|
||||
});
|
||||
let stopped = false;
|
||||
let attempts = resume?.failures ?? 0;
|
||||
let retry: ReturnType<typeof setTimeout> | undefined;
|
||||
const client = createHostClient(row.connection, {
|
||||
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
|
||||
connectTimeoutMs: 10000,
|
||||
transportFactory: transports,
|
||||
});
|
||||
const client = createHostClient(
|
||||
row.connection,
|
||||
{
|
||||
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
|
||||
connectTimeoutMs: 10000,
|
||||
webSocketAgent: webSocketProxyAgent,
|
||||
},
|
||||
this.budget,
|
||||
);
|
||||
const live: Live = {
|
||||
client,
|
||||
accepted: false,
|
||||
stop: () => {
|
||||
stopped = true;
|
||||
clearTimeout(retry);
|
||||
void client.close();
|
||||
},
|
||||
};
|
||||
@@ -294,7 +285,7 @@ export class ConnectedHosts {
|
||||
if (info.features?.connectedHosts !== true || info.features?.hostMultiplexing !== true)
|
||||
throw new HostError(
|
||||
"unsupported_capability",
|
||||
"Update the destination daemon to use connected hosts; retrying automatically.",
|
||||
"Update the destination daemon to use connected hosts.",
|
||||
);
|
||||
// Pin and persist under the same writer as management to prevent discovery races.
|
||||
await this.mutate(async () => {
|
||||
@@ -313,22 +304,16 @@ export class ConnectedHosts {
|
||||
if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return;
|
||||
Object.assign(row, next);
|
||||
live.accepted = true;
|
||||
attempts = 0;
|
||||
this.changed();
|
||||
});
|
||||
} catch (error) {
|
||||
if (stopped) return;
|
||||
row.status = "error";
|
||||
row.error = connectionFailure(error, client.authFailureReason !== null) ?? {
|
||||
code: "host_offline",
|
||||
message: "Host connection failed",
|
||||
};
|
||||
if (terminalFailures.has(row.error.code)) {
|
||||
row.status = "error";
|
||||
row.nextRetryAt = null;
|
||||
} else {
|
||||
stopped = true;
|
||||
retry = this.scheduleAcceptanceRetry(row, attempts, transports);
|
||||
}
|
||||
row.nextRetryAt = null;
|
||||
this.changed();
|
||||
await client.close();
|
||||
}
|
||||
@@ -344,17 +329,18 @@ export class ConnectedHosts {
|
||||
if (route.registrationId === row.registrationId) route.close();
|
||||
row.status = state.status === "connecting" ? "connecting" : "offline";
|
||||
row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : null;
|
||||
if (state.status === "disconnected")
|
||||
row.error = connectionFailure(
|
||||
Object.assign(new Error("Check the SSH configuration and daemon credentials"), {
|
||||
code: state.failureCode,
|
||||
}),
|
||||
client.authFailureReason !== null,
|
||||
) ?? {
|
||||
code: "host_offline",
|
||||
message: "Cannot reach host. Check the connection; retrying automatically.",
|
||||
};
|
||||
else if (row.error?.code !== "unsupported_capability") row.error = null;
|
||||
row.error =
|
||||
state.status === "disconnected"
|
||||
? (connectionFailure(
|
||||
Object.assign(new Error("Check the SSH configuration and daemon credentials"), {
|
||||
code: state.failureCode,
|
||||
}),
|
||||
client.authFailureReason !== null,
|
||||
) ?? {
|
||||
code: "host_offline",
|
||||
message: "Cannot reach host. Check the connection; retrying automatically.",
|
||||
})
|
||||
: null;
|
||||
if (row.error?.code === "authentication_failed") {
|
||||
row.status = "error";
|
||||
row.nextRetryAt = null;
|
||||
@@ -365,18 +351,6 @@ export class ConnectedHosts {
|
||||
});
|
||||
void client.connect().catch(() => {});
|
||||
}
|
||||
private scheduleAcceptanceRetry(
|
||||
row: Saved,
|
||||
failures: number,
|
||||
transports: DaemonTransportFactory,
|
||||
): ReturnType<typeof setTimeout> {
|
||||
const delay = acceptanceRetryDelayMs(failures);
|
||||
row.status = "offline";
|
||||
row.nextRetryAt = new Date(Date.now() + delay).toISOString();
|
||||
const retry = setTimeout(() => this.start(row, { failures: failures + 1, transports }), delay);
|
||||
retry.unref();
|
||||
return retry;
|
||||
}
|
||||
async openRoute(input: {
|
||||
serverId: string;
|
||||
scopeId: string;
|
||||
@@ -557,20 +531,6 @@ export class ConnectedHosts {
|
||||
}
|
||||
}
|
||||
|
||||
const ACCEPTANCE_RETRY_BASE_MS = 1500;
|
||||
const ACCEPTANCE_RETRY_MAX_MS = 30_000;
|
||||
|
||||
function acceptanceRetryDelayMs(failures: number): number {
|
||||
return Math.min(ACCEPTANCE_RETRY_BASE_MS * 2 ** failures, ACCEPTANCE_RETRY_MAX_MS);
|
||||
}
|
||||
|
||||
const terminalFailures = new Set([
|
||||
"server_id_mismatch",
|
||||
"self_host",
|
||||
"duplicate_host",
|
||||
"authentication_failed",
|
||||
]);
|
||||
|
||||
function connectionFailure(error: unknown, authenticationFailed: boolean): HostSummary["error"] {
|
||||
if (error instanceof HostError) return { code: error.code, message: error.message };
|
||||
if (error instanceof Error && "code" in error && error.code === "authentication_failed")
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import pLimit from "p-limit";
|
||||
import { ZodError } from "zod";
|
||||
import { resolveWorkerEntrypoint } from "../../utils/worker-entrypoint.js";
|
||||
import type * as build from "./compiler-build.js";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
import type { CompilerReply } from "./compiler-worker.js";
|
||||
@@ -37,9 +36,7 @@ const workerUrl = new URL(
|
||||
);
|
||||
// Resolve the development loader once at module initialization, never during a
|
||||
// compile request. Packaged workers execute ordinary JavaScript without tsx.
|
||||
const workerEntrypoint = source
|
||||
? `import(${JSON.stringify(pathToFileURL(createRequire(import.meta.url).resolve("tsx/esm/api")).href)}).then(({ register }) => { register(); return import(${JSON.stringify(workerUrl.href)}); });`
|
||||
: workerUrl;
|
||||
const compilerWorker = resolveWorkerEntrypoint(workerUrl);
|
||||
|
||||
// Reuse the isolate during a build batch (notably daemon bootstrap), then release
|
||||
// TypeScript's heap when idle. Only this owner admits work or stops the worker.
|
||||
@@ -74,7 +71,10 @@ class PluginCompiler {
|
||||
}
|
||||
|
||||
private startWorker(): Worker {
|
||||
const worker = new Worker(workerEntrypoint, { eval: source, execArgv: [] });
|
||||
const worker = new Worker(compilerWorker.entrypoint, {
|
||||
eval: compilerWorker.eval,
|
||||
execArgv: [],
|
||||
});
|
||||
this.worker = worker;
|
||||
// Keep error/exit ownership while idle too. The next caller gets a fresh
|
||||
// isolate after a crash; author build commands are never retried.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { parentPort, workerData } from "node:worker_threads";
|
||||
import { expandTilde } from "../path.js";
|
||||
|
||||
const port = parentPort!;
|
||||
const input: string = workerData;
|
||||
port.postMessage(expandTilde(input));
|
||||
@@ -0,0 +1,34 @@
|
||||
import { once } from "node:events";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { expandTilde } from "./path.js";
|
||||
import { resolveWorkerEntrypoint } from "./worker-entrypoint.js";
|
||||
|
||||
describe("worker entrypoint", () => {
|
||||
test("starts a compiled worker from its own URL", () => {
|
||||
const entry = resolveWorkerEntrypoint(
|
||||
new URL("file:///app/dist/server/server/plugins/compiler-worker.js"),
|
||||
);
|
||||
|
||||
expect({ ...entry, entrypoint: String(entry.entrypoint) }).toEqual({
|
||||
entrypoint: "file:///app/dist/server/server/plugins/compiler-worker.js",
|
||||
eval: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("runs a TypeScript worker, with its .js imports, through tsx", async () => {
|
||||
const entry = resolveWorkerEntrypoint(
|
||||
new URL("./test-fixtures/expand-tilde-worker.ts", import.meta.url),
|
||||
);
|
||||
const worker = new Worker(entry.entrypoint, {
|
||||
eval: entry.eval,
|
||||
execArgv: [],
|
||||
workerData: "~/project",
|
||||
});
|
||||
|
||||
const [expanded] = await once(worker, "message");
|
||||
|
||||
expect(expanded).toBe(expandTilde("~/project"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export interface WorkerEntrypoint {
|
||||
entrypoint: string | URL;
|
||||
eval: boolean;
|
||||
}
|
||||
|
||||
export function resolveWorkerEntrypoint(workerUrl: URL): WorkerEntrypoint {
|
||||
if (!workerUrl.pathname.endsWith(".ts")) return { entrypoint: workerUrl, eval: false };
|
||||
const tsxUrl = pathToFileURL(createRequire(import.meta.url).resolve("tsx/esm/api")).href;
|
||||
return {
|
||||
entrypoint: `import(${JSON.stringify(tsxUrl)}).then(({ register }) => { register(); return import(${JSON.stringify(workerUrl.href)}); });`,
|
||||
eval: true,
|
||||
};
|
||||
}
|
||||
@@ -680,46 +680,51 @@ test("macOS jobs use latest runners while preserving release architectures", ()
|
||||
assert.match(release, /runner: macos-26-intel\n\s+electron_arch: x64/);
|
||||
});
|
||||
|
||||
test("Nix desktop packaging resolves the workspace-local builder", () => {
|
||||
const source = readFileSync(new URL("nix/desktop-package.nix", repoRoot), "utf8");
|
||||
const invocation = source.match(/^\s+([^\n]*electron-builder) \\\n/m)?.[1];
|
||||
assert.ok(invocation, "Nix invokes the desktop packaging tool");
|
||||
const directory = mkdtempSync(join(tmpdir(), "nix-builder-resolution-"));
|
||||
test("macOS signing repair keeps certificate and keychain passwords separate", () => {
|
||||
const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8");
|
||||
const match = workflow.match(/<<'KEYCHAIN_FIX'\n([\s\S]*?)\n KEYCHAIN_FIX/);
|
||||
assert.ok(match, "the release workflow repairs the pinned signing tool");
|
||||
const script = match[1].replace(/^ /gm, "");
|
||||
const original = [
|
||||
"return await importCerts(keychainFile, certPaths, cscPasswords);",
|
||||
"async function importCerts(keychainFile, paths, keyPasswords) {",
|
||||
'["import", paths[i], "-P", password]',
|
||||
'["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]',
|
||||
"}",
|
||||
].join("\n");
|
||||
const directory = mkdtempSync(join(tmpdir(), "signing-repair-"));
|
||||
try {
|
||||
const desktop = join(directory, "packages/desktop");
|
||||
mkdirSync(join(desktop, "node_modules/.bin"), { recursive: true });
|
||||
mkdirSync(join(desktop, "node_modules/electron-builder"));
|
||||
writeFileSync(
|
||||
join(desktop, "node_modules/electron-builder/package.json"),
|
||||
JSON.stringify({
|
||||
name: "electron-builder",
|
||||
version: "1.0.0",
|
||||
bin: { "electron-builder": "../.bin/electron-builder" },
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
join(directory, "package.json"),
|
||||
JSON.stringify({ private: true, workspaces: ["packages/desktop"] }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(desktop, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "desktop-fixture",
|
||||
private: true,
|
||||
devDependencies: { "electron-builder": "1.0.0" },
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
join(desktop, "node_modules/.bin/electron-builder"),
|
||||
'#!/bin/sh\nprintf "workspace-builder\\n"\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const result = spawnSync("sh", ["-c", `${invocation} --version`], {
|
||||
cwd: desktop,
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout.trim(), "workspace-builder");
|
||||
const packageDirectory = join(directory, "node_modules/app-builder-lib");
|
||||
mkdirSync(join(packageDirectory, "out/codeSign"), { recursive: true });
|
||||
const manifest = join(packageDirectory, "package.json");
|
||||
const sourcePath = join(packageDirectory, "out/codeSign/macCodeSign.js");
|
||||
for (const [version, source, succeeds] of [
|
||||
["26.8.1", original, true],
|
||||
["26.8.1", original.replace("cscPasswords);", "differentArguments);"), false],
|
||||
["26.18.0", original, false],
|
||||
]) {
|
||||
writeFileSync(manifest, JSON.stringify({ name: "app-builder-lib", version }));
|
||||
writeFileSync(sourcePath, source);
|
||||
const result = spawnSync(process.execPath, ["-e", script], {
|
||||
cwd: directory,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const actual = readFileSync(sourcePath, "utf8");
|
||||
if (succeeds) {
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(
|
||||
actual.includes("importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)"),
|
||||
);
|
||||
assert.ok(
|
||||
actual.includes("importCerts(keychainFile, paths, keyPasswords, keychainPassword)"),
|
||||
);
|
||||
assert.ok(actual.includes('["import", paths[i], "-P", password]'));
|
||||
assert.ok(actual.includes('"-k", keychainPassword, keychainFile]'));
|
||||
} else {
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(actual, source, "unexpected tool inputs must fail without a partial write");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { cp, mkdtemp, rm } from "node:fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -9,6 +10,12 @@ import { promisify } from "node:util";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const bridgeModule = "packages/server/dist/server/server/agent/providers/opencode/bridge.js";
|
||||
const claudeDist = "packages/server/dist/server/server/agent/providers/claude";
|
||||
const workerThreadEntrypoints = [
|
||||
"packages/server/dist/server/server/plugins/compiler-worker.js",
|
||||
`${claudeDist}/rewind-worker.js`,
|
||||
`${claudeDist}/rewind-session-store.js`,
|
||||
];
|
||||
|
||||
// Mirrors nix/package.nix's installPhase: copy every traced path into $out.
|
||||
async function installTracedDaemon(outRoot) {
|
||||
@@ -17,12 +24,14 @@ async function installTracedDaemon(outRoot) {
|
||||
[path.join(repoRoot, "scripts/trace-daemon.mjs")],
|
||||
{ cwd: repoRoot, maxBuffer: 64 * 1024 * 1024 },
|
||||
);
|
||||
for (const file of stdout.split("\n").filter(Boolean)) {
|
||||
const files = stdout.split("\n").filter(Boolean);
|
||||
for (const file of files) {
|
||||
await cp(path.join(repoRoot, file), path.join(outRoot, file), {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
test("traced daemon closure ships the OpenCode bridge plugin for every OpenCode version", async () => {
|
||||
@@ -44,3 +53,32 @@ test("traced daemon closure ships the OpenCode bridge plugin for every OpenCode
|
||||
await rm(outRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("traced daemon closure ships the worker-thread entrypoints and what they load", async () => {
|
||||
const outRoot = await mkdtemp(path.join(os.tmpdir(), "trace-daemon-dist-"));
|
||||
try {
|
||||
const files = await installTracedDaemon(outRoot);
|
||||
for (const file of workerThreadEntrypoints) {
|
||||
assert.ok(files.includes(file), `${file} is missing from the traced closure`);
|
||||
}
|
||||
|
||||
const { realClaudeRewindSdk } = await import(
|
||||
pathToFileURL(path.join(outRoot, claudeDist, "rewind.js")).href
|
||||
);
|
||||
const sessionId = randomUUID();
|
||||
const user = randomUUID();
|
||||
const transcriptPath = path.join(outRoot, `${sessionId}.jsonl`);
|
||||
await writeFile(
|
||||
transcriptPath,
|
||||
`${JSON.stringify({ type: "user", uuid: user, parentUuid: null, sessionId, message: { role: "user", content: "first" } })}\n`,
|
||||
);
|
||||
const fork = await realClaudeRewindSdk.forkSession(sessionId, {
|
||||
upToMessageId: user,
|
||||
transcriptPath,
|
||||
});
|
||||
const forked = await readFile(path.join(outRoot, `${fork.sessionId}.jsonl`), "utf8");
|
||||
assert.equal(JSON.parse(forked.split("\n")[0]).forkedFrom.messageUuid, user);
|
||||
} finally {
|
||||
await rm(outRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user