mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bd38fb236 | ||
|
|
7e5ad53491 | ||
|
|
a529578cd1 |
@@ -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
@@ -3,7 +3,7 @@ import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
submitMessage,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -12,14 +12,8 @@ function userMessages(page: Page) {
|
||||
return page.getByTestId("user-message");
|
||||
}
|
||||
|
||||
async function sendDraftWhenReady(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
||||
await pressInComposer(page, "Enter");
|
||||
}
|
||||
|
||||
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
||||
await fillComposerDraft(page, text);
|
||||
await sendDraftWhenReady(page);
|
||||
await submitMessage(page, text);
|
||||
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
||||
await expectComposerDraft(page, "");
|
||||
}
|
||||
@@ -55,7 +49,7 @@ test.describe("Composer recall", () => {
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
|
||||
await sendDraftWhenReady(page);
|
||||
await pressInComposer(page, "Enter");
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
} finally {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -120,7 +120,6 @@ import {
|
||||
} from "../provider-launch-config.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
|
||||
import { resolveResumeProcessCwd } from "./resume-process-cwd.js";
|
||||
import {
|
||||
buildStringCommandShellInvocation,
|
||||
createStringCommandShellEnvOverlay,
|
||||
@@ -166,6 +165,18 @@ function rejectOnProcessFailure(child: ChildProcess, stderrChunks: string[]): Pr
|
||||
});
|
||||
}
|
||||
|
||||
async function isDirectory(target: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fs.stat(target)).isDirectory();
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT" || code === "ENOTDIR") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -2872,8 +2883,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
// Loading an archived agent's history runs nothing in its working directory, which
|
||||
// may have been removed with its worktree. session/load still names the original
|
||||
// directory; only the process starts from the home directory.
|
||||
private resolveProcessCwd(): Promise<string> {
|
||||
return resolveResumeProcessCwd(this.config.cwd, this.resumePurpose);
|
||||
private async resolveProcessCwd(): Promise<string> {
|
||||
if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) {
|
||||
return this.config.cwd;
|
||||
}
|
||||
return homedir();
|
||||
}
|
||||
|
||||
private async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { constants as bufferConstants } from "node:buffer";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
@@ -237,64 +236,4 @@ describe("importing a renamed Claude session", () => {
|
||||
|
||||
await expect(importSession(sessionId)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("reads the rename from a transcript too large to hold as one string", async () => {
|
||||
const sessionId = "oversized-transcript";
|
||||
const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir });
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
const transcript = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
// A hole stands in for the long session this used to fail on: the file only
|
||||
// has to be longer than a string can be, and the rename sits at its end.
|
||||
await fs.writeFile(transcript, "");
|
||||
await fs.truncate(transcript, bufferConstants.MAX_STRING_LENGTH + 1);
|
||||
await fs.appendFile(
|
||||
transcript,
|
||||
`\n${[
|
||||
userRecord({ sessionId, content: "Trace the slow query" }),
|
||||
{ type: "custom-title", customTitle: "Slow query hunt", sessionId },
|
||||
]
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join("\n")}\n`,
|
||||
);
|
||||
|
||||
const imported = await importSession(sessionId);
|
||||
|
||||
expect(imported.config.title).toBe("Slow query hunt");
|
||||
});
|
||||
|
||||
test("reads the rename from behind more records than one read covers", async () => {
|
||||
const sessionId = "late-records-rename";
|
||||
await writeSession({
|
||||
sessionId,
|
||||
records: [
|
||||
userRecord({ sessionId, content: "Review this project" }),
|
||||
{ type: "custom-title", customTitle: "Scrollback review", sessionId },
|
||||
// One record longer than a single read, then enough records to leave the
|
||||
// rename several reads back from the end of the file.
|
||||
userRecord({ sessionId, content: "x".repeat(128 * 1024) }),
|
||||
...Array.from({ length: 64 }, (_, index) =>
|
||||
userRecord({ sessionId, content: `${"y".repeat(4 * 1024)} ${index}` }),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const imported = await importSession(sessionId);
|
||||
|
||||
expect(imported.config.title).toBe("Scrollback review");
|
||||
});
|
||||
|
||||
test("imports a large record after the last rename without repeatedly copying it", async () => {
|
||||
const sessionId = "large-record-after-rename";
|
||||
await writeSession({
|
||||
sessionId,
|
||||
records: [
|
||||
{ type: "custom-title", customTitle: "Large attachment review", sessionId },
|
||||
userRecord({ sessionId, content: "x".repeat(96 * 1024 * 1024) }),
|
||||
],
|
||||
});
|
||||
|
||||
const imported = await importSession(sessionId);
|
||||
|
||||
expect(imported.config.title).toBe("Large attachment review");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
@@ -6853,79 +6853,40 @@ function normalizeClaudeSessionTitle(title: string | null): string | null {
|
||||
}
|
||||
|
||||
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title";
|
||||
const CLAUDE_CUSTOM_TITLE_NEEDLE = Buffer.from(`"${CLAUDE_CUSTOM_TITLE_TYPE}"`);
|
||||
const CLAUDE_TITLE_SCAN_CHUNK_BYTES = 64 * 1024;
|
||||
|
||||
function readClaudeCustomTitleFromRecord(record: Buffer): string | null {
|
||||
// The needle also matches a prompt that quotes it, so a record still has to
|
||||
// parse as a `custom-title` entry carrying a usable name. Records without the
|
||||
// needle are never decoded, so a long one costs a byte search and nothing more.
|
||||
if (!record.includes(CLAUDE_CUSTOM_TITLE_NEEDLE)) {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(record.toString("utf8").trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const entry = toObjectRecord(parsed);
|
||||
if (!entry || entry.isSidechain || entry.type !== CLAUDE_CUSTOM_TITLE_TYPE) {
|
||||
return null;
|
||||
}
|
||||
return normalizeClaudeSessionTitle(
|
||||
typeof entry.customTitle === "string" ? entry.customTitle : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `custom-title` record is re-emitted every time the session is saved, so
|
||||
* the current name is the last usable one in the file. Reading backwards a chunk
|
||||
* at a time finds it without walking the transcript, which matters because
|
||||
* sessions reach tens of megabytes, and never holds the file as one string, so a
|
||||
* transcript past V8's maximum string length still imports.
|
||||
* the current name is the last usable one in the file. Searching backwards finds it without
|
||||
* walking the transcript, which matters because sessions reach tens of
|
||||
* megabytes.
|
||||
*/
|
||||
async function readLastClaudeCustomTitle(filePath: string): Promise<string | null> {
|
||||
const handle = await fsPromises.open(filePath, "r");
|
||||
try {
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile()) {
|
||||
// Opening a directory succeeds on Windows even though reading it does not,
|
||||
// so reject the way reading the transcript as a file would.
|
||||
throw Object.assign(
|
||||
new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`),
|
||||
{ code: "EISDIR", path: filePath },
|
||||
function readLastClaudeCustomTitle(content: string): string | null {
|
||||
const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`;
|
||||
let at = content.lastIndexOf(needle);
|
||||
while (at >= 0) {
|
||||
const lineStart = content.lastIndexOf("\n", at) + 1;
|
||||
const lineEnd = content.indexOf("\n", at);
|
||||
const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line.trim());
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
const entry = toObjectRecord(parsed);
|
||||
if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) {
|
||||
const title = normalizeClaudeSessionTitle(
|
||||
typeof entry.customTitle === "string" ? entry.customTitle : null,
|
||||
);
|
||||
}
|
||||
let end = stats.size;
|
||||
// Retain unfinished record pieces in reverse file order. Assemble each
|
||||
// complete record once, so a large tool/image record costs linear work.
|
||||
let pieces: Buffer[] = [];
|
||||
while (end > 0) {
|
||||
const length = Math.min(CLAUDE_TITLE_SCAN_CHUNK_BYTES, end);
|
||||
const chunk = Buffer.alloc(length);
|
||||
await handle.read(chunk, 0, length, end - length);
|
||||
let recordEnd = chunk.length;
|
||||
for (
|
||||
let at = chunk.lastIndexOf(10);
|
||||
at >= 0;
|
||||
at = at === 0 ? -1 : chunk.lastIndexOf(10, at - 1)
|
||||
) {
|
||||
pieces.push(chunk.subarray(at + 1, recordEnd));
|
||||
const record = Buffer.concat(pieces.toReversed());
|
||||
const title = readClaudeCustomTitleFromRecord(record);
|
||||
if (title) return title;
|
||||
pieces = [];
|
||||
recordEnd = at;
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
if (recordEnd > 0) pieces.push(chunk.subarray(0, recordEnd));
|
||||
end -= length;
|
||||
}
|
||||
// The first record in the file has no newline in front of it.
|
||||
return readClaudeCustomTitleFromRecord(Buffer.concat(pieces.toReversed()));
|
||||
} finally {
|
||||
await handle.close();
|
||||
// The needle also matches a prompt that quotes it, and a record can carry no
|
||||
// usable name, so keep looking backwards for one that does.
|
||||
at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
||||
@@ -6937,8 +6898,9 @@ async function readClaudeRenamedSessionTitle(input: {
|
||||
sessionId: string;
|
||||
configDir: string;
|
||||
}): Promise<string | null> {
|
||||
let content: string;
|
||||
try {
|
||||
return await readLastClaudeCustomTitle(await claudeTranscriptPath(input));
|
||||
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
|
||||
} catch (error) {
|
||||
// A session with no transcript on disk has no rename to carry over. Every
|
||||
// other read failure is a real fault and stays explicit.
|
||||
@@ -6947,6 +6909,7 @@ async function readClaudeRenamedSessionTitle(input: {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return readLastClaudeCustomTitle(content);
|
||||
}
|
||||
|
||||
function normalizeImportablePromptPreview(text: string): string | null {
|
||||
|
||||
@@ -150,15 +150,6 @@ function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolea
|
||||
|
||||
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
||||
const INTERRUPT_TIMEOUT_MS = 2_000;
|
||||
// `turn/interrupt` ends a turn but leaves the shell commands it started running.
|
||||
// Each terminate is bounded by the first constant. A turn start waits for the
|
||||
// interrupted turn's commands to end, at most the interrupt's own bound plus one
|
||||
// terminate's and a margin, so it gives up only when a request outlives its timeout.
|
||||
const INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS = 2_000;
|
||||
const INTERRUPTED_TURN_CLEANUP_WAIT_MS =
|
||||
INTERRUPT_TIMEOUT_MS + INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS + 500;
|
||||
// Turns whose commands are remembered; older turns are forgotten first.
|
||||
const INTERRUPTED_TURN_TRACKED_TURNS = 16;
|
||||
const CODEX_PROVIDER = "codex" as const;
|
||||
// These native provider identities belong to Codex, not to an older custom
|
||||
// Paseo provider. Never include them in a custom provider's legacy import scan.
|
||||
@@ -3485,14 +3476,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
|
||||
private pendingAssistantMessageBoundary = false;
|
||||
private terminalCommandByProcessId = new Map<string, string>();
|
||||
// The root thread's still-running command process ids, keyed by the native turn that
|
||||
// started them. Codex's `thread/backgroundTerminals/list` carries no turn, so
|
||||
// `item/started` is the only record of which turn owns a process.
|
||||
private terminalProcessIdsByTurnId = new Map<
|
||||
string,
|
||||
{ processIds: Set<string>; terminatedProcessIds: Set<string>; interruptAccepted: boolean }
|
||||
>();
|
||||
private interruptedTurnCleanup: Promise<void> | null = null;
|
||||
private pendingUnlabeledTerminalInteractions = new Map<
|
||||
string,
|
||||
Array<{ callId: string; stdin: string | null }>
|
||||
@@ -4448,9 +4431,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
|
||||
hasCodexConfig: turnStart.hasCodexConfig,
|
||||
});
|
||||
// The interrupted turn's commands end before this turn begins, so none of them
|
||||
// outlives it into the new turn.
|
||||
await this.waitForInterruptedTurnCleanup();
|
||||
if (pendingStart.cancelRequested) {
|
||||
throw new Error("Codex turn start was interrupted before reaching Codex");
|
||||
}
|
||||
@@ -5056,27 +5036,15 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
|
||||
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
|
||||
}
|
||||
const client = this.client;
|
||||
const threadId = this.currentThreadId;
|
||||
const interruptRequest = client.request(
|
||||
"turn/interrupt",
|
||||
{
|
||||
threadId,
|
||||
turnId,
|
||||
},
|
||||
INTERRUPT_TIMEOUT_MS,
|
||||
);
|
||||
// Once Codex accepts the interrupt, end the commands this turn started. Registered
|
||||
// before the await, so a turn start racing this interrupt waits for it. An already-idle
|
||||
// turn was not interrupted, and what it left running stays.
|
||||
this.trackInterruptedTurnCleanup(
|
||||
interruptRequest.then(
|
||||
() => this.endInterruptedTurnProcesses(client, threadId, turnId),
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
try {
|
||||
await interruptRequest;
|
||||
await this.client.request(
|
||||
"turn/interrupt",
|
||||
{
|
||||
threadId: this.currentThreadId,
|
||||
turnId,
|
||||
},
|
||||
INTERRUPT_TIMEOUT_MS,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isCodexAlreadyIdleInterrupt(error)) {
|
||||
throw error;
|
||||
@@ -5117,137 +5085,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return !this.client.hasPendingRequests();
|
||||
}
|
||||
|
||||
// Remember which turn started a command, so an interrupt ends that turn's commands and
|
||||
// no other turn's. Only the root thread's: a subagent's commands live on its own thread.
|
||||
// A command that starts after its turn's interrupt was accepted is ended as soon as it
|
||||
// is seen.
|
||||
private recordTurnTerminalProcess(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
|
||||
): void {
|
||||
const processId = parsed.item.processId;
|
||||
if (
|
||||
parsed.source !== "item" ||
|
||||
!parsed.turnId ||
|
||||
!parsed.threadId ||
|
||||
parsed.threadId !== this.currentThreadId ||
|
||||
typeof processId !== "string" ||
|
||||
!processId ||
|
||||
normalizeCodexThreadItemType(
|
||||
typeof parsed.item.type === "string" ? parsed.item.type : undefined,
|
||||
) !== "commandExecution"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const record = this.terminalProcessIdsByTurnId.get(parsed.turnId);
|
||||
if (parsed.kind === "item_completed") {
|
||||
// Only a completed command has exited. A failed one can still be running:
|
||||
// Codex reports an interrupted turn's live command as failed.
|
||||
if (parsed.item.status === "completed") record?.processIds.delete(processId);
|
||||
return;
|
||||
}
|
||||
const current = record ?? this.getTurnTerminalRecord(parsed.turnId);
|
||||
current.processIds.add(processId);
|
||||
if (current.interruptAccepted && this.client) {
|
||||
this.trackInterruptedTurnCleanup(
|
||||
this.endInterruptedTurnProcesses(this.client, parsed.threadId, parsed.turnId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// An accepted interrupt needs a record even before any of its turn's commands is seen:
|
||||
// an `item/started` can still be queued behind an earlier notification's conversion.
|
||||
private getTurnTerminalRecord(turnId: string) {
|
||||
const existing = this.terminalProcessIdsByTurnId.get(turnId);
|
||||
if (existing) return existing;
|
||||
const record = {
|
||||
processIds: new Set<string>(),
|
||||
terminatedProcessIds: new Set<string>(),
|
||||
interruptAccepted: false,
|
||||
};
|
||||
this.terminalProcessIdsByTurnId.set(turnId, record);
|
||||
for (const oldestTurnId of this.terminalProcessIdsByTurnId.keys()) {
|
||||
if (this.terminalProcessIdsByTurnId.size <= INTERRUPTED_TURN_TRACKED_TURNS) break;
|
||||
this.terminalProcessIdsByTurnId.delete(oldestTurnId);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
// A later sweep joins the pending one, so a turn start waits for both.
|
||||
private trackInterruptedTurnCleanup(cleanup: Promise<void>): void {
|
||||
const tracked = Promise.all([this.interruptedTurnCleanup, cleanup])
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
if (this.interruptedTurnCleanup === tracked) this.interruptedTurnCleanup = null;
|
||||
});
|
||||
this.interruptedTurnCleanup = tracked;
|
||||
}
|
||||
|
||||
// Terminate each process the interrupted turn started, by its own id, once each.
|
||||
// `thread/backgroundTerminals/clean` is never used: it ends every terminal on the thread,
|
||||
// an earlier turn's included. A process already gone answers `terminated: false`. Never
|
||||
// rejects; failures are logged.
|
||||
private async endInterruptedTurnProcesses(
|
||||
client: CodexAppServerClient,
|
||||
threadId: string | null,
|
||||
turnId: string,
|
||||
): Promise<void> {
|
||||
if (!threadId) return;
|
||||
const record = this.getTurnTerminalRecord(turnId);
|
||||
record.interruptAccepted = true;
|
||||
const processIds = [...record.processIds].filter(
|
||||
(processId) => !record.terminatedProcessIds.has(processId),
|
||||
);
|
||||
for (const processId of processIds) record.terminatedProcessIds.add(processId);
|
||||
const logContext = { agentId: this.agentId, provider: CODEX_PROVIDER, threadId, turnId };
|
||||
await Promise.all(
|
||||
processIds.map(async (processId) => {
|
||||
try {
|
||||
const response = await client.request(
|
||||
"thread/backgroundTerminals/terminate",
|
||||
{ threadId, processId },
|
||||
INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS,
|
||||
);
|
||||
this.logger.info(
|
||||
{ ...logContext, processId, terminated: toObjectRecord(response)?.terminated ?? null },
|
||||
"provider.codex.interrupt.terminal_terminated",
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ ...logContext, processId, err: error },
|
||||
"provider.codex.interrupt.terminal_terminate_failed",
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// A new turn starts only after every pending sweep of interrupted turns' commands has
|
||||
// ended, or after the bound, whichever is first.
|
||||
private async waitForInterruptedTurnCleanup(): Promise<void> {
|
||||
if (!this.interruptedTurnCleanup) return;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timedOut = await Promise.race([
|
||||
(async () => {
|
||||
while (this.interruptedTurnCleanup) await this.interruptedTurnCleanup;
|
||||
return false;
|
||||
})(),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(true), INTERRUPTED_TURN_CLEANUP_WAIT_MS);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
if (timedOut) {
|
||||
this.logger.warn(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: CODEX_PROVIDER,
|
||||
waitMs: INTERRUPTED_TURN_CLEANUP_WAIT_MS,
|
||||
},
|
||||
"provider.codex.interrupt.terminal_cleanup_timed_out",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.clearPendingPermissions();
|
||||
@@ -6372,11 +6209,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
this.completePendingRootCompactions();
|
||||
// What a turn that was not interrupted leaves running, it left on purpose; no later
|
||||
// interrupt may end it.
|
||||
if (parsed.status !== "interrupted" && this.currentTurnId) {
|
||||
this.terminalProcessIdsByTurnId.delete(this.currentTurnId);
|
||||
}
|
||||
if (parsed.status === "failed") {
|
||||
this.emitEvent({
|
||||
type: "turn_failed",
|
||||
@@ -6781,7 +6613,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private async handleItemCompletedNotification(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
|
||||
): Promise<void> {
|
||||
this.recordTurnTerminalProcess(parsed);
|
||||
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
|
||||
// and canonical `item/*`. Render ordinary items only from the canonical
|
||||
// channel, but accept a legacy-only child announcement so it can establish
|
||||
@@ -6944,7 +6775,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private async handleItemStartedNotification(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
|
||||
): Promise<void> {
|
||||
this.recordTurnTerminalProcess(parsed);
|
||||
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { AgentSession } from "../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
type FakeCodexAppServer,
|
||||
waitForNextEvent,
|
||||
waitForTimelineToolCall,
|
||||
} from "./codex/test-utils/fake-app-server.js";
|
||||
|
||||
const THREAD = "thread-1";
|
||||
const TERMINATE = "thread/backgroundTerminals/terminate";
|
||||
|
||||
type Handler = (params: unknown) => unknown;
|
||||
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function paramsOf(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
let sessions: AgentSession[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const session of sessions) await session.close().catch(() => undefined);
|
||||
sessions = [];
|
||||
});
|
||||
|
||||
/**
|
||||
* A Codex app-server whose interrupt ends the turn the way Codex does: an `interrupted`
|
||||
* turn completion, then the response. `terminate` answers per process.
|
||||
*/
|
||||
function createHarness(
|
||||
options: {
|
||||
terminate?: (processId: string) => unknown;
|
||||
interruptResponse?: (completeTurn: () => void) => unknown;
|
||||
} = {},
|
||||
) {
|
||||
const handlers: Record<string, Handler> = {
|
||||
"turn/interrupt": () => {
|
||||
const completeTurn = () =>
|
||||
appServer.completeTurn({ threadId: THREAD, status: "interrupted" });
|
||||
if (options.interruptResponse) return options.interruptResponse(completeTurn);
|
||||
completeTurn();
|
||||
return {};
|
||||
},
|
||||
[TERMINATE]: (params) =>
|
||||
options.terminate?.(String(paramsOf(params).processId)) ?? { terminated: true },
|
||||
};
|
||||
const appServer: FakeCodexAppServer = createFakeCodexAppServer(handlers);
|
||||
const session = new CodexAppServerAgentSession(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: "/workspace/project",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
sessions.push(session);
|
||||
|
||||
function requests(method: string): Record<string, unknown>[] {
|
||||
return appServer
|
||||
.requests()
|
||||
.filter((message) => message.method === method && "id" in message)
|
||||
.map((message) => paramsOf(message.params));
|
||||
}
|
||||
|
||||
function terminated(): string[] {
|
||||
return requests(TERMINATE)
|
||||
.map((params) => String(params.processId))
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Starts a turn and lets Codex identify it as `nativeTurnId`.
|
||||
async function startTurn(prompt: string, nativeTurnId: string): Promise<void> {
|
||||
const before = requests("turn/start").length;
|
||||
const started = session.startTurn(prompt);
|
||||
await vi.waitFor(() => expect(requests("turn/start")).toHaveLength(before + 1), {
|
||||
timeout: 5_000,
|
||||
});
|
||||
const turnStarted = waitForNextEvent(session, "turn_started");
|
||||
appServer.startsTurn({ threadId: THREAD, turnId: nativeTurnId });
|
||||
await started;
|
||||
await turnStarted;
|
||||
}
|
||||
|
||||
async function runsCommand(params: { turnId: string; processId: string }): Promise<void> {
|
||||
const itemId = `exec-${params.processId}`;
|
||||
const shown = waitForTimelineToolCall(session, itemId);
|
||||
appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: params.turnId,
|
||||
itemId,
|
||||
processId: params.processId,
|
||||
command: `sleep ${params.processId}`,
|
||||
});
|
||||
await shown;
|
||||
}
|
||||
|
||||
async function completesTurn(): Promise<void> {
|
||||
const completed = waitForNextEvent(session, "turn_completed");
|
||||
appServer.completeTurn({ threadId: THREAD });
|
||||
await completed;
|
||||
}
|
||||
|
||||
// Interrupts the running turn and waits for the session to report it canceled, as a
|
||||
// caller does before it starts the next turn: Codex notifications are handled in order
|
||||
// but asynchronously, so the interrupt's answer can arrive before the turn has ended.
|
||||
async function interrupts(): Promise<void> {
|
||||
const canceled = waitForNextEvent(session, "turn_canceled");
|
||||
await session.interrupt();
|
||||
await canceled;
|
||||
}
|
||||
|
||||
return {
|
||||
appServer,
|
||||
session,
|
||||
requests,
|
||||
terminated,
|
||||
startTurn,
|
||||
runsCommand,
|
||||
completesTurn,
|
||||
interrupts,
|
||||
};
|
||||
}
|
||||
|
||||
// Turn A leaves a server running on purpose and completes. Turn B starts two
|
||||
// commands, and a subagent on its own thread starts a third, then B is interrupted.
|
||||
async function interruptTurnB(harness: ReturnType<typeof createHarness>): Promise<void> {
|
||||
await harness.startTurn("start a server", "native-A");
|
||||
await harness.runsCommand({ turnId: "native-A", processId: "101" });
|
||||
await harness.completesTurn();
|
||||
|
||||
await harness.startTurn("run two commands", "native-B");
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
||||
// A subagent thread's command shows no row here; the next command's row shows it
|
||||
// was read, because notifications are read in order.
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: "child-thread",
|
||||
turnId: "native-B",
|
||||
itemId: "exec-209",
|
||||
processId: "209",
|
||||
command: "sleep 209",
|
||||
});
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "202" });
|
||||
await harness.interrupts();
|
||||
}
|
||||
|
||||
describe("an interrupted Codex turn's commands end before the next turn", () => {
|
||||
test("terminates exactly the processes the interrupted turn started, by id, and never cleans", async () => {
|
||||
const harness = createHarness();
|
||||
await interruptTurnB(harness);
|
||||
await harness.startTurn("correction", "native-C");
|
||||
|
||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
||||
expect(harness.requests(TERMINATE).every((params) => params.threadId === THREAD)).toBe(true);
|
||||
// `clean` would end every terminal on the thread, an earlier turn's included.
|
||||
expect(
|
||||
harness.appServer
|
||||
.requests()
|
||||
.filter((message) => String(message.method).startsWith("thread/backgroundTerminals/"))
|
||||
.map((message) => message.method),
|
||||
).toEqual([TERMINATE, TERMINATE]);
|
||||
harness.appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("leaves the next turn's commands running when Codex starts that turn before acknowledging the interrupt", async () => {
|
||||
const acknowledge = deferred();
|
||||
const harness = createHarness({
|
||||
// Codex ends B, starts a turn of its own (a goal continuation), and only then
|
||||
// answers the interrupt.
|
||||
interruptResponse: (completeTurn) => {
|
||||
completeTurn();
|
||||
return acknowledge.promise.then(() => ({}));
|
||||
},
|
||||
});
|
||||
await harness.startTurn("run two commands", "native-B");
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "202" });
|
||||
const interrupting = harness.session.interrupt();
|
||||
await vi.waitFor(() => expect(harness.requests("turn/interrupt")).toHaveLength(1));
|
||||
const autonomousTurn = waitForNextEvent(harness.session, "turn_started");
|
||||
harness.appServer.startsTurn({ threadId: THREAD, turnId: "native-C" });
|
||||
await autonomousTurn;
|
||||
await harness.runsCommand({ turnId: "native-C", processId: "301" });
|
||||
|
||||
acknowledge.resolve();
|
||||
await interrupting;
|
||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
||||
});
|
||||
|
||||
test("sends the next turn/start only after the terminates are answered", async () => {
|
||||
const answer = deferred();
|
||||
const harness = createHarness({
|
||||
terminate: () => answer.promise.then(() => ({ terminated: true })),
|
||||
});
|
||||
await interruptTurnB(harness);
|
||||
const next = harness.startTurn("correction", "native-C");
|
||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(harness.requests("turn/start")).toHaveLength(2);
|
||||
|
||||
answer.resolve();
|
||||
await next;
|
||||
expect(harness.requests("turn/start")).toHaveLength(3);
|
||||
});
|
||||
|
||||
test.each(["initial", "late"] as const)(
|
||||
"tracks both cleanup sweeps when the %s answer arrives first",
|
||||
async (answeredFirst) => {
|
||||
const first = deferred();
|
||||
const second = deferred();
|
||||
const harness = createHarness({
|
||||
terminate: (processId) =>
|
||||
(processId === "201" ? first : second).promise.then(() => ({ terminated: true })),
|
||||
});
|
||||
await harness.startTurn("run commands", "native-B");
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
||||
await harness.interrupts();
|
||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201"]));
|
||||
const next = harness.startTurn("correction", "native-C");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(harness.requests("turn/start")).toHaveLength(1);
|
||||
// The next turn is already waiting when another process is announced.
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: "native-B",
|
||||
itemId: "late-202",
|
||||
processId: "202",
|
||||
command: "sleep 202",
|
||||
});
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: "native-B",
|
||||
itemId: "duplicate-202",
|
||||
processId: "202",
|
||||
command: "sleep 202",
|
||||
});
|
||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
||||
(answeredFirst === "initial" ? first : second).resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(harness.requests("turn/start")).toHaveLength(1);
|
||||
(answeredFirst === "initial" ? second : first).resolve();
|
||||
await next;
|
||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
||||
// Accepted state survives the completed sweep and a newer foreground turn.
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: "native-B",
|
||||
itemId: "late-203",
|
||||
processId: "203",
|
||||
command: "sleep 203",
|
||||
});
|
||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202", "203"]));
|
||||
harness.appServer.assertNoErrors();
|
||||
},
|
||||
);
|
||||
|
||||
test("keeps the first sweep pending when a late sweep answers before the next start", async () => {
|
||||
const first = deferred();
|
||||
const harness = createHarness({
|
||||
terminate: (processId) =>
|
||||
processId === "201"
|
||||
? first.promise.then(() => ({ terminated: true }))
|
||||
: { terminated: true },
|
||||
});
|
||||
await harness.startTurn("run commands", "native-B");
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
||||
await harness.interrupts();
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: "native-B",
|
||||
itemId: "late-202",
|
||||
processId: "202",
|
||||
command: "sleep 202",
|
||||
});
|
||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
||||
const next = harness.startTurn("correction", "native-C");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(harness.requests("turn/start")).toHaveLength(1);
|
||||
first.resolve();
|
||||
await next;
|
||||
harness.appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("forgets completed commands but retains failed commands at interrupt", async () => {
|
||||
const harness = createHarness();
|
||||
await harness.startTurn("run commands", "native-B");
|
||||
for (const processId of ["201", "202"]) {
|
||||
await harness.runsCommand({ turnId: "native-B", processId });
|
||||
harness.appServer.child.stdout.write(
|
||||
JSON.stringify({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: THREAD,
|
||||
turnId: "native-B",
|
||||
item: {
|
||||
id: `exec-${processId}`,
|
||||
type: "commandExecution",
|
||||
processId,
|
||||
command: `sleep ${processId}`,
|
||||
status: processId === "201" ? "completed" : "failed",
|
||||
exitCode: processId === "201" ? 0 : null,
|
||||
},
|
||||
},
|
||||
}) + "\n",
|
||||
);
|
||||
}
|
||||
// A following command's row is a barrier for both completion handlers.
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "203" });
|
||||
await harness.interrupts();
|
||||
await harness.startTurn("correction", "native-C");
|
||||
expect(harness.terminated()).toEqual(["202", "203"]);
|
||||
harness.appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("retains accepted interrupts for only the latest sixteen turns", async () => {
|
||||
const harness = createHarness();
|
||||
for (let index = 0; index < 17; index++) {
|
||||
await harness.startTurn("run a command", `native-${index}`);
|
||||
await harness.interrupts();
|
||||
}
|
||||
await harness.startTurn("correction", "native-current");
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: "native-0",
|
||||
itemId: "evicted-201",
|
||||
processId: "201",
|
||||
command: "sleep 201",
|
||||
});
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: "native-16",
|
||||
itemId: "retained-202",
|
||||
processId: "202",
|
||||
command: "sleep 202",
|
||||
});
|
||||
await harness.runsCommand({ turnId: "native-current", processId: "301" });
|
||||
expect(harness.terminated()).toEqual(["202"]);
|
||||
harness.appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("a failing or unanswered terminate does not block or fail the next turn", async () => {
|
||||
const harness = createHarness({
|
||||
// 201 is refused while the next turn waits; 202 is never answered.
|
||||
terminate: (processId) =>
|
||||
processId === "201"
|
||||
? new Promise((resolve) => setTimeout(resolve, 300)).then(() => ({
|
||||
__jsonRpcError: { code: -32600, message: "invalid background terminal process id" },
|
||||
}))
|
||||
: new Promise(() => {}),
|
||||
});
|
||||
await interruptTurnB(harness);
|
||||
const startedAt = Date.now();
|
||||
await harness.startTurn("correction", "native-C");
|
||||
|
||||
expect(Date.now() - startedAt).toBeLessThan(5_000);
|
||||
expect(harness.requests("turn/start")).toHaveLength(3);
|
||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -4,102 +4,6 @@ import { createTestLogger } from "../../../../../test-utils/test-logger.js";
|
||||
import type { V2Api } from "./api.js";
|
||||
import { OpenCodeV2AgentClient } from "./agent.js";
|
||||
import { V2Harness } from "../test-utils/v2-harness.js";
|
||||
import type { AgentPermissionResponse } from "../../../agent-sdk-types.js";
|
||||
|
||||
describe("OpenCode v2 denial reasons", () => {
|
||||
test.each<{
|
||||
response: AgentPermissionResponse;
|
||||
decision: "reject" | "once" | "always";
|
||||
message?: string;
|
||||
}>([
|
||||
{
|
||||
response: { behavior: "deny", message: "Use the existing file instead." },
|
||||
decision: "reject",
|
||||
message: "Use the existing file instead.",
|
||||
},
|
||||
{ response: { behavior: "deny" }, decision: "reject" },
|
||||
{ response: { behavior: "allow" }, decision: "once" },
|
||||
{ response: { behavior: "allow", selectedActionId: "always" }, decision: "always" },
|
||||
])("preserves a permission response: $response", async ({ response, decision, message }) => {
|
||||
const harness = new V2Harness();
|
||||
let pending = true;
|
||||
harness.api.permission.list = async () =>
|
||||
pending
|
||||
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
|
||||
: [];
|
||||
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
|
||||
harness.api.permission.reply = async (input) => {
|
||||
replies.push(input);
|
||||
pending = false;
|
||||
};
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
try {
|
||||
await session.respondToPermission("approval", response);
|
||||
expect(replies).toEqual([
|
||||
{
|
||||
sessionID: "session",
|
||||
requestID: "approval",
|
||||
decision,
|
||||
...(message === undefined ? {} : { message }),
|
||||
},
|
||||
]);
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([true, false])(
|
||||
"explains cards superseded by a steer when clearing is %s",
|
||||
async (clearPendingPermissions) => {
|
||||
const harness = new V2Harness();
|
||||
harness.autoComplete = false;
|
||||
let pending = true;
|
||||
harness.api.permission.list = async () =>
|
||||
pending
|
||||
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
|
||||
: [];
|
||||
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
|
||||
harness.api.permission.reply = async (input) => {
|
||||
replies.push(input);
|
||||
pending = false;
|
||||
};
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
try {
|
||||
const { turnId } = await session.startTurn("Run pwd");
|
||||
await expect.poll(() => harness.prompts).toEqual(["Run pwd"]);
|
||||
await expect(
|
||||
session.steerActiveTurn!("go ahead", { expectedTurnId: turnId, clearPendingPermissions }),
|
||||
).resolves.toEqual({ status: "accepted" });
|
||||
expect(harness.prompts).toEqual(["Run pwd", "go ahead"]);
|
||||
expect(replies).toEqual(
|
||||
clearPendingPermissions
|
||||
? [
|
||||
{
|
||||
sessionID: "session",
|
||||
requestID: "approval",
|
||||
decision: "reject",
|
||||
message:
|
||||
"The user answered with a message instead of approving. Their message follows.",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
expect(session.getPendingPermissions()).toHaveLength(clearPendingPermissions ? 0 : 1);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("OpenCode v2 questions", () => {
|
||||
test("maps selected labels to native values and parses numeric and boolean answers", async () => {
|
||||
|
||||
@@ -68,9 +68,6 @@ export class SessionPermissions {
|
||||
sessionID: this.permissionOwners.get(requestId) ?? this.id,
|
||||
requestID: requestId,
|
||||
decision: permissionReply(response),
|
||||
...(response.behavior === "deny" && response.message !== undefined
|
||||
? { message: response.message }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
this.resolvePending(requestId, response);
|
||||
|
||||
@@ -100,11 +100,7 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
),
|
||||
clearPermissions: async () => {
|
||||
for (const request of this.permissions.list())
|
||||
await this.permissions.respondToPermission(request.id, {
|
||||
behavior: "deny",
|
||||
message:
|
||||
"The user answered with a message instead of approving. Their message follows.",
|
||||
});
|
||||
await this.permissions.respondToPermission(request.id, { behavior: "deny" });
|
||||
},
|
||||
});
|
||||
this.usage = new SessionUsage({
|
||||
|
||||
@@ -5,13 +5,11 @@ import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { setImmediate as waitForImmediate } from "node:timers/promises";
|
||||
@@ -25,7 +23,6 @@ import {
|
||||
PiRpcAgentSession,
|
||||
transformPiModels,
|
||||
} from "./agent.js";
|
||||
import { PiCliRuntime } from "./cli-runtime.js";
|
||||
import { FakePi } from "./test-utils/fake-pi.js";
|
||||
import { createPiExtensionHost } from "./extensions/index.js";
|
||||
import { PiExtensionHost } from "./extensions/host.js";
|
||||
@@ -2722,7 +2719,6 @@ describe("PiRpcAgentClient", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-import-config-"));
|
||||
const cwd = path.join(root, "workspace");
|
||||
const sessionsDir = path.join(root, "sessions");
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
const sessionFile = path.join(sessionsDir, "20260103_session.jsonl");
|
||||
writeFileSync(
|
||||
@@ -3536,320 +3532,6 @@ describe("PiRpcAgentClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PiRpcAgentClient history resume", () => {
|
||||
const BUILTIN_MCP_COMMAND = {
|
||||
name: "mcp",
|
||||
source: "extension",
|
||||
sourceInfo: { path: "builtin:mcp" },
|
||||
} as const;
|
||||
const PASEO_MCP_SERVERS = {
|
||||
paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents" },
|
||||
} as const;
|
||||
|
||||
interface ArchivedPiSession {
|
||||
handle: {
|
||||
provider: string;
|
||||
sessionId: string;
|
||||
nativeHandle: string;
|
||||
metadata: { cwd: string; model?: string };
|
||||
};
|
||||
sessionFile: string;
|
||||
header: Record<string, unknown>;
|
||||
body: string;
|
||||
originalBytes: Buffer;
|
||||
}
|
||||
|
||||
function createDeletedFolder(): string {
|
||||
const folder = mkdtempSync(path.join(tmpdir(), "paseo-pi-deleted-cwd-"));
|
||||
rmSync(folder, { recursive: true, force: true });
|
||||
return folder;
|
||||
}
|
||||
|
||||
function createFileWhereFolderWas(): string {
|
||||
const folder = createDeletedFolder();
|
||||
writeFileSync(folder, "not a directory");
|
||||
onTestFinished(() => rmSync(folder, { force: true }));
|
||||
return folder;
|
||||
}
|
||||
|
||||
function createExistingFolder(): string {
|
||||
const folder = mkdtempSync(path.join(tmpdir(), "paseo-pi-existing-cwd-"));
|
||||
onTestFinished(() => rmSync(folder, { recursive: true, force: true }));
|
||||
return folder;
|
||||
}
|
||||
|
||||
function writeArchivedSession(cwd: string, metadata: { model?: string } = {}): ArchivedPiSession {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "paseo-pi-archived-session-"));
|
||||
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const sessionFile = path.join(dir, "2026-01-01_pi-session-1.jsonl");
|
||||
const header = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "pi-session-1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd,
|
||||
};
|
||||
const body = [
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: "entry-1",
|
||||
parentId: null,
|
||||
message: { role: "user", content: "Où est le café ☕ ?" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: "entry-2",
|
||||
parentId: "entry-1",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "Dans la cuisine." }] },
|
||||
}),
|
||||
]
|
||||
.map((line) => `${line}\n`)
|
||||
.join("");
|
||||
writeFileSync(sessionFile, `${JSON.stringify(header)}\n${body}`);
|
||||
return {
|
||||
handle: {
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: sessionFile,
|
||||
metadata: { cwd, ...metadata },
|
||||
},
|
||||
sessionFile,
|
||||
header,
|
||||
body,
|
||||
originalBytes: readFileSync(sessionFile),
|
||||
};
|
||||
}
|
||||
|
||||
function readSessionCopy(sessionFile: string): { header: unknown; body: string } {
|
||||
const content = readUtf8File(sessionFile);
|
||||
const headerEnd = content.indexOf("\n");
|
||||
return {
|
||||
header: JSON.parse(content.slice(0, headerEnd)),
|
||||
body: content.slice(headerEnd + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function resumeArchived(
|
||||
client: PiRpcAgentClient,
|
||||
archived: ArchivedPiSession,
|
||||
purpose: "history" | "interactive",
|
||||
overrides: Partial<AgentSessionConfig> = {},
|
||||
): Promise<AgentSession> {
|
||||
return client.resumeSession(archived.handle, overrides, undefined, { purpose });
|
||||
}
|
||||
|
||||
function writeFakePiProcess(): {
|
||||
command: [string, ...string[]];
|
||||
readLaunches: () => Array<{ cwd: string; session: string | null }>;
|
||||
} {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "paseo-pi-process-"));
|
||||
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const scriptPath = path.join(dir, "fake-pi.cjs");
|
||||
const launchesPath = path.join(dir, "launches.jsonl");
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
`
|
||||
const fs = require("node:fs");
|
||||
const readline = require("node:readline");
|
||||
const [launchesPath, ...piArgs] = process.argv.slice(2);
|
||||
const sessionFlag = piArgs.indexOf("--session");
|
||||
const sessionFile = sessionFlag === -1 ? null : piArgs[sessionFlag + 1];
|
||||
fs.appendFileSync(launchesPath, JSON.stringify({ cwd: process.cwd(), session: sessionFile }) + "\\n");
|
||||
if (sessionFile) {
|
||||
const header = JSON.parse(fs.readFileSync(sessionFile, "utf8").split("\\n", 1)[0]);
|
||||
if (header.cwd && !fs.existsSync(header.cwd)) {
|
||||
fs.writeSync(
|
||||
2,
|
||||
"Stored session working directory does not exist: " + header.cwd + "\\nSession file: " + sessionFile + "\\nCurrent working directory: " + process.cwd() + "\\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const models = [
|
||||
{ provider: "openrouter", id: "a" },
|
||||
{ provider: "openrouter", id: "b" },
|
||||
];
|
||||
const state = {
|
||||
sessionId: "pi-session-1",
|
||||
sessionFile,
|
||||
model: models[0],
|
||||
thinkingLevel: "medium",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
messageCount: 0,
|
||||
pendingMessageCount: 0,
|
||||
};
|
||||
const commands = [${JSON.stringify(BUILTIN_MCP_COMMAND)}];
|
||||
function handle(request) {
|
||||
switch (request.type) {
|
||||
case "get_state":
|
||||
return state;
|
||||
case "get_commands":
|
||||
return { commands };
|
||||
case "get_available_models":
|
||||
return { models };
|
||||
case "set_model":
|
||||
fs.appendFileSync(
|
||||
sessionFile,
|
||||
JSON.stringify({ type: "model_change", id: "model-1", provider: request.provider, modelId: request.modelId }) + "\\n",
|
||||
);
|
||||
state.model = models.find((model) => model.id === request.modelId);
|
||||
return state.model;
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
readline.createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
const request = JSON.parse(line);
|
||||
process.stdout.write(
|
||||
JSON.stringify({ id: request.id, type: "response", command: request.type, success: true, data: handle(request) }) + "\\n",
|
||||
);
|
||||
});
|
||||
`,
|
||||
);
|
||||
return {
|
||||
command: [process.execPath, scriptPath, launchesPath],
|
||||
readLaunches: () =>
|
||||
readUtf8File(launchesPath)
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as { cwd: string; session: string | null }),
|
||||
};
|
||||
}
|
||||
|
||||
test.each([
|
||||
["is gone", createDeletedFolder],
|
||||
["is now a file", createFileWhereFolderWas],
|
||||
])(
|
||||
"a history resume of an agent whose folder %s starts Pi from the home directory on a copy of its session",
|
||||
async (_folderState, createFolder) => {
|
||||
const archived = writeArchivedSession(createFolder());
|
||||
const pi = new FakePi();
|
||||
|
||||
const session = await resumeArchived(createClient(pi), archived, "history");
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
expect(pi.recordedLaunches).toHaveLength(1);
|
||||
const launch = pi.recordedLaunches[0]!;
|
||||
expect(launch.cwd).toBe(homedir());
|
||||
expect(launch.session).not.toBe(archived.sessionFile);
|
||||
expect(readSessionCopy(launch.session!)).toEqual({
|
||||
header: { ...archived.header, cwd: homedir() },
|
||||
body: archived.body,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("a history resume of an agent whose folder is gone removes its session copy on close and leaves the original unchanged", async () => {
|
||||
const archived = writeArchivedSession(createDeletedFolder());
|
||||
const pi = new FakePi();
|
||||
|
||||
const session = await resumeArchived(createClient(pi), archived, "history");
|
||||
const sessionCopy = pi.recordedLaunches[0]!.session!;
|
||||
expect(existsSync(sessionCopy)).toBe(true);
|
||||
await session.close();
|
||||
|
||||
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
|
||||
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
|
||||
});
|
||||
|
||||
test("a history resume of an agent whose folder is gone persists the original session file while Pi reports the copy", async () => {
|
||||
const storedCwd = createDeletedFolder();
|
||||
const archived = writeArchivedSession(storedCwd);
|
||||
const pi = new FakePi();
|
||||
|
||||
const session = await resumeArchived(createClient(pi), archived, "history");
|
||||
onTestFinished(() => session.close());
|
||||
await session.getRuntimeInfo();
|
||||
|
||||
expect(pi.latestSession().state.sessionFile).toBe(pi.recordedLaunches[0]!.session);
|
||||
expect(session.describePersistence()).toMatchObject({
|
||||
nativeHandle: archived.sessionFile,
|
||||
metadata: { cwd: storedCwd },
|
||||
});
|
||||
});
|
||||
|
||||
test("a history resume of an agent whose folder is gone removes its session copy when Pi fails to start", async () => {
|
||||
const archived = writeArchivedSession(createDeletedFolder());
|
||||
const pi = new FakePi();
|
||||
pi.queueSessionSetup((session) => {
|
||||
session.getStateError = new Error("Pi RPC process exited with code 1 and signal null");
|
||||
});
|
||||
|
||||
await expect(resumeArchived(createClient(pi), archived, "history")).rejects.toThrow(
|
||||
"Pi RPC process exited with code 1",
|
||||
);
|
||||
|
||||
expect(pi.recordedLaunches).toHaveLength(1);
|
||||
expect(pi.recordedLaunches[0]!.session).not.toBe(archived.sessionFile);
|
||||
expect(existsSync(path.dirname(pi.recordedLaunches[0]!.session!))).toBe(false);
|
||||
});
|
||||
|
||||
test("a history resume of an agent whose folder is gone checks MCP support from the home directory", async () => {
|
||||
const archived = writeArchivedSession(createDeletedFolder());
|
||||
const pi = new FakePi();
|
||||
pi.queueCommands([BUILTIN_MCP_COMMAND]);
|
||||
|
||||
const session = await resumeArchived(createClient(pi), archived, "history", {
|
||||
mcpServers: PASEO_MCP_SERVERS,
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
expect(pi.recordedLaunches.map((launch) => launch.cwd)).toEqual([homedir(), homedir()]);
|
||||
expect(session.capabilities.supportsMcpServers).toBe(true);
|
||||
});
|
||||
|
||||
test("an interactive resume of an agent whose folder is gone still fails in its stored folder", async () => {
|
||||
const storedCwd = createDeletedFolder();
|
||||
const archived = writeArchivedSession(storedCwd);
|
||||
const pi = new FakePi();
|
||||
|
||||
await expect(resumeArchived(createClient(pi), archived, "interactive")).rejects.toThrow(
|
||||
`Stored session working directory does not exist: ${storedCwd}`,
|
||||
);
|
||||
|
||||
expect(pi.recordedLaunches).toMatchObject([{ cwd: storedCwd, session: archived.sessionFile }]);
|
||||
});
|
||||
|
||||
test("a history resume of an agent whose folder exists keeps the stored folder and session file", async () => {
|
||||
const storedCwd = createExistingFolder();
|
||||
const archived = writeArchivedSession(storedCwd);
|
||||
const pi = new FakePi();
|
||||
|
||||
const session = await resumeArchived(createClient(pi), archived, "history");
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
expect(pi.recordedLaunches).toMatchObject([{ cwd: storedCwd, session: archived.sessionFile }]);
|
||||
});
|
||||
|
||||
test("a history resume of an agent whose folder is gone opens its session in a real Pi process started from the home directory", async () => {
|
||||
const archived = writeArchivedSession(createDeletedFolder(), { model: "openrouter/b" });
|
||||
const fakePiProcess = writeFakePiProcess();
|
||||
const logger = pino({ level: "silent" });
|
||||
const client = new PiRpcAgentClient({
|
||||
logger,
|
||||
runtime: new PiCliRuntime({ logger, command: fakePiProcess.command }),
|
||||
});
|
||||
|
||||
const session = await resumeArchived(client, archived, "history", {
|
||||
mcpServers: PASEO_MCP_SERVERS,
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
const launches = fakePiProcess.readLaunches();
|
||||
const home = realpathSync.native(homedir());
|
||||
expect(launches.map((launch) => realpathSync.native(launch.cwd))).toEqual([home, home]);
|
||||
expect(session.capabilities.supportsMcpServers).toBe(true);
|
||||
const sessionCopy = launches[1]!.session!;
|
||||
expect(readUtf8File(sessionCopy)).toContain('"type":"model_change"');
|
||||
expect(session.describePersistence()?.nativeHandle).toBe(archived.sessionFile);
|
||||
|
||||
await session.close();
|
||||
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
|
||||
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformPiModels", () => {
|
||||
test("normalizes labels that include the upstream provider prefix", () => {
|
||||
expect(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mapCustomMessageToToolCall } from "../custom-message.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { basename, join, resolve as resolvePath } from "node:path";
|
||||
import { join, resolve as resolvePath } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { z } from "zod";
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPromptInput,
|
||||
type AgentProvider,
|
||||
type AgentResumeSessionOptions,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentRuntimeInfo,
|
||||
@@ -76,13 +75,8 @@ import {
|
||||
piExtensionRuntimeBridge,
|
||||
type PiExtensionEventOutput,
|
||||
} from "./extensions/index.js";
|
||||
import { resolveResumeProcessCwd } from "../resume-process-cwd.js";
|
||||
import { revertPiConversation } from "./rewind.js";
|
||||
import {
|
||||
copyPiSessionFileWithCwd,
|
||||
listPiImportableSessions,
|
||||
readPiImportSessionConfig,
|
||||
} from "./session-descriptor.js";
|
||||
import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js";
|
||||
import type { PiRuntime, PiRuntimeSession, PiStartSessionInput } from "./runtime.js";
|
||||
import type {
|
||||
PiAgentSessionEvent,
|
||||
@@ -251,7 +245,6 @@ interface PiRpcAgentSessionOptions {
|
||||
initialState: PiSessionState;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
currentModeId?: string | null;
|
||||
persistedSessionFile?: string;
|
||||
cleanup?: () => void;
|
||||
extensionTimeoutMs?: number;
|
||||
logger: Logger;
|
||||
@@ -499,14 +492,13 @@ function buildResumeConfig(
|
||||
|
||||
function buildResumeStartInput(input: {
|
||||
resumeConfig: PiResumeConfig;
|
||||
processCwd: string;
|
||||
sessionFile: string;
|
||||
launchContext: AgentLaunchContext | undefined;
|
||||
mcpConfigFile: PiTempFile | null;
|
||||
paseoExtension: PiTempFile | null;
|
||||
}): PiStartSessionInput {
|
||||
return {
|
||||
cwd: input.processCwd,
|
||||
cwd: input.resumeConfig.cwd,
|
||||
env: input.launchContext?.env,
|
||||
session: input.sessionFile,
|
||||
thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined,
|
||||
@@ -626,19 +618,6 @@ function createPiMcpConfigFile(
|
||||
};
|
||||
}
|
||||
|
||||
async function createPiSessionCopy(sessionFile: string, cwd: string): Promise<PiTempFile> {
|
||||
const dir = mkdtempSync(join(tmpdir(), "paseo-pi-session-"));
|
||||
const cleanup = () => rmSync(dir, { recursive: true, force: true });
|
||||
const filePath = join(dir, basename(sessionFile));
|
||||
try {
|
||||
await copyPiSessionFileWithCwd({ sourceFile: sessionFile, targetFile: filePath, cwd });
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
throw error;
|
||||
}
|
||||
return { path: filePath, cleanup };
|
||||
}
|
||||
|
||||
function createPiPaseoExtensionFile({
|
||||
systemPrompt,
|
||||
mcpServers = {},
|
||||
@@ -1253,7 +1232,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.capabilities = options.capabilities;
|
||||
this.provider = PI_PROVIDER;
|
||||
this.currentModeId = options.currentModeId ?? null;
|
||||
this.persistedSessionFile = options.persistedSessionFile;
|
||||
this.cleanup = options.cleanup;
|
||||
this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS;
|
||||
this.logger = options.logger;
|
||||
@@ -1284,7 +1262,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
|
||||
private readonly runtimeSession: PiRuntimeSession;
|
||||
private readonly config: AgentSessionConfig;
|
||||
private readonly persistedSessionFile?: string;
|
||||
private readonly cleanup?: () => void;
|
||||
private readonly extensionTimeoutMs: number;
|
||||
|
||||
@@ -1519,7 +1496,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.state.sessionId,
|
||||
nativeHandle: this.persistedSessionFile ?? this.state.sessionFile,
|
||||
nativeHandle: this.state.sessionFile,
|
||||
metadata: {
|
||||
cwd: this.config.cwd,
|
||||
...(this.config.model ? { model: this.config.model } : {}),
|
||||
@@ -2668,7 +2645,6 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentResumeSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const sessionFile = handle.nativeHandle;
|
||||
if (!sessionFile) {
|
||||
@@ -2677,7 +2653,6 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
|
||||
const persistenceMetadata = parsePersistenceMetadata(handle.metadata);
|
||||
const resumeConfig = buildResumeConfig(persistenceMetadata, overrides, this.provider);
|
||||
const processCwd = await resolveResumeProcessCwd(resumeConfig.cwd, options?.purpose);
|
||||
|
||||
const providerOptions = PiProviderOptionsSchema.parse(
|
||||
resumeConfig.config.providerOptions ?? {},
|
||||
@@ -2688,7 +2663,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
...launchContext?.env,
|
||||
};
|
||||
const mcp = await this.prepareMcpInjection(
|
||||
processCwd,
|
||||
resumeConfig.cwd,
|
||||
resumeConfig.config.mcpServers,
|
||||
mcpEnv,
|
||||
runtime,
|
||||
@@ -2701,29 +2676,20 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
),
|
||||
mcpServers: mcp?.kind === "builtin" ? mcp.servers : undefined,
|
||||
});
|
||||
const tempFiles = mcpConfigFile ? [mcpConfigFile, paseoExtension] : [paseoExtension];
|
||||
const cleanupTempFiles = () => {
|
||||
for (const tempFile of tempFiles) tempFile.cleanup();
|
||||
};
|
||||
let sessionCopy: PiTempFile | null = null;
|
||||
let runtimeSession: PiRuntimeSession;
|
||||
try {
|
||||
if (processCwd !== resumeConfig.cwd) {
|
||||
sessionCopy = await createPiSessionCopy(sessionFile, processCwd);
|
||||
tempFiles.push(sessionCopy);
|
||||
}
|
||||
runtimeSession = await runtime.startSession(
|
||||
buildResumeStartInput({
|
||||
resumeConfig,
|
||||
processCwd,
|
||||
sessionFile: sessionCopy?.path ?? sessionFile,
|
||||
sessionFile,
|
||||
launchContext,
|
||||
mcpConfigFile,
|
||||
paseoExtension,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
cleanupTempFiles();
|
||||
mcpConfigFile?.cleanup();
|
||||
paseoExtension?.cleanup();
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
@@ -2736,15 +2702,15 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
},
|
||||
initialState,
|
||||
capabilities: capabilitiesForSession(mcp !== null),
|
||||
persistedSessionFile: sessionCopy ? sessionFile : undefined,
|
||||
cleanup: cleanupTempFiles,
|
||||
cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]),
|
||||
extensionTimeoutMs: providerOptions.extensionTimeoutMs,
|
||||
logger: this.logger,
|
||||
usagePollScheduler: this.usagePollScheduler,
|
||||
});
|
||||
} catch (error) {
|
||||
await runtimeSession.close().catch(() => undefined);
|
||||
cleanupTempFiles();
|
||||
mcpConfigFile?.cleanup();
|
||||
paseoExtension?.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, utimes, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, onTestFinished, test } from "vitest";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
copyPiSessionFileWithCwd,
|
||||
listPiImportableSessions,
|
||||
readPiImportSessionConfig,
|
||||
} from "./session-descriptor.js";
|
||||
import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js";
|
||||
|
||||
async function writeSession(root: string, lines: unknown[]): Promise<string> {
|
||||
const sessionsDir = path.join(root, "sessions", "project");
|
||||
@@ -220,97 +216,3 @@ test("Pi import config preserves thinking before a later model in large sessions
|
||||
thinkingOptionId: "low",
|
||||
});
|
||||
});
|
||||
|
||||
test("a Pi session copy names the new cwd in its header and keeps every later byte", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||
const header = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "pi-session-1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd: path.join(root, "removed-worktree"),
|
||||
};
|
||||
const body = Buffer.from(
|
||||
Array.from(
|
||||
{ length: 2_000 },
|
||||
(_, index) =>
|
||||
`${JSON.stringify({ type: "message", id: `entry-${index}`, message: { role: "user", content: `café ☕ ${index}` } })}\n`,
|
||||
).join(""),
|
||||
);
|
||||
expect(body.length).toBeGreaterThan(64 * 1024);
|
||||
const sourceFile = path.join(root, "source.jsonl");
|
||||
const targetFile = path.join(root, "copy.jsonl");
|
||||
await writeFile(sourceFile, Buffer.concat([Buffer.from(`${JSON.stringify(header)}\n`), body]));
|
||||
|
||||
await copyPiSessionFileWithCwd({ sourceFile, targetFile, cwd: "/home/user" });
|
||||
|
||||
const copy = await readFile(targetFile);
|
||||
const headerEnd = copy.indexOf(0x0a);
|
||||
expect(JSON.parse(copy.subarray(0, headerEnd).toString("utf8"))).toEqual({
|
||||
...header,
|
||||
cwd: "/home/user",
|
||||
});
|
||||
expect(copy.subarray(headerEnd + 1).equals(body)).toBe(true);
|
||||
});
|
||||
|
||||
test("a Pi session copy refuses a file without a session header", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||
const sourceFile = path.join(root, "source.jsonl");
|
||||
await writeFile(sourceFile, `${JSON.stringify({ type: "message", id: "entry-1" })}\n`);
|
||||
|
||||
await expect(
|
||||
copyPiSessionFileWithCwd({ sourceFile, targetFile: path.join(root, "copy.jsonl"), cwd: "/" }),
|
||||
).rejects.toThrow(`Pi session file has no session header: ${sourceFile}`);
|
||||
});
|
||||
|
||||
const COPY_BODY = [
|
||||
{ type: "message", id: "entry-1", message: { role: "user", content: "café ☕" } },
|
||||
{ type: "message", id: "entry-2", message: { role: "assistant", content: "Dans la cuisine." } },
|
||||
]
|
||||
.map((entry) => `${JSON.stringify(entry)}\n`)
|
||||
.join("");
|
||||
|
||||
const COPY_HEADER = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "pi-session-1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd: "/removed-worktree",
|
||||
};
|
||||
|
||||
async function copySessionContent(content: string): Promise<Buffer> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||
const sourceFile = path.join(root, "source.jsonl");
|
||||
const targetFile = path.join(root, "copy.jsonl");
|
||||
await writeFile(sourceFile, content);
|
||||
await copyPiSessionFileWithCwd({ sourceFile, targetFile, cwd: "/home/user" });
|
||||
return readFile(targetFile);
|
||||
}
|
||||
|
||||
test.each([
|
||||
["a header line longer than 64 KiB", "", { ...COPY_HEADER, padding: "x".repeat(70 * 1024) }],
|
||||
["a header with an empty cwd", "", { ...COPY_HEADER, cwd: "" }],
|
||||
["blank lines before the header", "\n\r\n", COPY_HEADER],
|
||||
])("a Pi session copy accepts %s", async (_headerShape, prefix, header) => {
|
||||
const copy = await copySessionContent(`${prefix}${JSON.stringify(header)}\n${COPY_BODY}`);
|
||||
|
||||
const headerEnd = copy.indexOf(0x0a);
|
||||
expect(JSON.parse(copy.subarray(0, headerEnd).toString("utf8"))).toEqual({
|
||||
...header,
|
||||
cwd: "/home/user",
|
||||
});
|
||||
expect(copy.subarray(headerEnd + 1).equals(Buffer.from(COPY_BODY))).toBe(true);
|
||||
});
|
||||
|
||||
test("a Pi session copy reports a missing session file", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||
const sourceFile = path.join(root, "missing.jsonl");
|
||||
|
||||
await expect(
|
||||
copyPiSessionFileWithCwd({ sourceFile, targetFile: path.join(root, "copy.jsonl"), cwd: "/" }),
|
||||
).rejects.toThrow(`Pi session file is missing: ${sourceFile}`);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createReadStream, createWriteStream, type Dirent } from "node:fs";
|
||||
import { open, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import type { Dirent } from "node:fs";
|
||||
import { open, readdir, readFile, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
|
||||
import type {
|
||||
ImportableProviderSession,
|
||||
@@ -19,7 +18,6 @@ const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
|
||||
const HEAD_BYTES = 64 * 1024;
|
||||
const TAIL_BYTES = 256 * 1024;
|
||||
const FULL_SCAN_LINE_LIMIT = 2_000;
|
||||
const PI_SESSION_HEADER_SCAN_BYTES = 1024 * 1024;
|
||||
// Rank all discovered files cheaply, then parse only a bounded recent window.
|
||||
const IMPORT_CANDIDATE_OVERSCAN = 40;
|
||||
const IMPORT_CANDIDATE_MIN = 400;
|
||||
@@ -102,59 +100,6 @@ export async function listPiImportableSessions(
|
||||
);
|
||||
}
|
||||
|
||||
export async function copyPiSessionFileWithCwd(input: {
|
||||
sourceFile: string;
|
||||
targetFile: string;
|
||||
cwd: string;
|
||||
}): Promise<void> {
|
||||
const found = findSessionHeaderLine(
|
||||
await readSessionFileStart(input.sourceFile, PI_SESSION_HEADER_SCAN_BYTES),
|
||||
);
|
||||
if (!found) {
|
||||
throw new Error(`Pi session file has no session header: ${input.sourceFile}`);
|
||||
}
|
||||
await writeFile(input.targetFile, `${JSON.stringify({ ...found.header, cwd: input.cwd })}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
await pipeline(
|
||||
createReadStream(input.sourceFile, { start: found.bodyStart }),
|
||||
createWriteStream(input.targetFile, { flags: "a" }),
|
||||
);
|
||||
}
|
||||
|
||||
function findSessionHeaderLine(
|
||||
scanned: Buffer,
|
||||
): { header: Record<string, unknown>; bodyStart: number } | null {
|
||||
let lineStart = 0;
|
||||
while (lineStart < scanned.length) {
|
||||
const newline = scanned.indexOf(0x0a, lineStart);
|
||||
const bodyStart = newline === -1 ? scanned.length : newline + 1;
|
||||
const line = scanned.subarray(lineStart, bodyStart).toString("utf8").trim();
|
||||
if (line) {
|
||||
const header = parseJsonRecord(line);
|
||||
return header?.type === "session" ? { header, bodyStart } : null;
|
||||
}
|
||||
lineStart = bodyStart;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readSessionFileStart(filePath: string, maxBytes: number): Promise<Buffer> {
|
||||
const handle = await open(filePath, "r").catch((error: unknown) => {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
throw new Error(`Pi session file is missing: ${filePath}`);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
const buffer = Buffer.alloc(maxBytes);
|
||||
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
|
||||
return buffer.subarray(0, bytesRead);
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPiImportSessionConfig(filePath: string): Promise<PiImportSessionConfig> {
|
||||
const descriptor = await readPiSessionDescriptor(filePath);
|
||||
if (!descriptor) return {};
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
import type {
|
||||
PiRuntime,
|
||||
PiRuntimeLaunch,
|
||||
@@ -77,12 +75,6 @@ export class FakePi implements PiRuntime {
|
||||
const session = new FakePiSession(launch, this.removedModels);
|
||||
session.commands = this.queuedCommands.shift() ?? [];
|
||||
this.queuedSessionSetups.shift()?.(session);
|
||||
const missingSessionCwd = launch.noSession ? null : readMissingSessionCwd(launch.session);
|
||||
if (missingSessionCwd !== null) {
|
||||
session.getStateError = new Error(
|
||||
`Pi RPC process exited with code 1 and signal null\nStored session working directory does not exist: ${missingSessionCwd}\nSession file: ${launch.session}\nCurrent working directory: ${launch.cwd}`,
|
||||
);
|
||||
}
|
||||
this.sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
@@ -109,27 +101,6 @@ export class FakePi implements PiRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
function readMissingSessionCwd(sessionFile: string | undefined): string | null {
|
||||
if (!sessionFile || !existsSync(sessionFile)) {
|
||||
return null;
|
||||
}
|
||||
const [headerLine = ""] = readFileSync(sessionFile, "utf8").split("\n", 1);
|
||||
let header: unknown;
|
||||
try {
|
||||
header = JSON.parse(headerLine);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof header !== "object" || header === null || !("cwd" in header)) {
|
||||
return null;
|
||||
}
|
||||
const { type, cwd } = header as { type?: unknown; cwd?: unknown };
|
||||
if (type !== "session" || typeof cwd !== "string" || !cwd || existsSync(cwd)) {
|
||||
return null;
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
|
||||
export class FakePiSession implements PiRuntimeSession {
|
||||
readonly environment: Record<string, string>;
|
||||
readonly prompts: Array<{ message: string; imageCount: number }> = [];
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
import type { AgentResumePurpose } from "../agent-sdk-types.js";
|
||||
|
||||
export async function resolveResumeProcessCwd(
|
||||
cwd: string,
|
||||
purpose: AgentResumePurpose | undefined,
|
||||
): Promise<string> {
|
||||
if (purpose !== "history" || (await isDirectory(cwd))) {
|
||||
return cwd;
|
||||
}
|
||||
return homedir();
|
||||
}
|
||||
|
||||
async function isDirectory(target: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(target)).isDirectory();
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT" || code === "ENOTDIR") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -670,11 +670,11 @@ describe("paseo daemon bootstrap", () => {
|
||||
|
||||
test("rolls back standalone listener without starting plugins when main listen fails", async () => {
|
||||
const mainPort = await findFreePort();
|
||||
const standalonePort = await findFreePort();
|
||||
const occupiedMain = http.createServer((_req, res) => {
|
||||
res.end("occupied-main");
|
||||
});
|
||||
await new Promise<void>((resolve) => occupiedMain.listen(mainPort, "127.0.0.1", resolve));
|
||||
const standalonePort = await findFreePort();
|
||||
|
||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-"));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
|
||||
@@ -183,7 +183,7 @@ test("ordinary routed APIs isolate scopes and enforce one-hop, disable and origi
|
||||
await paseo.dispose();
|
||||
});
|
||||
|
||||
test("routed authority isolates simultaneous sessions and preserves ordinary permission RPC errors", async () => {
|
||||
test("routed authority is bounded by the caller, with isolated simultaneous sessions", async () => {
|
||||
const b = await daemon();
|
||||
const a = await daemon();
|
||||
const owner = await api(a);
|
||||
@@ -210,16 +210,33 @@ test("routed authority isolates simultaneous sessions and preserves ordinary per
|
||||
code: "permission_denied",
|
||||
});
|
||||
expect((await owner.paseo.hosts.list()).hosts).toHaveLength(1);
|
||||
const directClient = createHostClient(`127.0.0.1:${b.port}`, {
|
||||
clientId: crypto.randomUUID(),
|
||||
sessionPermissions: ["daemon.read", "workspace.read"],
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
await directClient.connect();
|
||||
const direct = createPaseoApi(directClient);
|
||||
await remote.dispose();
|
||||
});
|
||||
|
||||
test("SDK host clients preserve ordinary permission RPC errors", async () => {
|
||||
const b = await daemon();
|
||||
const a = await daemon();
|
||||
const owner = await api(a);
|
||||
await owner.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||
const serverId = (await online(owner.paseo)).hosts[0]!.serverId!;
|
||||
const clients = [a, b].map((host) =>
|
||||
createHostClient(`127.0.0.1:${host.port}`, {
|
||||
clientId: crypto.randomUUID(),
|
||||
sessionPermissions: ["daemon.read", "workspace.read"],
|
||||
reconnect: { enabled: false },
|
||||
}),
|
||||
);
|
||||
for (const client of clients) {
|
||||
cleanup.push(() => client.close());
|
||||
await client.connect();
|
||||
}
|
||||
const origin = createPaseoApi(clients[0]!);
|
||||
const direct = createPaseoApi(clients[1]!);
|
||||
const remote = await origin.hosts.client(serverId);
|
||||
cleanup.push(async () => {
|
||||
await remote.dispose();
|
||||
await direct.dispose();
|
||||
await directClient.close();
|
||||
await origin.dispose();
|
||||
});
|
||||
const input = { source: { kind: "directory" as const, path: b.staticDir } };
|
||||
const directError = (await direct.workspaces.create(input).catch((error: unknown) => error)) as {
|
||||
@@ -230,7 +247,6 @@ test("routed authority isolates simultaneous sessions and preserves ordinary per
|
||||
};
|
||||
expect(directError.code).toBe("access_denied");
|
||||
expect(remoteError.code).toBe(directError.code);
|
||||
await remote.dispose();
|
||||
});
|
||||
|
||||
test("distinct origin sessions cannot collide through a caller-selected route scope, including terminal binary output", async () => {
|
||||
@@ -768,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")
|
||||
|
||||
@@ -88,9 +88,13 @@ test.each(["uncommitted", "base", "commit"] as const)(
|
||||
60_000,
|
||||
);
|
||||
|
||||
test.each(["single", "batch", "rename"] as const)(
|
||||
"%s diff includes hunks from a repository subdirectory before and after commit",
|
||||
async (shape) => {
|
||||
test.each(
|
||||
(["uncommitted", "base"] as const).flatMap((mode) =>
|
||||
(["single", "batch", "rename"] as const).map((shape) => ({ mode, shape })),
|
||||
),
|
||||
)(
|
||||
"$mode $shape diff includes hunks from a repository subdirectory",
|
||||
async ({ mode, shape }) => {
|
||||
const ctx = await createDaemonTestContext();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-"));
|
||||
|
||||
@@ -108,7 +112,9 @@ test.each(["single", "batch", "rename"] as const)(
|
||||
}
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
|
||||
git(cwd, "checkout", "-b", "feature");
|
||||
if (mode === "base") {
|
||||
git(cwd, "checkout", "-b", "feature");
|
||||
}
|
||||
const filename = shape === "rename" ? "renamed.txt" : "normal.txt";
|
||||
if (shape === "rename") {
|
||||
git(cwd, "mv", "sub/normal.txt", `sub/${filename}`);
|
||||
@@ -117,38 +123,36 @@ test.each(["single", "batch", "rename"] as const)(
|
||||
if (shape === "batch") {
|
||||
writeFileSync(join(cwd, "sibling.txt"), "new sibling\n");
|
||||
}
|
||||
async function expectSubdirectoryDiff(mode: "uncommitted" | "base") {
|
||||
const compare = { mode, baseRef: "main" };
|
||||
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
|
||||
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
|
||||
const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare);
|
||||
const liveDiff = await subscription.ready;
|
||||
await subscription.release();
|
||||
|
||||
expect(rootDiff.error).toBeNull();
|
||||
expect(subdirectoryDiff.error).toBeNull();
|
||||
expect(liveDiff.error).toBeNull();
|
||||
expect(liveDiff.files).toEqual(rootDiff.files);
|
||||
expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1);
|
||||
const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`);
|
||||
expect(file).toMatchObject({
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}),
|
||||
});
|
||||
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "remove", content: "before" }),
|
||||
expect.objectContaining({ type: "add", content: "after" }),
|
||||
]),
|
||||
);
|
||||
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
|
||||
if (mode === "base") {
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
|
||||
}
|
||||
|
||||
await expectSubdirectoryDiff("uncommitted");
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
|
||||
await expectSubdirectoryDiff("base");
|
||||
const compare = { mode, baseRef: "main" };
|
||||
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
|
||||
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
|
||||
const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare);
|
||||
const liveDiff = await subscription.ready;
|
||||
await subscription.release();
|
||||
|
||||
expect(rootDiff.error).toBeNull();
|
||||
expect(subdirectoryDiff.error).toBeNull();
|
||||
expect(liveDiff.error).toBeNull();
|
||||
expect(liveDiff.files).toEqual(rootDiff.files);
|
||||
expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1);
|
||||
const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`);
|
||||
expect(file).toMatchObject({
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}),
|
||||
});
|
||||
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "remove", content: "before" }),
|
||||
expect.objectContaining({ type: "add", content: "after" }),
|
||||
]),
|
||||
);
|
||||
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
|
||||
} finally {
|
||||
await ctx.cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
|
||||
@@ -102,52 +102,53 @@ describe("daemon E2E", () => {
|
||||
},
|
||||
);
|
||||
|
||||
test("downloads ASCII and Unicode filenames through one workspace", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Download Token Test Agent",
|
||||
});
|
||||
|
||||
expect(agent.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
|
||||
for (const { fileName, disposition } of [
|
||||
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
|
||||
{
|
||||
fileName: "café.txt",
|
||||
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
|
||||
},
|
||||
{
|
||||
fileName: "中文报告 (最终版).txt",
|
||||
disposition:
|
||||
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
|
||||
},
|
||||
{
|
||||
fileName: "報告書.md",
|
||||
disposition:
|
||||
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
|
||||
},
|
||||
{
|
||||
fileName: "보고서.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
|
||||
},
|
||||
{
|
||||
fileName: "report-📄.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
|
||||
},
|
||||
]) {
|
||||
test.each([
|
||||
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
|
||||
{
|
||||
fileName: "café.txt",
|
||||
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
|
||||
},
|
||||
{
|
||||
fileName: "中文报告 (最终版).txt",
|
||||
disposition:
|
||||
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
|
||||
},
|
||||
{
|
||||
fileName: "報告書.md",
|
||||
disposition:
|
||||
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
|
||||
},
|
||||
{
|
||||
fileName: "보고서.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
|
||||
},
|
||||
{
|
||||
fileName: "report-📄.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
|
||||
},
|
||||
])(
|
||||
"issues token over WS and downloads $fileName via HTTP",
|
||||
async ({ fileName, disposition }) => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const filePath = path.join(cwd, fileName);
|
||||
const fileContents = `download test payload: ${fileName}`;
|
||||
const fileContents = "download test payload";
|
||||
writeFileSync(filePath, fileContents, "utf-8");
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Download Token Test Agent",
|
||||
});
|
||||
|
||||
expect(agent.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
|
||||
const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName);
|
||||
|
||||
expect(tokenResponse.error).toBeNull();
|
||||
@@ -166,11 +167,12 @@ describe("daemon E2E", () => {
|
||||
|
||||
const body = await response.text();
|
||||
expect(body).toBe(fileContents);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
},
|
||||
60000,
|
||||
);
|
||||
|
||||
test("rejects invalid token", async () => {
|
||||
const response = await fetch(
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { FormInfo } from "@opencode/client";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { OpenCodeV2AgentClient } from "../agent/providers/opencode/v2/agent.js";
|
||||
import { V2Harness } from "../agent/providers/opencode/test-utils/v2-harness.js";
|
||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
|
||||
async function createQuestionHarness() {
|
||||
const artifacts = path.resolve(".artifacts");
|
||||
await mkdir(artifacts, { recursive: true });
|
||||
const root = await mkdtemp(path.join(artifacts, "peer-permissions-"));
|
||||
const runtime = new V2Harness();
|
||||
runtime.autoComplete = false;
|
||||
runtime.api.model.list = async () => ({ location: runtime.info.location, data: [] });
|
||||
runtime.api.provider.list = async () => ({
|
||||
location: runtime.info.location,
|
||||
data: [{ id: "test", name: "Test", activation: "enabled", package: "test" }],
|
||||
});
|
||||
let form: FormInfo | null = null;
|
||||
const answers: Array<Record<string, unknown>> = [];
|
||||
runtime.api.session.form.list = async () => (form ? [form] : []);
|
||||
runtime.api.session.form.cancel = async () => {
|
||||
form = null;
|
||||
runtime.finishExecution("interrupted");
|
||||
};
|
||||
runtime.api.session.form.reply = async ({ answer }) => {
|
||||
answers.push(answer);
|
||||
form = null;
|
||||
runtime.finishExecution();
|
||||
};
|
||||
// A steer is admitted to the existing execution, rather than starting another one.
|
||||
runtime.prompt = async (input) => {
|
||||
runtime.prompts.push(input.text);
|
||||
if (runtime.prompts.length === 1) runtime.startExecution();
|
||||
};
|
||||
const provider = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: runtime.runtime,
|
||||
});
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { ...createTestAgentClients(), opencode: provider },
|
||||
paseoHomeRoot: path.join(root, "daemon"),
|
||||
staticDir: path.join(root, "static"),
|
||||
cleanup: false,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const recipient = await client.createAgent({
|
||||
provider: "opencode",
|
||||
cwd: root,
|
||||
title: "Waiting for a human answer",
|
||||
});
|
||||
const sender = await client.createAgent({ provider: "claude", cwd: root });
|
||||
await client.sendMessage(recipient.id, "Ask which color to use");
|
||||
await expect.poll(() => runtime.prompts).toHaveLength(1);
|
||||
form = {
|
||||
id: "color-question",
|
||||
sessionID: "session",
|
||||
title: "Color",
|
||||
fields: [{ key: "color", title: "Color", type: "string" }],
|
||||
};
|
||||
runtime.push({ id: "question-created", created: 2, type: "form.created", data: { form } });
|
||||
await client.waitForAgentUpsert(recipient.id, (agent) =>
|
||||
(agent.pendingPermissions ?? []).some((request) => request.id === "color-question"),
|
||||
);
|
||||
return {
|
||||
client,
|
||||
recipient,
|
||||
sender,
|
||||
answers,
|
||||
async close() {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test("an agent-sourced steer leaves the question pending and answerable", async () => {
|
||||
const { client, recipient, sender, answers, close } = await createQuestionHarness();
|
||||
try {
|
||||
await client.sendAgentMessage(recipient.id, "Peer status check", {
|
||||
sourceAgentId: sender.id,
|
||||
});
|
||||
// The public wait includes admitted message delivery before reporting permission status.
|
||||
const waiting = await client.waitForFinish(recipient.id);
|
||||
expect(waiting.status).toBe("permission");
|
||||
expect(waiting.final?.pendingPermissions).toMatchObject([
|
||||
{ id: "color-question", kind: "question" },
|
||||
]);
|
||||
await client.respondToPermission(recipient.id, "color-question", {
|
||||
behavior: "allow",
|
||||
updatedInput: { answers: { Color: "Blue" } },
|
||||
});
|
||||
await client.waitForAgentUpsert(
|
||||
recipient.id,
|
||||
(agent) => (agent.pendingPermissions ?? []).length === 0 && agent.status === "idle",
|
||||
);
|
||||
expect(answers).toEqual([{ color: "Blue" }]);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a human steer still dismisses the question", async () => {
|
||||
const { client, recipient, answers, close } = await createQuestionHarness();
|
||||
try {
|
||||
await client.sendAgentMessage(recipient.id, "Use red instead", { activeTurnBehavior: "steer" });
|
||||
const finished = await client.waitForFinish(recipient.id);
|
||||
expect(finished.status).toBe("idle");
|
||||
expect(finished.final?.pendingPermissions ?? []).toEqual([]);
|
||||
expect(answers).toEqual([]);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
@@ -20,7 +20,6 @@ import { DaemonConfigStore } from "../daemon-config-store.js";
|
||||
import { PluginService } from "./index.js";
|
||||
import { BuiltinPluginLoader } from "./builtin/index.js";
|
||||
import { ManagedPluginSources } from "./managed-source.js";
|
||||
import type { PluginProviderMetadata } from "./plugin-process-protocol.js";
|
||||
import {
|
||||
startNpmRegistry,
|
||||
npmPluginPackages,
|
||||
@@ -180,55 +179,6 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) {
|
||||
return { runtime, started, releaseStart, starts };
|
||||
}
|
||||
|
||||
function createGatedRuntime(providers: Record<string, readonly PluginProviderMetadata[]> = {}) {
|
||||
const gates = new Map<string, PromiseWithResolvers<void>>();
|
||||
const gate = (pluginId: string) => {
|
||||
const existing = gates.get(pluginId);
|
||||
if (existing) return existing;
|
||||
const created = Promise.withResolvers<void>();
|
||||
gates.set(pluginId, created);
|
||||
return created;
|
||||
};
|
||||
const begun: string[] = [];
|
||||
const running = new Set<string>();
|
||||
const runtime: TestPluginRuntime = {
|
||||
...emptyUsageRuntime,
|
||||
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
|
||||
invoke: async () => undefined,
|
||||
getLogs: () => [],
|
||||
clearLogs: () => undefined,
|
||||
getProviderRegistrations: (pluginId) =>
|
||||
running.has(pluginId) ? (providers[pluginId] ?? []) : [],
|
||||
startPlugin: async (pluginId) => {
|
||||
begun.push(pluginId);
|
||||
await gate(pluginId).promise;
|
||||
running.add(pluginId);
|
||||
},
|
||||
stopPluginById: async (pluginId) => running.delete(pluginId),
|
||||
stopAll: async () => {
|
||||
running.clear();
|
||||
},
|
||||
subscribe: () => () => undefined,
|
||||
bindPaseoSessionHost: () => undefined,
|
||||
};
|
||||
return {
|
||||
runtime,
|
||||
begun,
|
||||
finish: (pluginId: string) => gate(pluginId).resolve(),
|
||||
fail: (pluginId: string, error: Error) => gate(pluginId).reject(error),
|
||||
};
|
||||
}
|
||||
|
||||
function nextNotification(service: PluginService, pluginId: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const unsubscribe = service.subscribe((notified) => {
|
||||
if (notified !== pluginId) return;
|
||||
unsubscribe();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("PluginService", () => {
|
||||
it.each([
|
||||
{ shadow: false, runtime: "subprocess" },
|
||||
@@ -422,127 +372,15 @@ describe("PluginService", () => {
|
||||
"startup-first": { source: "directory", path: first },
|
||||
"startup-second": { source: "directory", path: second },
|
||||
});
|
||||
const notifications: { pluginId: string; running: boolean }[] = [];
|
||||
service.subscribe((pluginId) =>
|
||||
notifications.push({ pluginId, running: catalogIds(service).includes(pluginId) }),
|
||||
);
|
||||
const snapshots: string[][] = [];
|
||||
service.subscribe(() => snapshots.push(catalogIds(service)));
|
||||
|
||||
await service.start();
|
||||
|
||||
expect(notifications).toEqual([
|
||||
{ pluginId: "startup-first", running: true },
|
||||
{ pluginId: "startup-second", running: true },
|
||||
]);
|
||||
expect(catalogIds(service)).toEqual(["startup-first", "startup-second"]);
|
||||
expect(snapshots).toEqual([["startup-first"], ["startup-first", "startup-second"]]);
|
||||
await service.stopAllPlugins();
|
||||
}, 20_000);
|
||||
|
||||
it("starts configured plugins concurrently and publishes them in config order", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
const gated = createGatedRuntime();
|
||||
const service = createService(
|
||||
home,
|
||||
{
|
||||
alpha: { source: "directory", path: "/plugins/alpha" },
|
||||
beta: { source: "directory", path: "/plugins/beta" },
|
||||
gamma: { source: "directory", path: "/plugins/gamma" },
|
||||
},
|
||||
{ runtime: gated.runtime },
|
||||
);
|
||||
const notified: string[] = [];
|
||||
service.subscribe((pluginId) => notified.push(pluginId));
|
||||
let started = false;
|
||||
const starting = (async () => {
|
||||
await service.start();
|
||||
started = true;
|
||||
})();
|
||||
|
||||
await expect.poll(() => gated.begun).toEqual(["alpha", "beta", "gamma"]);
|
||||
|
||||
gated.finish("gamma");
|
||||
const alphaPublished = nextNotification(service, "alpha");
|
||||
gated.finish("alpha");
|
||||
await alphaPublished;
|
||||
expect(notified).toEqual(["alpha"]);
|
||||
expect(started).toBe(false);
|
||||
|
||||
gated.finish("beta");
|
||||
await starting;
|
||||
expect(notified).toEqual(["alpha", "beta", "gamma"]);
|
||||
expect(catalogIds(service)).toEqual(["alpha", "beta", "gamma"]);
|
||||
await service.stopAllPlugins();
|
||||
});
|
||||
|
||||
it("starts the other configured plugins when one fails while they are still starting", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
const gated = createGatedRuntime();
|
||||
const service = createService(
|
||||
home,
|
||||
{
|
||||
broken: { source: "directory", path: "/plugins/broken" },
|
||||
healthy: { source: "directory", path: "/plugins/healthy" },
|
||||
},
|
||||
{ runtime: gated.runtime },
|
||||
);
|
||||
const notified: string[] = [];
|
||||
service.subscribe((pluginId) => notified.push(pluginId));
|
||||
const starting = service.start();
|
||||
|
||||
await expect.poll(() => gated.begun).toEqual(["broken", "healthy"]);
|
||||
const brokenPublished = nextNotification(service, "broken");
|
||||
gated.fail("broken", new Error("startup exploded"));
|
||||
await brokenPublished;
|
||||
gated.finish("healthy");
|
||||
await starting;
|
||||
|
||||
expect(notified).toEqual(["broken", "healthy"]);
|
||||
expect(catalogIds(service)).toEqual(["healthy"]);
|
||||
expect(await service.listPlugins()).toMatchObject([
|
||||
{ id: "broken", status: "failed", error: "startup exploded" },
|
||||
{ id: "healthy", status: "running" },
|
||||
]);
|
||||
await service.stopAllPlugins();
|
||||
});
|
||||
|
||||
it("keeps a provider ID with the plugin listed first when both start at once", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
const gated = createGatedRuntime({
|
||||
first: [{ id: "shared-agent", label: "First agent" }],
|
||||
second: [{ id: "shared-agent", label: "Second agent" }],
|
||||
});
|
||||
const service = createService(
|
||||
home,
|
||||
{
|
||||
first: { source: "directory", path: "/plugins/first" },
|
||||
second: { source: "directory", path: "/plugins/second" },
|
||||
},
|
||||
{ runtime: gated.runtime },
|
||||
);
|
||||
const starting = service.start();
|
||||
|
||||
await expect.poll(() => gated.begun).toEqual(["first", "second"]);
|
||||
gated.finish("second");
|
||||
gated.finish("first");
|
||||
await starting;
|
||||
|
||||
expect(service.getProviderRegistrations()).toMatchObject([
|
||||
{ id: "shared-agent", label: "First agent" },
|
||||
]);
|
||||
expect(catalogIds(service)).toEqual(["first"]);
|
||||
expect(await service.listPlugins()).toMatchObject([
|
||||
{ id: "first", status: "running" },
|
||||
{
|
||||
id: "second",
|
||||
status: "failed",
|
||||
error: 'Plugin second cannot register provider ID "shared-agent" twice',
|
||||
},
|
||||
]);
|
||||
await service.stopAllPlugins();
|
||||
});
|
||||
|
||||
it("uses an explicit config key, exposes reload failure, and retries from disk", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
|
||||
@@ -230,18 +230,11 @@ export class PluginService {
|
||||
}
|
||||
});
|
||||
if (config.pluginsEnabled === true) {
|
||||
let earlierPublished: Promise<unknown> = Promise.resolve();
|
||||
await Promise.all(
|
||||
Object.entries(config.plugins ?? {})
|
||||
.filter(([, source]) => source.enabled !== false)
|
||||
.map(async ([pluginId]) => {
|
||||
const publishTurn = earlierPublished;
|
||||
const starting = this.startConfigured(pluginId, publishTurn);
|
||||
earlierPublished = Promise.all([publishTurn, starting]);
|
||||
await starting;
|
||||
this.notify(pluginId);
|
||||
}),
|
||||
);
|
||||
for (const [pluginId, source] of Object.entries(config.plugins ?? {})) {
|
||||
if (source.enabled === false) continue;
|
||||
await this.startConfigured(pluginId);
|
||||
this.notify(pluginId);
|
||||
}
|
||||
}
|
||||
this.configStore.onFieldChange("pluginsEnabled", (value) => {
|
||||
this.handleGlobalSwitch(value === true);
|
||||
@@ -567,12 +560,12 @@ export class PluginService {
|
||||
}).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins"));
|
||||
}
|
||||
|
||||
private async startConfigured(pluginId: string, publishTurn?: Promise<unknown>): Promise<void> {
|
||||
private async startConfigured(pluginId: string): Promise<void> {
|
||||
const source = this.configStore.get().plugins?.[pluginId];
|
||||
if (!source || source.enabled === false || !this.canPublish(pluginId)) return;
|
||||
this.errors.delete(pluginId);
|
||||
try {
|
||||
await this.startPlugin(pluginId, source.path, publishTurn);
|
||||
await this.startPlugin(pluginId, source.path);
|
||||
} catch (error) {
|
||||
if (this.canPublish(pluginId)) this.recordFailure(pluginId, error);
|
||||
}
|
||||
@@ -601,13 +594,8 @@ export class PluginService {
|
||||
);
|
||||
}
|
||||
|
||||
private async startPlugin(
|
||||
pluginId: string,
|
||||
sourcePath: string,
|
||||
publishTurn?: Promise<unknown>,
|
||||
): Promise<void> {
|
||||
private async startPlugin(pluginId: string, sourcePath: string): Promise<void> {
|
||||
await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId));
|
||||
await publishTurn;
|
||||
try {
|
||||
await this.publishProviderRegistrations(pluginId, sourcePath);
|
||||
this.publishUsageSources(pluginId);
|
||||
|
||||
@@ -61,24 +61,10 @@ describe("managed Git plugin sources", () => {
|
||||
roots.push(home);
|
||||
const sources = new ManagedPluginSources(home);
|
||||
|
||||
// Own the failure response instead of relying on a closed port and OS connect timing.
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(500, { "Content-Type": "text/plain" });
|
||||
response.end("clone rejected for oauth2:super-secret\n");
|
||||
const failure = sources.prepareInstall({
|
||||
source: "git:https://oauth2:super-secret@127.0.0.1:1/missing.git",
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address() as { port: number };
|
||||
try {
|
||||
const failure = sources.prepareInstall({
|
||||
source: `git:http://oauth2:super-secret@127.0.0.1:${address.port}/missing.git`,
|
||||
});
|
||||
await expect(failure).rejects.toThrow("500");
|
||||
await expect(failure).rejects.toThrow("clone rejected for [redacted]:[redacted]");
|
||||
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
|
||||
});
|
||||
|
||||
it("normalizes explicit GitHub and Git identifiers to the same Git source", async () => {
|
||||
|
||||
@@ -23,10 +23,7 @@ export function createPushNotifications(options: {
|
||||
}): PushNotifications {
|
||||
const now = options.now ?? Date.now;
|
||||
const store = new PushTokenStore(options.logger, options.filePath, now, PUSH_TOKEN_LEASE_MS);
|
||||
const service = new PushService({
|
||||
logger: options.logger,
|
||||
revokeToken: (token) => store.revokeToken(token),
|
||||
});
|
||||
const service = new PushService(options.logger, (token) => store.revokeToken(token));
|
||||
const deliver =
|
||||
options.deliver ??
|
||||
((tokens: string[], payload: PushPayload) => service.sendPush(tokens, payload));
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { PushService } from "./push-service.js";
|
||||
|
||||
describe("PushService", () => {
|
||||
test("requests high priority for every notification across batches", async () => {
|
||||
const requests: Record<string, unknown>[][] = [];
|
||||
const sendRequest: typeof fetch = async (_url, init) => {
|
||||
const messages = JSON.parse(String(init?.body)) as Record<string, unknown>[];
|
||||
requests.push(messages);
|
||||
return new Response(
|
||||
JSON.stringify({ data: messages.map(() => ({ status: "ok", id: "ticket" })) }),
|
||||
);
|
||||
};
|
||||
const service = new PushService({
|
||||
logger: pino({ level: "silent" }),
|
||||
revokeToken: () => undefined,
|
||||
sendRequest,
|
||||
});
|
||||
const tokens = Array.from({ length: 101 }, (_, index) => `ExponentPushToken[device-${index}]`);
|
||||
|
||||
await service.sendPush(tokens, {
|
||||
title: "Agent finished",
|
||||
body: "Done",
|
||||
data: { agentId: "agent" },
|
||||
});
|
||||
|
||||
expect(requests.map((batch) => batch.length)).toEqual([100, 1]);
|
||||
expect(requests.flat()).toEqual(
|
||||
tokens.map((token) => ({
|
||||
to: token,
|
||||
priority: "high",
|
||||
title: "Agent finished",
|
||||
body: "Done",
|
||||
data: { agentId: "agent" },
|
||||
sound: "default",
|
||||
})),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,6 @@ interface ExpoPushMessage {
|
||||
body: string;
|
||||
data?: Record<string, unknown>;
|
||||
sound?: "default";
|
||||
priority?: "default" | "normal" | "high";
|
||||
}
|
||||
|
||||
interface ExpoPushTicket {
|
||||
@@ -32,16 +31,10 @@ const MAX_BATCH_SIZE = 100;
|
||||
export class PushService {
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly revokeToken: (token: string) => void;
|
||||
private readonly sendRequest: typeof fetch;
|
||||
|
||||
constructor(options: {
|
||||
logger: pino.Logger;
|
||||
revokeToken: (token: string) => void;
|
||||
sendRequest?: typeof fetch;
|
||||
}) {
|
||||
this.logger = options.logger.child({ component: "push-service" });
|
||||
this.revokeToken = options.revokeToken;
|
||||
this.sendRequest = options.sendRequest ?? fetch;
|
||||
constructor(logger: pino.Logger, revokeToken: (token: string) => void) {
|
||||
this.logger = logger.child({ component: "push-service" });
|
||||
this.revokeToken = revokeToken;
|
||||
}
|
||||
|
||||
async sendPush(tokens: string[], payload: PushPayload): Promise<void> {
|
||||
@@ -49,16 +42,12 @@ export class PushService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Expo defaults to "normal" priority on Android, which FCM holds back while
|
||||
// the device dozes, so notifications arrive minutes late. iOS already
|
||||
// defaults to "high".
|
||||
const messages: ExpoPushMessage[] = tokens.map((token) => ({
|
||||
to: token,
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
data: payload.data,
|
||||
sound: "default",
|
||||
priority: "high",
|
||||
}));
|
||||
|
||||
// Batch tokens (max 100 per request per Expo limits)
|
||||
@@ -72,7 +61,7 @@ export class PushService {
|
||||
|
||||
private async sendBatch(messages: ExpoPushMessage[]): Promise<void> {
|
||||
try {
|
||||
const response = await this.sendRequest(EXPO_PUSH_URL, {
|
||||
const response = await fetch(EXPO_PUSH_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { type EventEmitter, once } from "node:events";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import type { Duplex } from "node:stream";
|
||||
import express from "express";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import pino from "pino";
|
||||
@@ -334,39 +332,48 @@ describe("service proxy subsystem shape", () => {
|
||||
});
|
||||
});
|
||||
|
||||
interface ProxiedServiceFixture {
|
||||
interface ForwardedFixture {
|
||||
upstreamPort: number;
|
||||
daemonPort: number;
|
||||
daemon: http.Server;
|
||||
hostname: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a real workspace service behind a real daemon listener that serves the
|
||||
* service proxy for both HTTP and upgrades.
|
||||
* Runs a real workspace service behind a real daemon listener. The upstream
|
||||
* echoes the headers it received so tests can assert what actually crossed the
|
||||
* proxy, not what a helper returned.
|
||||
*/
|
||||
async function startProxiedService(
|
||||
upstream: http.Server,
|
||||
options: { upstreamHost?: string; logger?: pino.Logger } = {},
|
||||
): Promise<ProxiedServiceFixture> {
|
||||
const { upstreamHost = "127.0.0.1", logger: proxyLogger = logger } = options;
|
||||
// Upgraded sockets keep the server alive; close() alone would hang.
|
||||
const sockets = new Set<net.Socket>();
|
||||
function trackConnections(server: http.Server) {
|
||||
server.on("connection", (socket: net.Socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ForwardedFixture> {
|
||||
const upstream = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
}
|
||||
trackConnections(upstream);
|
||||
req.on("end", () => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body }));
|
||||
});
|
||||
});
|
||||
const upgradeSockets: net.Socket[] = [];
|
||||
upstream.on("upgrade", (req, socket) => {
|
||||
upgradeSockets.push(socket);
|
||||
// The client resets this connection once it has the echo, so the reset
|
||||
// reaches us as an 'error'. A raw upgrade socket has no default handler —
|
||||
// without this the event goes unhandled and takes down the test process.
|
||||
socket.on("error", () => socket.destroy());
|
||||
const payload = JSON.stringify(req.headers);
|
||||
socket.write(
|
||||
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`,
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upstream.once("error", reject);
|
||||
upstream.listen(0, upstreamHost, resolve);
|
||||
});
|
||||
const upstreamPort = (upstream.address() as net.AddressInfo).port;
|
||||
|
||||
const serviceProxy = createServiceProxySubsystem({ logger: proxyLogger });
|
||||
const serviceProxy = createServiceProxySubsystem({ logger });
|
||||
const route = serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
@@ -383,51 +390,24 @@ async function startProxiedService(
|
||||
res.status(404).send("404 Not Found");
|
||||
});
|
||||
const daemon = http.createServer(app);
|
||||
trackConnections(daemon);
|
||||
daemon.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: false }));
|
||||
await new Promise<void>((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve));
|
||||
|
||||
return {
|
||||
upstreamPort,
|
||||
daemonPort,
|
||||
daemon,
|
||||
hostname: route.hostname,
|
||||
async close() {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
// Upgraded sockets keep the server alive; close() alone would hang.
|
||||
for (const socket of upgradeSockets) socket.destroy();
|
||||
daemon.closeAllConnections();
|
||||
await new Promise<void>((resolve) => daemon.close(() => resolve()));
|
||||
upstream.closeAllConnections();
|
||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The upstream echoes the headers it received so tests can assert what
|
||||
* actually crossed the proxy, not what a helper returned.
|
||||
*/
|
||||
function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ProxiedServiceFixture> {
|
||||
const upstream = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on("end", () => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body }));
|
||||
});
|
||||
});
|
||||
upstream.on("upgrade", (req, socket) => {
|
||||
// The client resets this connection once it has the echo, so the reset
|
||||
// reaches us as an 'error'. A raw upgrade socket has no default handler —
|
||||
// without this the event goes unhandled and takes down the test process.
|
||||
socket.on("error", () => socket.destroy());
|
||||
const payload = JSON.stringify(req.headers);
|
||||
socket.write(
|
||||
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`,
|
||||
);
|
||||
});
|
||||
return startProxiedService(upstream, { upstreamHost });
|
||||
}
|
||||
|
||||
function upgradeThroughProxy(
|
||||
port: number,
|
||||
host: string,
|
||||
@@ -671,198 +651,3 @@ describe("service proxy forwarded headers", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function closed(emitter: EventEmitter): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
emitter.once("close", () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function within<T>(promise: Promise<T>, ms: number, failure: string): Promise<T> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`${failure} after ${ms}ms`)), ms);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function requestThroughProxy(
|
||||
fixture: ProxiedServiceFixture,
|
||||
requestPath: string,
|
||||
): http.ClientRequest {
|
||||
return http.get({
|
||||
hostname: "127.0.0.1",
|
||||
port: fixture.daemonPort,
|
||||
path: requestPath,
|
||||
headers: { host: fixture.hostname },
|
||||
});
|
||||
}
|
||||
|
||||
const STRAY_WARNING_GRACE_MS = 100;
|
||||
|
||||
function createUpgradeUpstream(onUpgrade: (socket: Duplex) => void): http.Server {
|
||||
const upstream = http.createServer();
|
||||
upstream.on("upgrade", (_req, socket) => {
|
||||
socket.on("error", () => socket.destroy());
|
||||
socket.on("end", () => socket.end());
|
||||
socket.write(
|
||||
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n",
|
||||
);
|
||||
onUpgrade(socket);
|
||||
});
|
||||
return upstream;
|
||||
}
|
||||
|
||||
function openUpgradeThroughProxy(fixture: ProxiedServiceFixture): Promise<net.Socket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect({ host: "127.0.0.1", port: fixture.daemonPort }, () => {
|
||||
const lines = [
|
||||
"GET /ws HTTP/1.1",
|
||||
`Host: ${fixture.hostname}`,
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
"",
|
||||
"",
|
||||
];
|
||||
socket.write(lines.join("\r\n"));
|
||||
});
|
||||
let raw = "";
|
||||
socket.on("data", (chunk: Buffer) => {
|
||||
raw += chunk.toString();
|
||||
if (raw.includes("\r\n\r\n")) resolve(socket);
|
||||
});
|
||||
socket.on("close", () => reject(new Error("upgrade closed before the 101 arrived")));
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe("service proxy connection teardown", () => {
|
||||
it("closes the upstream connection when a client leaves an event stream", async () => {
|
||||
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
|
||||
const upstream = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
res.write("data: open\n\n");
|
||||
upstreamResponse.resolve(res);
|
||||
});
|
||||
const fixture = await startProxiedService(upstream);
|
||||
try {
|
||||
const request = requestThroughProxy(fixture, "/events");
|
||||
const [response] = (await once(request, "response")) as [http.IncomingMessage];
|
||||
await once(response, "data");
|
||||
const upstreamClosed = closed(await upstreamResponse.promise);
|
||||
|
||||
request.destroy();
|
||||
|
||||
await within(upstreamClosed, 1000, "the service still holds the event stream");
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
it("aborts the upstream request when a client leaves before the response starts", async () => {
|
||||
const warnings: unknown[] = [];
|
||||
const proxyLogger = pino(
|
||||
{ level: "warn" },
|
||||
{ write: (line: string) => warnings.push(JSON.parse(line)) },
|
||||
);
|
||||
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
|
||||
const upstream = http.createServer((_req, res) => {
|
||||
upstreamResponse.resolve(res);
|
||||
});
|
||||
const fixture = await startProxiedService(upstream, { logger: proxyLogger });
|
||||
try {
|
||||
const request = requestThroughProxy(fixture, "/slow");
|
||||
request.on("error", () => undefined);
|
||||
const upstreamClosed = closed(await upstreamResponse.promise);
|
||||
|
||||
request.destroy();
|
||||
|
||||
await within(upstreamClosed, 1000, "the service still holds the abandoned request");
|
||||
await new Promise((resolve) => setTimeout(resolve, STRAY_WARNING_GRACE_MS));
|
||||
expect(warnings).toEqual([]);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
it("lets a complete response finish without resetting the service", async () => {
|
||||
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
|
||||
const upstream = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/plain" });
|
||||
res.write("first;");
|
||||
upstreamResponse.resolve(res);
|
||||
});
|
||||
const fixture = await startProxiedService(upstream);
|
||||
try {
|
||||
const response = httpGet(fixture.daemonPort, fixture.hostname, { path: "/" });
|
||||
const res = await upstreamResponse.promise;
|
||||
const upstreamClosed = closed(res);
|
||||
|
||||
res.end("second");
|
||||
|
||||
await expect(response).resolves.toEqual({ status: 200, body: "first;second" });
|
||||
await within(upstreamClosed, 1000, "the service response never closed");
|
||||
expect(res.writableFinished).toBe(true);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
it("closes the upstream WebSocket when the daemon drops the client socket", async () => {
|
||||
const upstreamSocket = Promise.withResolvers<Duplex>();
|
||||
const fixture = await startProxiedService(
|
||||
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
|
||||
);
|
||||
const daemonSide = Promise.withResolvers<Duplex>();
|
||||
fixture.daemon.on("upgrade", (_req, socket) => daemonSide.resolve(socket));
|
||||
try {
|
||||
const client = await openUpgradeThroughProxy(fixture);
|
||||
const upstreamClosed = closed(await upstreamSocket.promise);
|
||||
|
||||
(await daemonSide.promise).destroy();
|
||||
|
||||
await within(upstreamClosed, 1000, "the service still holds the WebSocket");
|
||||
client.destroy();
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
it("closes the upstream WebSocket when the client disconnects", async () => {
|
||||
const upstreamSocket = Promise.withResolvers<Duplex>();
|
||||
const fixture = await startProxiedService(
|
||||
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
|
||||
);
|
||||
try {
|
||||
const client = await openUpgradeThroughProxy(fixture);
|
||||
const upstreamClosed = closed(await upstreamSocket.promise);
|
||||
|
||||
client.destroy();
|
||||
|
||||
await within(upstreamClosed, 1000, "the service still holds the WebSocket");
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
it("closes the client WebSocket when the service closes it", async () => {
|
||||
const upstreamSocket = Promise.withResolvers<Duplex>();
|
||||
const fixture = await startProxiedService(
|
||||
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
|
||||
);
|
||||
try {
|
||||
const client = await openUpgradeThroughProxy(fixture);
|
||||
const clientClosed = closed(client);
|
||||
|
||||
(await upstreamSocket.promise).destroy();
|
||||
|
||||
await within(clientClosed, 1000, "the client WebSocket is still open");
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
@@ -342,14 +342,7 @@ function proxyHttpRequest({
|
||||
proxyRes.pipe(res, { end: true });
|
||||
},
|
||||
);
|
||||
let clientClosed = false;
|
||||
res.on("close", () => {
|
||||
if (res.writableFinished) return;
|
||||
clientClosed = true;
|
||||
proxyReq.destroy();
|
||||
});
|
||||
proxyReq.on("error", (err) => {
|
||||
if (clientClosed) return;
|
||||
logger.warn(
|
||||
{ err, hostname: route.hostname, port: route.port },
|
||||
"Service proxy: upstream unreachable",
|
||||
@@ -406,9 +399,6 @@ function proxyUpgradeRequest({
|
||||
socket.on("error", () => {
|
||||
targetSocket.destroy();
|
||||
});
|
||||
socket.on("close", () => {
|
||||
targetSocket.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
function sameRouteOwner(left: ServiceProxyRouteEntry, right: ServiceProxyRouteEntry): boolean {
|
||||
|
||||
@@ -8318,7 +8318,7 @@ export class Session {
|
||||
},
|
||||
activeTurnBehavior: msg.activeTurnBehavior ?? "steer",
|
||||
sessionMode: msg.sessionMode,
|
||||
clearPendingPermissions: false,
|
||||
clearPendingPermissions: true,
|
||||
});
|
||||
deliveryStatus = "queued";
|
||||
queued();
|
||||
|
||||
@@ -95,13 +95,7 @@ it("accepts terminalId and token reports through the route into the tracker", as
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
`
|
||||
const fs = require("node:fs");
|
||||
const envPath = ${JSON.stringify(envPath)};
|
||||
fs.writeFileSync(envPath + ".tmp", JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL }));
|
||||
fs.renameSync(envPath + ".tmp", envPath);
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
`require("node:fs").writeFileSync(${JSON.stringify(envPath)}, JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL })); setInterval(() => {}, 1000);`,
|
||||
],
|
||||
});
|
||||
await waitForCondition(() => existsSync(envPath), 10000);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { flushGitCommandTrace } from "../utils/git-command-trace.js";
|
||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
@@ -731,103 +730,3 @@ test("Git event identity retains a symlink's literal target before canonicalizin
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(process.platform !== "linux")(
|
||||
"missing folders stop degraded Git polling and explicit refreshes, then recover",
|
||||
async () => {
|
||||
const artifacts = path.resolve(".artifacts");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
const root = mkdtempSync(path.join(artifacts, "workspace-git-missing-"));
|
||||
const cwd = path.join(root, "repo");
|
||||
const initializeRepo = (branch: string) => {
|
||||
mkdirSync(cwd);
|
||||
execFileSync("git", ["init", "-b", branch], { cwd, stdio: "pipe" });
|
||||
writeFileSync(path.join(cwd, "README.md"), "fixture\n");
|
||||
execFileSync("git", ["add", "."], { cwd, stdio: "pipe" });
|
||||
execFileSync(
|
||||
"git",
|
||||
[
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.invalid",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"-m",
|
||||
"fixture",
|
||||
],
|
||||
{ cwd, stdio: "pipe" },
|
||||
);
|
||||
};
|
||||
initializeRepo("main");
|
||||
// Exceed the real Linux observer's directory limit to enter degraded polling.
|
||||
// No watcher errors or Git responses are injected.
|
||||
for (let index = 0; index < 5100; index++) {
|
||||
mkdirSync(path.join(cwd, `generated-${index}`));
|
||||
}
|
||||
const trace = path.join(root, "git.jsonl");
|
||||
writeFileSync(trace, "");
|
||||
const previousTrace = process.env.PASEO_GIT_TRACE_FILE;
|
||||
process.env.PASEO_GIT_TRACE_FILE = trace;
|
||||
let degraded = false;
|
||||
let suspended = false;
|
||||
const logger = pino(
|
||||
{ level: "warn" },
|
||||
{
|
||||
write(line) {
|
||||
const message = JSON.parse(line).msg;
|
||||
if (message === "Failed to start working tree watcher; using degraded polling")
|
||||
degraded = true;
|
||||
if (
|
||||
message ===
|
||||
"Workspace folder no longer exists; suspended git observation until it is back"
|
||||
)
|
||||
suspended = true;
|
||||
},
|
||||
},
|
||||
);
|
||||
const service = new WorkspaceGitServiceImpl({ logger, paseoHome: path.join(root, "home") });
|
||||
const subscription = service.registerWorkspace({ cwd }, () => {});
|
||||
const commandCount = async () => {
|
||||
await flushGitCommandTrace();
|
||||
return readFileSync(trace, "utf8").trim().split("\n").filter(Boolean).length;
|
||||
};
|
||||
try {
|
||||
await expect
|
||||
.poll(() => degraded && service.getMetrics().workspaceRefreshInFlightCount === 0, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBe(true);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
await expect.poll(() => suspended, { timeout: 15_000 }).toBe(true);
|
||||
await expect.poll(() => service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||
const commandsWhileMissing = await commandCount();
|
||||
await service.getSnapshot(cwd, {
|
||||
force: true,
|
||||
includeForge: false,
|
||||
reason: "refresh-button",
|
||||
});
|
||||
await service.refresh(cwd);
|
||||
expect(await commandCount()).toBe(commandsWhileMissing);
|
||||
await new Promise((resolve) => setTimeout(resolve, 16_000));
|
||||
expect(await commandCount()).toBe(commandsWhileMissing);
|
||||
|
||||
initializeRepo("restored");
|
||||
const snapshot = await service.getSnapshot(cwd, {
|
||||
force: true,
|
||||
includeForge: false,
|
||||
reason: "refresh-button",
|
||||
});
|
||||
expect(snapshot.git).toMatchObject({ isGit: true, currentBranch: "restored" });
|
||||
await expect.poll(() => service.getMetrics().repositoryWorkspaceLinkCount).toBe(1);
|
||||
} finally {
|
||||
subscription.unsubscribe();
|
||||
await service.dispose();
|
||||
if (previousTrace === undefined) delete process.env.PASEO_GIT_TRACE_FILE;
|
||||
else process.env.PASEO_GIT_TRACE_FILE = previousTrace;
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
@@ -244,7 +244,6 @@ function createService(
|
||||
filterEvents: (events) => events,
|
||||
verify: vi.fn(async () => {}),
|
||||
})),
|
||||
workspaceFolderExists: vi.fn(async () => true),
|
||||
...overrides,
|
||||
} as never,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path, { join } from "node:path";
|
||||
import type pino from "pino";
|
||||
@@ -12,14 +10,11 @@ import type {
|
||||
PullRequestStatusResult,
|
||||
} from "../utils/checkout-git.js";
|
||||
import {
|
||||
BACKGROUND_GIT_FETCH_INTERVAL_MS,
|
||||
WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS,
|
||||
WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY,
|
||||
WORKSPACE_GIT_REFRESH_CONCURRENCY,
|
||||
WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS,
|
||||
WorkspaceGitServiceImpl,
|
||||
type WorkspaceGitRuntimeSnapshot,
|
||||
workspaceFolderExists,
|
||||
} from "./workspace-git-service.js";
|
||||
|
||||
const REPO_CWD = path.resolve("/tmp/repo");
|
||||
@@ -28,7 +23,6 @@ function createLogger() {
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
};
|
||||
return logger;
|
||||
@@ -257,7 +251,7 @@ function createGitHubServiceStub(): ForgeService {
|
||||
interface CreateServiceTestOptions {
|
||||
subscribe?: ReturnType<typeof vi.fn>;
|
||||
getCheckoutStatus?: ReturnType<typeof vi.fn>;
|
||||
getCheckoutSnapshotFacts?: (cwd: string) => Promise<CheckoutSnapshotFacts>;
|
||||
getCheckoutSnapshotFacts?: ReturnType<typeof vi.fn>;
|
||||
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
|
||||
getCheckoutWorktreeState?: ReturnType<typeof vi.fn>;
|
||||
getPullRequestStatus?: ReturnType<typeof vi.fn>;
|
||||
@@ -267,7 +261,6 @@ interface CreateServiceTestOptions {
|
||||
runGitFetch?: ReturnType<typeof vi.fn>;
|
||||
runGitCommand?: ReturnType<typeof vi.fn>;
|
||||
getWorkspaceGitSelfHealPhaseMs?: (cwd: string) => number;
|
||||
workspaceFolderExists?: (cwd: string) => Promise<boolean>;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
@@ -301,7 +294,6 @@ function buildDefaultTestServiceDeps() {
|
||||
signal: null,
|
||||
})),
|
||||
getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000),
|
||||
workspaceFolderExists: vi.fn(async () => true),
|
||||
createWatcherLivenessCanary: vi.fn(() => ({
|
||||
path: "",
|
||||
filterEvents: (events) => events,
|
||||
@@ -332,145 +324,6 @@ function createService(options?: CreateServiceTestOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
type WatchCallback = (error: Error | null, events: Array<{ path: string; type: "update" }>) => void;
|
||||
|
||||
interface FolderBackedGitOptions {
|
||||
facts?: (cwd: string) => CheckoutSnapshotFacts;
|
||||
unwatchableDirectories?: string[];
|
||||
heldWatch?: { directory: string; release: Promise<void> };
|
||||
}
|
||||
|
||||
function createFolderBackedGit(options?: FolderBackedGitOptions) {
|
||||
const watchCallbacks = new Map<string, WatchCallback>();
|
||||
const subscribe = vi.fn(async (directory: string, callback: WatchCallback) => {
|
||||
if (!existsSync(directory) || options?.unwatchableDirectories?.includes(directory)) {
|
||||
throw new Error(`Cannot watch ${directory}`);
|
||||
}
|
||||
if (options?.heldWatch?.directory === directory) {
|
||||
await options.heldWatch.release;
|
||||
}
|
||||
watchCallbacks.set(directory, callback);
|
||||
return createAsyncSubscription();
|
||||
});
|
||||
const getCheckoutSnapshotFacts = vi.fn(
|
||||
async (cwd: string): Promise<CheckoutSnapshotFacts> =>
|
||||
existsSync(cwd) ? (options?.facts ?? createCheckoutSnapshotFacts)(cwd) : { isGit: false },
|
||||
);
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) =>
|
||||
existsSync(cwd) ? createCheckoutStatus(cwd) : ({ isGit: false } as const),
|
||||
);
|
||||
const getCheckoutShortstat = vi.fn(async (_cwd: string) => ({ additions: 1, deletions: 0 }));
|
||||
const getCheckoutWorktreeState = vi.fn(async (_cwd: string) => ({
|
||||
isDirty: false,
|
||||
diffStat: { additions: 1, deletions: 0 },
|
||||
}));
|
||||
const runGitCommand = vi.fn(async (args: string[], runOptions: { cwd: string }) => ({
|
||||
stdout: args[0] === "rev-parse" ? `${runOptions.cwd}\n` : "",
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
}));
|
||||
const runGitFetch = vi.fn(async (_cwd: string) => ({ changes: [], error: null }));
|
||||
const callsIn = (fake: ReturnType<typeof vi.fn>, cwd: string) =>
|
||||
fake.mock.calls.filter(([calledCwd]) => calledCwd === cwd).length;
|
||||
|
||||
return {
|
||||
deps: {
|
||||
subscribe,
|
||||
getCheckoutSnapshotFacts,
|
||||
getCheckoutStatus,
|
||||
getCheckoutShortstat,
|
||||
getCheckoutWorktreeState,
|
||||
runGitCommand,
|
||||
runGitFetch,
|
||||
workspaceFolderExists: async (cwd: string) => existsSync(cwd),
|
||||
now: () => new Date(),
|
||||
},
|
||||
watchCallbackFor(directory: string): WatchCallback {
|
||||
const callback = watchCallbacks.get(directory);
|
||||
if (!callback) {
|
||||
throw new Error(`No watcher was started for ${directory}`);
|
||||
}
|
||||
return callback;
|
||||
},
|
||||
watchAttemptsFor: (directory: string) => callsIn(subscribe, directory),
|
||||
pollsOf: (cwd: string) => callsIn(getCheckoutShortstat, cwd),
|
||||
fetchesIn: (cwd: string) => callsIn(runGitFetch, cwd),
|
||||
gitCommandsIn(cwd: string): number {
|
||||
const cwdCommands = [
|
||||
getCheckoutSnapshotFacts,
|
||||
getCheckoutStatus,
|
||||
getCheckoutShortstat,
|
||||
getCheckoutWorktreeState,
|
||||
runGitFetch,
|
||||
].reduce((count, fake) => count + callsIn(fake, cwd), 0);
|
||||
const commandsRunInCwd = runGitCommand.mock.calls.filter(
|
||||
([, runOptions]) => runOptions.cwd === cwd,
|
||||
).length;
|
||||
return cwdCommands + commandsRunInCwd;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function linkedWorktreeFacts(gitCommonDir: string) {
|
||||
return (cwd: string): CheckoutSnapshotFacts => ({
|
||||
...createCheckoutSnapshotFacts(cwd),
|
||||
absoluteGitDir: join(gitCommonDir, "worktrees", path.basename(cwd)),
|
||||
gitCommonDir,
|
||||
});
|
||||
}
|
||||
|
||||
const SETTLE_ATTEMPTS = 1_000;
|
||||
|
||||
async function settle(assertion: () => void): Promise<void> {
|
||||
let lastFailure: unknown = null;
|
||||
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
assertion();
|
||||
return;
|
||||
} catch (failure) {
|
||||
lastFailure = failure;
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
}
|
||||
throw lastFailure;
|
||||
}
|
||||
|
||||
async function waitForRefreshesToSettle(service: WorkspaceGitServiceImpl): Promise<void> {
|
||||
await settle(() => {
|
||||
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForObservationToSettle(
|
||||
service: WorkspaceGitServiceImpl,
|
||||
repositoryWorkspaceLinkCount: number,
|
||||
): Promise<void> {
|
||||
await settle(() => {
|
||||
const metrics = service.getMetrics();
|
||||
expect(metrics.repositoryWorkspaceLinkCount).toBe(repositoryWorkspaceLinkCount);
|
||||
expect(metrics.workspaceObservationSetupInFlightCount).toBe(0);
|
||||
expect(metrics.workspaceRefreshInFlightCount).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
async function advanceThroughDegradedPolls(service: WorkspaceGitServiceImpl): Promise<void> {
|
||||
for (const delayMs of [5_000, 10_000, 20_000, 40_000, 60_000, 60_000]) {
|
||||
await vi.advanceTimersByTimeAsync(delayMs);
|
||||
await waitForRefreshesToSettle(service);
|
||||
}
|
||||
}
|
||||
|
||||
async function advanceThroughWorkingTreeRecoveryAttempts(
|
||||
service: WorkspaceGitServiceImpl,
|
||||
): Promise<void> {
|
||||
for (const delayMs of [30_000, 60_000, 120_000]) {
|
||||
await vi.advanceTimersByTimeAsync(delayMs);
|
||||
await waitForRefreshesToSettle(service);
|
||||
}
|
||||
}
|
||||
|
||||
describe("WorkspaceGitServiceImpl", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
@@ -1700,460 +1553,3 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
service.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceGitServiceImpl with workspace folders that no longer exist", () => {
|
||||
const temporaryFolders: string[] = [];
|
||||
|
||||
async function createTemporaryFolder(): Promise<string> {
|
||||
const folder = await mkdtemp(join(os.tmpdir(), "paseo-workspace-git-"));
|
||||
temporaryFolders.push(folder);
|
||||
return folder;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await Promise.all(
|
||||
temporaryFolders.splice(0).map((folder) => rm(folder, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
test("polling stops for a workspace whose folder no longer exists", async () => {
|
||||
const workspace = await createTemporaryFolder();
|
||||
const git = createFolderBackedGit();
|
||||
const service = createService(git.deps);
|
||||
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||
await waitForObservationToSettle(service, 1);
|
||||
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
const gitCommandsAtDeletion = git.gitCommandsIn(workspace);
|
||||
await advanceThroughDegradedPolls(service);
|
||||
|
||||
expect(git.gitCommandsIn(workspace)).toBe(gitCommandsAtDeletion);
|
||||
expect(service.getMetrics()).toMatchObject({
|
||||
repositoryTargetCount: 0,
|
||||
workingTreeWatchTargetCount: 0,
|
||||
});
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
subscription.unsubscribe();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test("a deleted worktree leaves its shared repository polling and fetch to the surviving worktree", async () => {
|
||||
const gitCommonDir = join(await createTemporaryFolder(), ".git");
|
||||
const deletedWorktree = await createTemporaryFolder();
|
||||
const survivingWorktree = await createTemporaryFolder();
|
||||
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
|
||||
let survivingWorktreeEdits = 0;
|
||||
git.deps.getCheckoutShortstat.mockImplementation(async (cwd: string) => ({
|
||||
additions: cwd === survivingWorktree ? ++survivingWorktreeEdits : 1,
|
||||
deletions: 0,
|
||||
}));
|
||||
const service = createService(git.deps);
|
||||
const deletedSubscription = service.registerWorkspace({ cwd: deletedWorktree }, vi.fn());
|
||||
await waitForObservationToSettle(service, 1);
|
||||
const survivingSubscription = service.registerWorkspace({ cwd: survivingWorktree }, vi.fn());
|
||||
await waitForObservationToSettle(service, 2);
|
||||
|
||||
await rm(deletedWorktree, { recursive: true, force: true });
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await waitForRefreshesToSettle(service);
|
||||
const deletedWorktreeCommands = git.gitCommandsIn(deletedWorktree);
|
||||
|
||||
for (let tick = 0; tick < 3; tick += 1) {
|
||||
const survivingWorktreePolls = git.pollsOf(survivingWorktree);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await settle(() => {
|
||||
expect(git.pollsOf(survivingWorktree)).toBe(survivingWorktreePolls + 1);
|
||||
});
|
||||
}
|
||||
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
|
||||
|
||||
const fetchesBefore = git.deps.runGitFetch.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(2 * BACKGROUND_GIT_FETCH_INTERVAL_MS);
|
||||
expect(git.deps.runGitFetch.mock.calls.slice(fetchesBefore).map(([cwd]) => cwd)).toEqual([
|
||||
survivingWorktree,
|
||||
survivingWorktree,
|
||||
]);
|
||||
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
|
||||
|
||||
deletedSubscription.unsubscribe();
|
||||
survivingSubscription.unsubscribe();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test("deleting every worktree of a repository stops its polling and background fetch", async () => {
|
||||
const gitCommonDir = join(await createTemporaryFolder(), ".git");
|
||||
const firstWorktree = await createTemporaryFolder();
|
||||
const secondWorktree = await createTemporaryFolder();
|
||||
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
|
||||
const service = createService(git.deps);
|
||||
const firstSubscription = service.registerWorkspace({ cwd: firstWorktree }, vi.fn());
|
||||
await waitForObservationToSettle(service, 1);
|
||||
const secondSubscription = service.registerWorkspace({ cwd: secondWorktree }, vi.fn());
|
||||
await waitForObservationToSettle(service, 2);
|
||||
|
||||
await rm(firstWorktree, { recursive: true, force: true });
|
||||
await rm(secondWorktree, { recursive: true, force: true });
|
||||
const commandsAtDeletion = [
|
||||
git.gitCommandsIn(firstWorktree),
|
||||
git.gitCommandsIn(secondWorktree),
|
||||
];
|
||||
await advanceThroughDegradedPolls(service);
|
||||
|
||||
expect([git.gitCommandsIn(firstWorktree), git.gitCommandsIn(secondWorktree)]).toEqual(
|
||||
commandsAtDeletion,
|
||||
);
|
||||
expect(service.getMetrics()).toMatchObject({
|
||||
repositoryTargetCount: 0,
|
||||
workingTreeWatchTargetCount: 0,
|
||||
});
|
||||
expect(vi.getTimerCount()).toBe(2);
|
||||
|
||||
firstSubscription.unsubscribe();
|
||||
secondSubscription.unsubscribe();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test("working tree fallback polling and watcher recovery stop for a workspace whose folder no longer exists", async () => {
|
||||
const workspace = await createTemporaryFolder();
|
||||
await mkdir(join(workspace, ".git"));
|
||||
const git = createFolderBackedGit();
|
||||
const service = createService(git.deps);
|
||||
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||
await waitForObservationToSettle(service, 1);
|
||||
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
git.watchCallbackFor(workspace)(new Error("watched directory was deleted"), []);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await waitForRefreshesToSettle(service);
|
||||
const commandsAfterWatcherError = git.gitCommandsIn(workspace);
|
||||
const watchAttemptsAfterWatcherError = git.watchAttemptsFor(workspace);
|
||||
await advanceThroughDegradedPolls(service);
|
||||
|
||||
expect(git.gitCommandsIn(workspace)).toBe(commandsAfterWatcherError);
|
||||
expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
subscription.unsubscribe();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test("a workspace whose folder comes back resumes polling on its next subscription", async () => {
|
||||
const workspace = await createTemporaryFolder();
|
||||
const git = createFolderBackedGit();
|
||||
const service = createService(git.deps);
|
||||
const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||
await waitForObservationToSettle(service, 1);
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
const commandsWhileMissing = git.gitCommandsIn(workspace);
|
||||
await advanceThroughDegradedPolls(service);
|
||||
expect(git.gitCommandsIn(workspace)).toBe(commandsWhileMissing);
|
||||
|
||||
await mkdir(workspace);
|
||||
const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||
await waitForObservationToSettle(service, 1);
|
||||
const pollsAfterResume = git.pollsOf(workspace);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await waitForRefreshesToSettle(service);
|
||||
|
||||
expect(git.pollsOf(workspace)).toBe(pollsAfterResume + 1);
|
||||
|
||||
firstSubscription.unsubscribe();
|
||||
secondSubscription.unsubscribe();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test("a suspended workspace costs no git while its folder is missing, even when a refresh is requested", async () => {
|
||||
const workspace = await createTemporaryFolder();
|
||||
const git = createFolderBackedGit();
|
||||
const service = createService(git.deps);
|
||||
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||
await waitForObservationToSettle(service, 1);
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await advanceThroughDegradedPolls(service);
|
||||
const commandsWhileMissing = git.gitCommandsIn(workspace);
|
||||
|
||||
for (let probe = 0; probe < 10; probe += 1) {
|
||||
await vi.advanceTimersByTimeAsync(WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS);
|
||||
await waitForRefreshesToSettle(service);
|
||||
}
|
||||
service.scheduleRefreshForCwd(workspace);
|
||||
service.onWorkspaceStateMayHaveChanged(workspace);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await waitForRefreshesToSettle(service);
|
||||
|
||||
expect(git.gitCommandsIn(workspace)).toBe(commandsWhileMissing);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
subscription.unsubscribe();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test("a suspended worktree resumes polling and its repository fetch once its folder is back, without a new subscription", async () => {
|
||||
const gitCommonDir = join(await createTemporaryFolder(), ".git");
|
||||
const worktree = await createTemporaryFolder();
|
||||
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
|
||||
const service = createService(git.deps);
|
||||
const subscription = service.registerWorkspace({ cwd: worktree }, vi.fn());
|
||||
await waitForObservationToSettle(service, 1);
|
||||
await rm(worktree, { recursive: true, force: true });
|
||||
await advanceThroughDegradedPolls(service);
|
||||
await waitForObservationToSettle(service, 0);
|
||||
|
||||
await mkdir(worktree);
|
||||
const fetchesBeforeRestore = git.fetchesIn(worktree);
|
||||
await vi.advanceTimersByTimeAsync(WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS);
|
||||
await waitForObservationToSettle(service, 1);
|
||||
expect(git.fetchesIn(worktree)).toBe(fetchesBeforeRestore + 1);
|
||||
|
||||
const pollsAfterRestore = git.pollsOf(worktree);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await waitForRefreshesToSettle(service);
|
||||
expect(git.pollsOf(worktree)).toBe(pollsAfterRestore + 1);
|
||||
|
||||
subscription.unsubscribe();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test("a forced snapshot resumes a suspended workspace whose folder is back", async () => {
|
||||
const workspace = await createTemporaryFolder();
|
||||
const git = createFolderBackedGit();
|
||||
const service = createService(git.deps);
|
||||
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||
await waitForObservationToSettle(service, 1);
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await advanceThroughDegradedPolls(service);
|
||||
await waitForObservationToSettle(service, 0);
|
||||
|
||||
await mkdir(workspace);
|
||||
const snapshot = await service.getSnapshot(workspace, {
|
||||
force: true,
|
||||
reason: "refresh-button",
|
||||
});
|
||||
await waitForObservationToSettle(service, 1);
|
||||
|
||||
expect(snapshot.git.isGit).toBe(true);
|
||||
|
||||
subscription.unsubscribe();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test("a resumed workspace rebuilds observation from fresh facts, not the non-git facts cached while its folder was gone", async () => {
|
||||
const workspace = await createTemporaryFolder();
|
||||
const git = createFolderBackedGit();
|
||||
const service = createService(git.deps);
|
||||
const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||
await waitForObservationToSettle(service, 1);
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
service.onWorkspaceStateMayHaveChanged(workspace);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await waitForRefreshesToSettle(service);
|
||||
expect((await service.getSnapshot(workspace)).git.isGit).toBe(false);
|
||||
await advanceThroughDegradedPolls(service);
|
||||
await waitForObservationToSettle(service, 0);
|
||||
|
||||
await mkdir(workspace);
|
||||
const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||
await waitForObservationToSettle(service, 1);
|
||||
|
||||
expect((await service.getSnapshot(workspace)).git.isGit).toBe(true);
|
||||
|
||||
firstSubscription.unsubscribe();
|
||||
secondSubscription.unsubscribe();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test("resuming waits for an observation setup that was still running when the folder vanished", async () => {
|
||||
const workspace = await createTemporaryFolder();
|
||||
const gitDir = join(workspace, ".git");
|
||||
await mkdir(gitDir);
|
||||
const metadataWatchRelease = createDeferred<void>();
|
||||
const git = createFolderBackedGit({
|
||||
unwatchableDirectories: [workspace],
|
||||
heldWatch: { directory: gitDir, release: metadataWatchRelease.promise },
|
||||
});
|
||||
const service = createService(git.deps);
|
||||
const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||
await settle(() => {
|
||||
expect(git.watchAttemptsFor(gitDir)).toBe(1);
|
||||
});
|
||||
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await settle(() => {
|
||||
expect(service.getMetrics()).toMatchObject({
|
||||
repositoryTargetCount: 0,
|
||||
workingTreeWatchTargetCount: 0,
|
||||
});
|
||||
});
|
||||
await mkdir(workspace);
|
||||
const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||
metadataWatchRelease.resolve();
|
||||
await waitForObservationToSettle(service, 1);
|
||||
|
||||
expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1);
|
||||
|
||||
firstSubscription.unsubscribe();
|
||||
secondSubscription.unsubscribe();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test("an open working tree watch neither notifies nor recovers while its folder is missing, and picks up once it is back", async () => {
|
||||
const workspace = await createTemporaryFolder();
|
||||
await mkdir(join(workspace, ".git"));
|
||||
const git = createFolderBackedGit();
|
||||
const service = createService(git.deps);
|
||||
const workspaceSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
||||
await waitForObservationToSettle(service, 1);
|
||||
const diffListener = vi.fn();
|
||||
const diffWatch = await service.requestWorkingTreeWatch(workspace, diffListener);
|
||||
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
git.watchCallbackFor(workspace)(new Error("watched directory was deleted"), []);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await waitForRefreshesToSettle(service);
|
||||
const commandsAfterWatcherError = git.gitCommandsIn(workspace);
|
||||
const notificationsAfterWatcherError = diffListener.mock.calls.length;
|
||||
const watchAttemptsAfterWatcherError = git.watchAttemptsFor(workspace);
|
||||
await advanceThroughWorkingTreeRecoveryAttempts(service);
|
||||
|
||||
expect(git.gitCommandsIn(workspace)).toBe(commandsAfterWatcherError);
|
||||
expect(diffListener.mock.calls.length).toBe(notificationsAfterWatcherError);
|
||||
expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError);
|
||||
expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1);
|
||||
|
||||
await mkdir(join(workspace, ".git"), { recursive: true });
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await waitForRefreshesToSettle(service);
|
||||
expect(diffListener.mock.calls.length).toBe(notificationsAfterWatcherError + 1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(240_000);
|
||||
await waitForRefreshesToSettle(service);
|
||||
expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError + 1);
|
||||
const notificationsAfterResubscribe = diffListener.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await waitForRefreshesToSettle(service);
|
||||
expect(diffListener.mock.calls.length).toBe(notificationsAfterResubscribe);
|
||||
|
||||
diffWatch.unsubscribe();
|
||||
workspaceSubscription.unsubscribe();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test("a refresh crossing suspension preserves the cached snapshot and drops queued work", async () => {
|
||||
const workspace = await createTemporaryFolder();
|
||||
const git = createFolderBackedGit();
|
||||
const started = createDeferred<void>();
|
||||
const release = createDeferred<void>();
|
||||
let holdNextRead = false;
|
||||
let factsReads = 0;
|
||||
const service = createService({
|
||||
...git.deps,
|
||||
getCheckoutSnapshotFacts: async (cwd: string) => {
|
||||
factsReads++;
|
||||
if (holdNextRead) {
|
||||
holdNextRead = false;
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
}
|
||||
return git.deps.getCheckoutSnapshotFacts(cwd);
|
||||
},
|
||||
});
|
||||
const updates: WorkspaceGitRuntimeSnapshot[] = [];
|
||||
const subscription = service.registerWorkspace({ cwd: workspace }, (snapshot) =>
|
||||
updates.push(snapshot),
|
||||
);
|
||||
await waitForObservationToSettle(service, 1);
|
||||
const cached = await service.getSnapshot(workspace);
|
||||
holdNextRead = true;
|
||||
const active = service.getSnapshot(workspace, {
|
||||
force: true,
|
||||
includeForge: false,
|
||||
reason: "overlapping-refresh",
|
||||
});
|
||||
await started.promise;
|
||||
const queued = service.getSnapshot(workspace, {
|
||||
force: true,
|
||||
includeForge: true,
|
||||
reason: "queued-refresh",
|
||||
});
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await settle(() => expect(service.getMetrics().repositoryWorkspaceLinkCount).toBe(0));
|
||||
const readsAtSuspension = factsReads;
|
||||
const updatesAtSuspension = updates.length;
|
||||
release.resolve();
|
||||
expect(await active).toEqual(cached);
|
||||
expect(await queued).toEqual(cached);
|
||||
expect(await service.getSnapshot(workspace)).toEqual(cached);
|
||||
expect(updates).toHaveLength(updatesAtSuspension);
|
||||
expect(factsReads).toBe(readsAtSuspension);
|
||||
subscription.unsubscribe();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test("restoration settles a suspended refresh before reloading facts", async () => {
|
||||
const workspace = await createTemporaryFolder();
|
||||
const git = createFolderBackedGit();
|
||||
const staleFacts = createDeferred<CheckoutSnapshotFacts>();
|
||||
const started = createDeferred<void>();
|
||||
let holdNextRead = false;
|
||||
const service = createService({
|
||||
...git.deps,
|
||||
getCheckoutSnapshotFacts: async (cwd: string) => {
|
||||
if (holdNextRead) {
|
||||
holdNextRead = false;
|
||||
started.resolve();
|
||||
return staleFacts.promise;
|
||||
}
|
||||
return git.deps.getCheckoutSnapshotFacts(cwd);
|
||||
},
|
||||
});
|
||||
const updates: WorkspaceGitRuntimeSnapshot[] = [];
|
||||
const subscription = service.registerWorkspace({ cwd: workspace }, (snapshot) =>
|
||||
updates.push(snapshot),
|
||||
);
|
||||
await waitForObservationToSettle(service, 1);
|
||||
const cached = await service.getSnapshot(workspace);
|
||||
holdNextRead = true;
|
||||
const active = service.getSnapshot(workspace, {
|
||||
force: true,
|
||||
includeForge: false,
|
||||
reason: "overlapping-refresh",
|
||||
});
|
||||
await started.promise;
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await settle(() => expect(service.getMetrics().repositoryWorkspaceLinkCount).toBe(0));
|
||||
const updatesAtSuspension = updates.length;
|
||||
await mkdir(workspace);
|
||||
const restored = service.getSnapshot(workspace, {
|
||||
force: true,
|
||||
includeForge: false,
|
||||
reason: "folder-returned",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
staleFacts.resolve({ isGit: false });
|
||||
expect(await active).toEqual(cached);
|
||||
expect((await restored).git.isGit).toBe(true);
|
||||
expect(updates.slice(updatesAtSuspension).every((snapshot) => snapshot.git.isGit)).toBe(true);
|
||||
await waitForObservationToSettle(service, 1);
|
||||
subscription.unsubscribe();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test("workspaceFolderExists reports a missing folder and a path under a file as missing", async () => {
|
||||
const folder = await createTemporaryFolder();
|
||||
const file = join(folder, "file.txt");
|
||||
await writeFile(file, "");
|
||||
|
||||
expect(await workspaceFolderExists(folder)).toBe(true);
|
||||
expect(await workspaceFolderExists(join(folder, "missing"))).toBe(false);
|
||||
expect(await workspaceFolderExists(join(file, "nested"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { LRUCache } from "lru-cache";
|
||||
import { CheckoutDiffCache } from "./checkout-diff-cache.js";
|
||||
@@ -74,7 +74,7 @@ import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
|
||||
import { createWatcherLivenessCanary } from "./watcher-liveness-canary.js";
|
||||
|
||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
|
||||
export const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
||||
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
||||
const FETCH_METADATA_ECHO_TTL_MS = 5_000;
|
||||
export const WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS = 60_000;
|
||||
const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
|
||||
@@ -384,7 +384,6 @@ interface WorkspaceGitServiceDependencies {
|
||||
runGitCommand: typeof runGitCommand;
|
||||
getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitObservationReensurePhaseMs;
|
||||
createWatcherLivenessCanary: typeof createWatcherLivenessCanary;
|
||||
workspaceFolderExists: (cwd: string) => Promise<boolean>;
|
||||
now: () => Date;
|
||||
}
|
||||
|
||||
@@ -436,8 +435,6 @@ interface WorkspaceGitTarget {
|
||||
repoGitRoot: string | null;
|
||||
observationSetupPromise: Promise<void> | null;
|
||||
observationSetupComplete: boolean;
|
||||
folderMissing: boolean;
|
||||
folderRestorePromise: Promise<void> | null;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
@@ -574,21 +571,10 @@ function buildDefaultWorkspaceGitServiceDeps(
|
||||
runGitCommand,
|
||||
getWorkspaceGitSelfHealPhaseMs: getWorkspaceGitObservationReensurePhaseMs,
|
||||
createWatcherLivenessCanary,
|
||||
workspaceFolderExists,
|
||||
now: () => new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function workspaceFolderExists(cwd: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(cwd);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return code !== "ENOENT" && code !== "ENOTDIR";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorkspaceGitServiceDeps(
|
||||
subscribe: SubscribeToFileChanges,
|
||||
deps: Partial<WorkspaceGitServiceDependencies> | undefined,
|
||||
@@ -674,9 +660,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
const cwd = resolve(params.cwd);
|
||||
const target = this.ensureWorkspaceTarget(cwd);
|
||||
target.listeners.add(listener);
|
||||
if (target.folderMissing) {
|
||||
void this.resumeWorkspaceIfFolderRestored(target);
|
||||
}
|
||||
if (target.listeners.size === 1) {
|
||||
this.startWorkspaceSubscriptionTimers(target);
|
||||
}
|
||||
@@ -768,9 +751,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
if (!request.force && target.latestSnapshot) {
|
||||
return target.latestSnapshot;
|
||||
}
|
||||
if (target.folderMissing) {
|
||||
await this.resumeWorkspaceIfFolderRestored(target);
|
||||
}
|
||||
|
||||
return this.requestWorkspaceSnapshot(target, request);
|
||||
}
|
||||
@@ -992,9 +972,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
this.assertNotDisposed();
|
||||
cwd = resolve(cwd);
|
||||
const target = this.ensureWorkspaceTarget(cwd);
|
||||
if (target.folderMissing) {
|
||||
await this.resumeWorkspaceIfFolderRestored(target);
|
||||
}
|
||||
await this.refreshWorkspaceTarget(target, {
|
||||
force: false,
|
||||
refreshStructure: true,
|
||||
@@ -1028,14 +1005,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
this.assertNotDisposed();
|
||||
cwd = resolve(cwd);
|
||||
const target = this.workspaceTargets.get(cwd);
|
||||
if (!target) {
|
||||
return;
|
||||
if (target) {
|
||||
this.scheduleWorkspaceRefresh(target, { queueIfBusy: false });
|
||||
}
|
||||
if (target.folderMissing) {
|
||||
void this.resumeWorkspaceIfFolderRestored(target);
|
||||
return;
|
||||
}
|
||||
this.scheduleWorkspaceRefresh(target, { queueIfBusy: false });
|
||||
}
|
||||
|
||||
onWorkspaceStateMayHaveChanged(cwd: string): void {
|
||||
@@ -1046,10 +1018,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return;
|
||||
}
|
||||
this.invalidateForge(normalizedCwd);
|
||||
if (target.folderMissing) {
|
||||
void this.resumeWorkspaceIfFolderRestored(target);
|
||||
return;
|
||||
}
|
||||
this.scheduleWorkspaceRefresh(target, {
|
||||
force: true,
|
||||
includeForge: true,
|
||||
@@ -1259,8 +1227,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
repoGitRoot: null,
|
||||
observationSetupPromise: null,
|
||||
observationSetupComplete: false,
|
||||
folderMissing: false,
|
||||
folderRestorePromise: null,
|
||||
closed: false,
|
||||
};
|
||||
|
||||
@@ -1392,7 +1358,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
private isActiveObservedWorkspaceTarget(target: WorkspaceGitTarget): boolean {
|
||||
return (
|
||||
!target.closed &&
|
||||
!target.folderMissing &&
|
||||
target.listeners.size > 0 &&
|
||||
this.workspaceTargets.get(target.cwd) === target
|
||||
);
|
||||
@@ -1647,110 +1612,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
workspaceTarget: WorkspaceGitTarget,
|
||||
request: WorkspaceGitRefreshRequest,
|
||||
): Promise<boolean> {
|
||||
if (await this.suspendWorkspaceIfFolderMissing(workspaceTarget)) {
|
||||
return false;
|
||||
}
|
||||
const fingerprintBefore = workspaceTarget.latestFingerprint;
|
||||
await this.refreshWorkspaceTarget(workspaceTarget, request);
|
||||
return workspaceTarget.latestFingerprint !== fingerprintBefore;
|
||||
}
|
||||
|
||||
private async suspendWorkspaceIfFolderMissing(target: WorkspaceGitTarget): Promise<boolean> {
|
||||
if (target.folderMissing) {
|
||||
return true;
|
||||
}
|
||||
if (await this.deps.workspaceFolderExists(target.cwd)) {
|
||||
return false;
|
||||
}
|
||||
this.suspendWorkspaceWithMissingFolder(target);
|
||||
return true;
|
||||
}
|
||||
|
||||
private suspendWorkspaceWithMissingFolder(target: WorkspaceGitTarget): void {
|
||||
if (target.folderMissing || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||
return;
|
||||
}
|
||||
target.folderMissing = true;
|
||||
if (target.refreshState.status === "in-flight") {
|
||||
target.refreshState.queued = null;
|
||||
}
|
||||
this.detachWorkspaceFromRepoTarget(target);
|
||||
target.repoGitRoot = null;
|
||||
this.stopWorkspaceObservation(target);
|
||||
target.observationSetupComplete = false;
|
||||
this.scheduleMissingFolderProbe(target);
|
||||
this.logger.warn(
|
||||
{ cwd: target.cwd },
|
||||
"Workspace folder no longer exists; suspended git observation until it is back",
|
||||
);
|
||||
}
|
||||
|
||||
private isSuspendedWorkspaceTarget(target: WorkspaceGitTarget): boolean {
|
||||
return (
|
||||
target.folderMissing && !target.closed && this.workspaceTargets.get(target.cwd) === target
|
||||
);
|
||||
}
|
||||
|
||||
private scheduleMissingFolderProbe(target: WorkspaceGitTarget): void {
|
||||
if (
|
||||
!this.isSuspendedWorkspaceTarget(target) ||
|
||||
target.observationReensureTimer ||
|
||||
target.folderRestorePromise
|
||||
) {
|
||||
return;
|
||||
}
|
||||
target.observationReensureTimer = setTimeout(() => {
|
||||
target.observationReensureTimer = null;
|
||||
void this.resumeWorkspaceIfFolderRestored(target);
|
||||
}, WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private resumeWorkspaceIfFolderRestored(target: WorkspaceGitTarget): Promise<void> {
|
||||
if (!target.folderMissing) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
target.folderRestorePromise ??= this.resumeWorkspaceWithRestoredFolder(target).finally(() => {
|
||||
target.folderRestorePromise = null;
|
||||
this.scheduleMissingFolderProbe(target);
|
||||
});
|
||||
return target.folderRestorePromise;
|
||||
}
|
||||
|
||||
private async resumeWorkspaceWithRestoredFolder(target: WorkspaceGitTarget): Promise<void> {
|
||||
if (!(await this.deps.workspaceFolderExists(target.cwd))) {
|
||||
return;
|
||||
}
|
||||
await target.observationSetupPromise;
|
||||
if (target.refreshState.status === "in-flight") {
|
||||
await target.refreshState.promise.catch(() => undefined);
|
||||
}
|
||||
if (!this.isSuspendedWorkspaceTarget(target)) {
|
||||
return;
|
||||
}
|
||||
target.latestFacts = null;
|
||||
target.folderMissing = false;
|
||||
await this.refreshWorkspaceTarget(target, {
|
||||
force: false,
|
||||
refreshStructure: true,
|
||||
refreshWorktree: true,
|
||||
includeForge: false,
|
||||
reason: "workspace-folder-restored",
|
||||
queueIfBusy: true,
|
||||
movedRemoteRefs: new Set(),
|
||||
});
|
||||
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||
return;
|
||||
}
|
||||
target.observationSetupComplete = false;
|
||||
if (target.observationReensureTimer) {
|
||||
clearTimeout(target.observationReensureTimer);
|
||||
target.observationReensureTimer = null;
|
||||
}
|
||||
this.logger.info({ cwd: target.cwd }, "Workspace folder is back; resumed git observation");
|
||||
this.startWorkspaceSubscriptionTimers(target);
|
||||
this.scheduleWorkspaceObservationSetup(target);
|
||||
}
|
||||
|
||||
private startWorkingTreeWatchFallback(
|
||||
target: WorkingTreeWatchTarget,
|
||||
reason: WorkingTreeWatchFallbackReason,
|
||||
@@ -1788,9 +1654,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return changed;
|
||||
}),
|
||||
);
|
||||
if (await this.deps.workspaceFolderExists(target.watchPath)) {
|
||||
this.notifyWorkingTreeConsumers(target);
|
||||
}
|
||||
this.notifyWorkingTreeConsumers(target);
|
||||
if (!target.closed && (target.subscription === null || target.repoRoot === null)) {
|
||||
target.fallbackPollQuietTickCount = changes.some(Boolean)
|
||||
? 0
|
||||
@@ -1867,10 +1731,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
if (target.closed || target.subscription) {
|
||||
return;
|
||||
}
|
||||
if (!(await this.deps.workspaceFolderExists(target.watchPath))) {
|
||||
this.scheduleWorkingTreeWatchRecovery(target);
|
||||
return;
|
||||
}
|
||||
await this.refreshWorkingTreeIgnoredDirectories(target);
|
||||
if (target.closed || target.subscription) {
|
||||
return;
|
||||
@@ -2926,7 +2786,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
|
||||
private updateForgePrStatusPollForTarget(target: WorkspaceGitTarget): void {
|
||||
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
||||
if (target.listeners.size === 0) {
|
||||
this.stopForgePrStatusPollForTarget(target);
|
||||
return;
|
||||
}
|
||||
@@ -3189,9 +3049,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
target: WorkspaceGitTarget,
|
||||
request: WorkspaceGitRefreshRequest,
|
||||
): Promise<WorkspaceGitRuntimeSnapshot> {
|
||||
if (target.folderMissing) {
|
||||
return Promise.resolve(target.latestSnapshot ?? buildNotGitSnapshot(target.cwd));
|
||||
}
|
||||
if (target.refreshState.status === "in-flight") {
|
||||
const active = target.refreshState.request;
|
||||
const addsWork =
|
||||
@@ -3334,19 +3191,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
try {
|
||||
const runRefreshGitCommand = createRunGitCommand(`workspace-refresh:${request.reason}`);
|
||||
const admittedSnapshot = await this.workspaceRefreshLimit(() => {
|
||||
if (
|
||||
target.closed ||
|
||||
target.folderMissing ||
|
||||
this.workspaceTargets.get(target.cwd) !== target
|
||||
) {
|
||||
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||
return null;
|
||||
}
|
||||
return this.refreshSnapshot(target, request, runRefreshGitCommand);
|
||||
});
|
||||
if (target.folderMissing) {
|
||||
failure = null;
|
||||
break;
|
||||
}
|
||||
if (!admittedSnapshot) {
|
||||
break;
|
||||
}
|
||||
@@ -3360,10 +3209,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
failure = { error };
|
||||
}
|
||||
|
||||
if (target.folderMissing) {
|
||||
failure = null;
|
||||
break;
|
||||
}
|
||||
if (this.disposed || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||
break;
|
||||
}
|
||||
@@ -3659,7 +3504,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
snapshot: WorkspaceGitRuntimeSnapshot,
|
||||
options?: { forceEmit?: boolean },
|
||||
): void {
|
||||
if (target.closed || target.folderMissing || this.workspaceTargets.get(target.cwd) !== target) {
|
||||
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||
return;
|
||||
}
|
||||
target.latestSnapshot = snapshot;
|
||||
@@ -3815,25 +3660,21 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
|
||||
private removeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
||||
this.detachWorkspaceFromRepoTarget(target);
|
||||
if (target.repoGitRoot) {
|
||||
const repoTarget = this.repoTargets.get(target.repoGitRoot);
|
||||
repoTarget?.workspaceKeys.delete(target.cwd);
|
||||
if (repoTarget && repoTarget.workspaceKeys.size === 0) {
|
||||
this.closeRepoTarget(repoTarget);
|
||||
this.repoTargets.delete(target.repoGitRoot);
|
||||
} else if (repoTarget?.cwd === target.cwd) {
|
||||
repoTarget.cwd = repoTarget.workspaceKeys.values().next().value ?? repoTarget.cwd;
|
||||
}
|
||||
}
|
||||
|
||||
this.closeWorkspaceTarget(target);
|
||||
this.workspaceTargets.delete(target.cwd);
|
||||
}
|
||||
|
||||
private detachWorkspaceFromRepoTarget(target: WorkspaceGitTarget): void {
|
||||
if (!target.repoGitRoot) {
|
||||
return;
|
||||
}
|
||||
const repoTarget = this.repoTargets.get(target.repoGitRoot);
|
||||
repoTarget?.workspaceKeys.delete(target.cwd);
|
||||
if (repoTarget && repoTarget.workspaceKeys.size === 0) {
|
||||
this.closeRepoTarget(repoTarget);
|
||||
this.repoTargets.delete(target.repoGitRoot);
|
||||
} else if (repoTarget?.cwd === target.cwd) {
|
||||
repoTarget.cwd = repoTarget.workspaceKeys.values().next().value ?? repoTarget.cwd;
|
||||
}
|
||||
}
|
||||
|
||||
private removeWorkingTreeWatchListener(cwd: string, listener: () => void): void {
|
||||
const target = this.workingTreeWatchTargets.get(cwd);
|
||||
if (!target) {
|
||||
@@ -3878,11 +3719,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
|
||||
private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
||||
target.closed = true;
|
||||
this.stopWorkspaceObservation(target);
|
||||
target.listeners.clear();
|
||||
}
|
||||
|
||||
private stopWorkspaceObservation(target: WorkspaceGitTarget): void {
|
||||
if (target.workingTreeWatchTarget) {
|
||||
this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd);
|
||||
target.workingTreeWatchTarget = null;
|
||||
@@ -3891,12 +3727,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
clearTimeout(target.debounceTimer);
|
||||
target.debounceTimer = null;
|
||||
}
|
||||
target.pendingDebounceRequest = null;
|
||||
if (target.observationReensureTimer) {
|
||||
clearTimeout(target.observationReensureTimer);
|
||||
target.observationReensureTimer = null;
|
||||
}
|
||||
this.stopForgePrStatusPollForTarget(target);
|
||||
target.listeners.clear();
|
||||
}
|
||||
|
||||
private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
@@ -55,7 +54,6 @@ function makeSources(bundleSource = "export {};\n"): {
|
||||
mkdirSync(native, { recursive: true });
|
||||
writeFileSync(path.join(native, "pty.node"), "native");
|
||||
writeFileSync(path.join(native, "spawn-helper"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
chmodSync(path.join(native, "spawn-helper"), 0o755);
|
||||
// Another platform's build is not copied.
|
||||
mkdirSync(path.join(nodePtyDirectory, "prebuilds", "other-platform"));
|
||||
return { bundleDirectory, nodePtyDirectory };
|
||||
|
||||
@@ -410,12 +410,7 @@ describe("createTerminal", () => {
|
||||
cwd: realpathSync(tmpdir()),
|
||||
cols: 500,
|
||||
command: process.execPath,
|
||||
// Keep the PTY alive until the screen assertion; exit disposes its parser.
|
||||
args: [
|
||||
"-e",
|
||||
"process.stdout.write(JSON.stringify(process.argv.slice(1))); setInterval(() => {}, 100000)",
|
||||
nastyPrompt,
|
||||
],
|
||||
args: ["-e", "process.stdout.write(JSON.stringify(process.argv.slice(1)))", nastyPrompt],
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -186,13 +186,7 @@ function insertDocByPath(nodes: DocsNavNode[], doc: Doc, stripTopDirectory = fal
|
||||
|
||||
// A section's top-level directory and its category name the same thing, so
|
||||
// only directories below it become collapsible groups.
|
||||
if (
|
||||
stripTopDirectory &&
|
||||
directories[0] &&
|
||||
doc.frontmatter.category?.toLowerCase().endsWith(directories[0].toLowerCase())
|
||||
) {
|
||||
directories.shift();
|
||||
}
|
||||
if (stripTopDirectory) directories.shift();
|
||||
|
||||
let current = nodes;
|
||||
let parent: Extract<DocsNavNode, { type: "group" }> | undefined;
|
||||
|
||||
@@ -2,25 +2,6 @@ import { describe, expect, it } from "vitest";
|
||||
import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect, parseHeadings } from "./docs";
|
||||
|
||||
describe("plugin documentation", () => {
|
||||
it.each(["en", "zh-CN"] as const)(
|
||||
"nests local file copying under Git worktrees in %s",
|
||||
(locale) => {
|
||||
const workspaces = buildDocsNavTree(getDocs(locale)).find(
|
||||
(node) => node.type === "category" && node.label === "Workspaces",
|
||||
);
|
||||
expect(workspaces).toMatchObject({
|
||||
children: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "group",
|
||||
href: "/docs/worktrees",
|
||||
children: [expect.objectContaining({ href: "/docs/worktrees/worktreeinclude" })],
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(getDoc("worktrees/worktreeinclude", locale)).toBeDefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("groups current guides by task without version navigation", () => {
|
||||
const plugins = buildDocsNavTree(getDocs("en")).find(
|
||||
(node) => node.type === "category" && node.label === "Plugins",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { expect, it } from "vitest";
|
||||
|
||||
const docsRoot = path.resolve(import.meta.dirname, "../../../public-docs");
|
||||
|
||||
it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees/index.md"])(
|
||||
it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees.md"])(
|
||||
"%s makes schedule run retention discoverable",
|
||||
async (file) => {
|
||||
const markdown = await readFile(path.join(docsRoot, file), "utf8");
|
||||
|
||||
@@ -84,7 +84,16 @@ Add `--forge <name>` when Paseo cannot infer the forge from the source checkout.
|
||||
|
||||
## Copy local files with .worktreeinclude
|
||||
|
||||
Use [`.worktreeinclude`](/docs/worktrees/worktreeinclude) to copy selected gitignored files, such as `.env`, from your source checkout before setup runs.
|
||||
Create `.worktreeinclude` at the root of your source checkout to copy selected gitignored files into new worktrees:
|
||||
|
||||
```gitignore
|
||||
.env
|
||||
config/*.local.json
|
||||
```
|
||||
|
||||
Patterns use gitignore syntax. A file must match `.worktreeinclude` and be gitignored to be copied. Tracked files are never copied, and existing destination files are never overwritten. Without `.worktreeinclude`, no local files are copied. Symlinked directories and nested repositories inside an included path cause the copy to fail and abort worktree creation.
|
||||
|
||||
The built-in `worktree-include` plugin copies from the checkout you create the worktree from, including when that checkout is another worktree. Copying finishes before setup, terminals, and the first agent. A copy failure aborts creation and removes the new worktree. Copies use copy-on-write clones on APFS and btrfs when supported, with a regular copy elsewhere.
|
||||
|
||||
## paseo.json
|
||||
|
||||
@@ -82,7 +82,16 @@ paseo workspace create \
|
||||
|
||||
## 使用 .worktreeinclude 复制本地文件 {#copy-local-files-with-worktreeinclude}
|
||||
|
||||
使用 [`.worktreeinclude`](/docs/worktrees/worktreeinclude),在 setup 运行前将 `.env` 等选定的 Git 忽略文件从源检出复制到新 worktree。
|
||||
在源检出的根目录创建 `.worktreeinclude`,将选定的 Git 忽略文件复制到新的 worktree:
|
||||
|
||||
```gitignore
|
||||
.env
|
||||
config/*.local.json
|
||||
```
|
||||
|
||||
模式使用 gitignore 语法。只有同时匹配 `.worktreeinclude` 且被 Git 忽略的文件才会复制。已跟踪的文件不会被复制,目标中已存在的文件不会被覆盖。没有 `.worktreeinclude` 时,不会复制任何本地文件。包含路径中的符号链接目录和嵌套仓库会导致复制失败,并中止 worktree 创建。
|
||||
|
||||
内置的 `worktree-include` 插件从你创建 worktree 时所选的检出复制文件,即使源检出本身也是 worktree。复制会在 setup、终端和第一个 Agent 运行之前完成。复制失败会中止创建并删除新的 worktree。支持时会在 APFS 和 btrfs 上使用写时复制克隆,否则执行普通复制。
|
||||
|
||||
## paseo.json {#paseojson}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
---
|
||||
title: .worktreeinclude
|
||||
description: Copy gitignored local files into new Paseo worktrees.
|
||||
nav: .worktreeinclude
|
||||
order: 12
|
||||
category: Workspaces
|
||||
---
|
||||
|
||||
# .worktreeinclude
|
||||
|
||||
Add `.worktreeinclude` at the root of your source checkout to copy local configuration into new [Git worktrees](/docs/worktrees). Paseo copies matching gitignored files before setup, terminals, and the first Agent start.
|
||||
|
||||
## Copy your local configuration
|
||||
|
||||
For a project whose `.gitignore` includes `.env` and `config/*.local.json`, create this `.worktreeinclude`:
|
||||
|
||||
```gitignore
|
||||
.env
|
||||
config/*.local.json
|
||||
```
|
||||
|
||||
When you create a worktree through Paseo, an existing `.env` and matching files such as `config/dev.local.json` are copied to the same relative paths in the new worktree. Their contents stay out of Git.
|
||||
|
||||
Commit `.worktreeinclude` to share the patterns with your team. Each teammate supplies their own ignored files in the checkout they create worktrees from.
|
||||
|
||||
## Pattern syntax
|
||||
|
||||
Use gitignore syntax, with one pattern per line. Comments start with `#`. Prefix a pattern with `!` to exclude a match:
|
||||
|
||||
```gitignore
|
||||
# Local environment and configuration
|
||||
.env
|
||||
config/*.local.json
|
||||
!config/production.local.json
|
||||
```
|
||||
|
||||
A pattern only selects files that Git already ignores. Adding a path here does not ignore it, so also configure `.gitignore` when needed. Tracked files and untracked files that Git does not ignore are excluded.
|
||||
|
||||
## When files are copied
|
||||
|
||||
The built-in `worktree-include` plugin reads the file from the checkout you create the worktree from. That source can itself be another worktree. Keep the plugin enabled for copying to run.
|
||||
|
||||
- Without `.worktreeinclude`, the plugin copies nothing.
|
||||
- Matching files must exist in the source checkout.
|
||||
- Existing destination files are skipped, preserving their contents.
|
||||
- Copies happen during creation, with no ongoing synchronization between checkouts.
|
||||
- A copy error aborts creation and removes the new worktree. Avoid including symlinked directories or nested repositories, which cannot be copied as files.
|
||||
|
||||
## Run commands during setup
|
||||
|
||||
Use `.worktreeinclude` for files that already exist locally. Use a [setup hook](/docs/worktrees#setup-and-teardown) to install dependencies, generate files, or fetch secrets:
|
||||
|
||||
```json
|
||||
{
|
||||
"worktree": {
|
||||
"setup": "npm ci"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Put this configuration in `paseo.json` on the base branch used for the new worktree. Copied files are available when setup runs.
|
||||
@@ -1,61 +0,0 @@
|
||||
---
|
||||
title: .worktreeinclude
|
||||
description: 将 Git 忽略的本地文件复制到新的 Paseo worktree。
|
||||
nav: .worktreeinclude
|
||||
order: 12
|
||||
category: Workspaces
|
||||
---
|
||||
|
||||
# .worktreeinclude
|
||||
|
||||
在源检出的根目录添加 `.worktreeinclude`,将本地配置复制到新的 [Git worktree](/docs/worktrees)。Paseo 会在 setup、终端和首个 Agent 启动之前复制匹配的 Git 忽略文件。
|
||||
|
||||
## 复制本地配置 {#copy-your-local-configuration}
|
||||
|
||||
如果项目的 `.gitignore` 包含 `.env` 和 `config/*.local.json`,创建以下 `.worktreeinclude`:
|
||||
|
||||
```gitignore
|
||||
.env
|
||||
config/*.local.json
|
||||
```
|
||||
|
||||
通过 Paseo 创建 worktree 时,已有的 `.env` 和 `config/dev.local.json` 等匹配文件会被复制到新 worktree 中的相同相对路径。文件内容仍不会纳入 Git。
|
||||
|
||||
提交 `.worktreeinclude`,与团队共享这些模式。每位团队成员在用于创建 worktree 的检出中提供自己的忽略文件。
|
||||
|
||||
## 模式语法 {#pattern-syntax}
|
||||
|
||||
使用 gitignore 语法,每行一个模式。注释以 `#` 开头。模式前加 `!` 可排除匹配项:
|
||||
|
||||
```gitignore
|
||||
# Local environment and configuration
|
||||
.env
|
||||
config/*.local.json
|
||||
!config/production.local.json
|
||||
```
|
||||
|
||||
模式只会选择 Git 已忽略的文件。在此添加路径不会使 Git 忽略该文件,需要时还应配置 `.gitignore`。已跟踪的文件和未被 Git 忽略的未跟踪文件都不会被复制。
|
||||
|
||||
## 文件复制时机 {#when-files-are-copied}
|
||||
|
||||
内置的 `worktree-include` 插件从用于创建 worktree 的检出读取该文件。源检出也可以是另一个 worktree。保持该插件启用才能执行复制。
|
||||
|
||||
- 没有 `.worktreeinclude` 时,插件不会复制任何文件。
|
||||
- 匹配的文件必须存在于源检出中。
|
||||
- 目标中已存在的文件会被跳过,保留原有内容。
|
||||
- 复制发生在创建期间,之后不会在检出之间持续同步。
|
||||
- 复制错误会中止创建并删除新 worktree。避免包含符号链接目录或嵌套仓库,因为它们无法作为文件复制。
|
||||
|
||||
## 在 setup 期间运行命令 {#run-commands-during-setup}
|
||||
|
||||
使用 `.worktreeinclude` 复制本地已有文件。使用 [setup 钩子](/docs/worktrees#setup-and-teardown) 安装依赖、生成文件或获取密钥:
|
||||
|
||||
```json
|
||||
{
|
||||
"worktree": {
|
||||
"setup": "npm ci"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
将此配置放入新 worktree 所用基准分支的 `paseo.json` 中。setup 运行时,复制的文件已可使用。
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { readFileSync, readdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -679,48 +679,3 @@ test("macOS jobs use latest runners while preserving release architectures", ()
|
||||
// GitHub has no standard macos-latest-intel alias; use the newest Intel image.
|
||||
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-"));
|
||||
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");
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user