mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 22:23:07 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f318bf7cf | ||
|
|
04fe4be40f | ||
|
|
2d75cc80b2 | ||
|
|
bfac132920 | ||
|
|
34f996b32a | ||
|
|
3e83f433da | ||
|
|
674530152e | ||
|
|
919e44b319 | ||
|
|
4fe44038e3 | ||
|
|
9b0542d127 | ||
|
|
8130147426 | ||
|
|
fa89738e3a |
@@ -135,32 +135,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# Backport electron-builder#10172 to the pinned build tool only.
|
|
||||||
# Remove this step when app-builder-lib is upgraded to a release containing it.
|
|
||||||
- name: Repair temporary keychain password in signing tool
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
node <<'KEYCHAIN_FIX'
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const manifestPath = require.resolve('app-builder-lib/package.json');
|
|
||||||
if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') {
|
|
||||||
throw new Error('Reassess/remove the signing backport for the new app-builder-lib version');
|
|
||||||
}
|
|
||||||
const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js');
|
|
||||||
let source = fs.readFileSync(sourcePath, 'utf8');
|
|
||||||
const replacements = [
|
|
||||||
['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'],
|
|
||||||
['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'],
|
|
||||||
['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'],
|
|
||||||
];
|
|
||||||
for (const [before, after] of replacements) {
|
|
||||||
if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch');
|
|
||||||
source = source.replace(before, after);
|
|
||||||
}
|
|
||||||
fs.writeFileSync(sourcePath, source);
|
|
||||||
KEYCHAIN_FIX
|
|
||||||
|
|
||||||
- name: Set desktop package version from tag
|
- name: Set desktop package version from tag
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ registrations remain inspectable; a changed destination requires explicit identi
|
|||||||
|
|
||||||
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
|
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
|
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.
|
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.
|
||||||
|
|
||||||
Disposing a routed API releases its observations and session. It does not remove the saved host or
|
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
|
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
|
# The Nix output is not a distributable DMG, so leave it unsigned and
|
||||||
# disable the hardened runtime that requires a matching signature.
|
# disable the hardened runtime that requires a matching signature.
|
||||||
CSC_IDENTITY_AUTO_DISCOVERY=false \
|
CSC_IDENTITY_AUTO_DISCOVERY=false \
|
||||||
../../node_modules/.bin/electron-builder \
|
npm exec --offline -- electron-builder \
|
||||||
--config electron-builder.yml \
|
--config electron-builder.yml \
|
||||||
--dir \
|
--dir \
|
||||||
--mac \
|
--mac \
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU=
|
sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
|
||||||
|
|||||||
Generated
+1018
-1295
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import {
|
|||||||
composerLocator,
|
composerLocator,
|
||||||
expectComposerDraft,
|
expectComposerDraft,
|
||||||
expectComposerVisible,
|
expectComposerVisible,
|
||||||
submitMessage,
|
fillComposerDraft,
|
||||||
typeIntoFocusedComposer,
|
typeIntoFocusedComposer,
|
||||||
} from "../support/helpers/composer";
|
} from "../support/helpers/composer";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
@@ -12,8 +12,14 @@ function userMessages(page: Page) {
|
|||||||
return page.getByTestId("user-message");
|
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) {
|
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
||||||
await submitMessage(page, text);
|
await fillComposerDraft(page, text);
|
||||||
|
await sendDraftWhenReady(page);
|
||||||
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
||||||
await expectComposerDraft(page, "");
|
await expectComposerDraft(page, "");
|
||||||
}
|
}
|
||||||
@@ -49,7 +55,7 @@ test.describe("Composer recall", () => {
|
|||||||
await pressInComposer(page, "ArrowUp");
|
await pressInComposer(page, "ArrowUp");
|
||||||
await expectComposerDraft(page, "add tests for login");
|
await expectComposerDraft(page, "add tests for login");
|
||||||
|
|
||||||
await pressInComposer(page, "Enter");
|
await sendDraftWhenReady(page);
|
||||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -717,11 +717,13 @@ describe("terminal emulator runtime in a real browser", () => {
|
|||||||
{ name: "DA1-zero", bytes: "\x1b[0c" },
|
{ name: "DA1-zero", bytes: "\x1b[0c" },
|
||||||
{ name: "DA2", bytes: "\x1b[>c" },
|
{ name: "DA2", bytes: "\x1b[>c" },
|
||||||
{ name: "DA3", bytes: "\x1b[=c" },
|
{ name: "DA3", bytes: "\x1b[=c" },
|
||||||
|
{ name: "XTVERSION", bytes: "\x1b[>q" },
|
||||||
{ name: "DSR-5", bytes: "\x1b[5n" },
|
{ name: "DSR-5", bytes: "\x1b[5n" },
|
||||||
{ name: "DSR-6", bytes: "\x1b[6n" },
|
{ name: "DSR-6", bytes: "\x1b[6n" },
|
||||||
{ 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: "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-10-foreground-color", bytes: "\x1b]10;?\x07" },
|
||||||
{ name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" },
|
{ name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" },
|
||||||
{ name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" },
|
{ name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" },
|
||||||
|
|||||||
@@ -647,6 +647,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
terminal.parser.registerCsiHandler({ final: "c" }, () => true);
|
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: "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({ final: "n" }, () => true);
|
||||||
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true);
|
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true);
|
||||||
terminal.parser.registerCsiHandler({ final: "R" }, () => true);
|
terminal.parser.registerCsiHandler({ final: "R" }, () => true);
|
||||||
@@ -655,6 +656,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
{ prefix: "?", intermediates: "$", final: "p" },
|
{ prefix: "?", intermediates: "$", final: "p" },
|
||||||
() => true,
|
() => true,
|
||||||
);
|
);
|
||||||
|
terminal.parser.registerDcsHandler({ intermediates: "$", final: "q" }, () => true);
|
||||||
for (const code of [10, 11, 12]) {
|
for (const code of [10, 11, 12]) {
|
||||||
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
|
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,15 +204,16 @@ export function createHostTransportFactory(
|
|||||||
}
|
}
|
||||||
export function createHostClient(
|
export function createHostClient(
|
||||||
connection: string,
|
connection: string,
|
||||||
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions,
|
config: Omit<DaemonClientConfig, "url"> & HostTransportOptions,
|
||||||
budget?: HostTransportBudget,
|
budget?: HostTransportBudget,
|
||||||
): DaemonClient {
|
): DaemonClient {
|
||||||
const target = parseHostTarget(connection);
|
const target = parseHostTarget(connection);
|
||||||
const { webSocketAgent, ...clientConfig } = config;
|
const { webSocketAgent, transportFactory, ...clientConfig } = config;
|
||||||
return new DaemonClient({
|
return new DaemonClient({
|
||||||
...clientConfig,
|
...clientConfig,
|
||||||
url: "ws://host-connector/ws",
|
url: "ws://host-connector/ws",
|
||||||
...(target.password ? { password: target.password } : {}),
|
...(target.password ? { password: target.password } : {}),
|
||||||
transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }),
|
transportFactory:
|
||||||
|
transportFactory ?? createHostTransportFactory(connection, budget, { webSocketAgent }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
"@types/node": "24.6.0",
|
"@types/node": "24.6.0",
|
||||||
"@types/ws": "^8.5.14",
|
"@types/ws": "^8.5.14",
|
||||||
"electron": "44.2.0",
|
"electron": "44.2.0",
|
||||||
"electron-builder": "26.8.1",
|
"electron-builder": "26.18.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"unzip-crx-3": "^0.2.0",
|
"unzip-crx-3": "^0.2.0",
|
||||||
"vitest": "^4.1.6",
|
"vitest": "^4.1.6",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { constants as bufferConstants } from "node:buffer";
|
||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
@@ -236,4 +237,64 @@ describe("importing a renamed Claude session", () => {
|
|||||||
|
|
||||||
await expect(importSession(sessionId)).rejects.toThrow();
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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 = {
|
const info: AgentRuntimeInfo = {
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
sessionId: this.claudeSessionId,
|
sessionId: this.claudeSessionId,
|
||||||
model: this.lastOptionsModel,
|
model: this.lastOptionsModel ?? this.config.model ?? null,
|
||||||
modeId: this.currentMode ?? null,
|
modeId: this.currentMode ?? null,
|
||||||
...(this.lastRuntimeModel
|
...(this.lastRuntimeModel
|
||||||
? {
|
? {
|
||||||
@@ -6853,40 +6853,79 @@ function normalizeClaudeSessionTitle(title: string | null): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title";
|
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 `custom-title` record is re-emitted every time the session is saved, so
|
||||||
* the current name is the last usable one in the file. Searching backwards finds it without
|
* the current name is the last usable one in the file. Reading backwards a chunk
|
||||||
* walking the transcript, which matters because sessions reach tens of
|
* at a time finds it without walking the transcript, which matters because
|
||||||
* megabytes.
|
* sessions reach tens of megabytes, and never holds the file as one string, so a
|
||||||
|
* transcript past V8's maximum string length still imports.
|
||||||
*/
|
*/
|
||||||
function readLastClaudeCustomTitle(content: string): string | null {
|
async function readLastClaudeCustomTitle(filePath: string): Promise<string | null> {
|
||||||
const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`;
|
const handle = await fsPromises.open(filePath, "r");
|
||||||
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 {
|
try {
|
||||||
parsed = JSON.parse(line.trim());
|
const stats = await handle.stat();
|
||||||
} catch {
|
if (!stats.isFile()) {
|
||||||
parsed = undefined;
|
// Opening a directory succeeds on Windows even though reading it does not,
|
||||||
}
|
// so reject the way reading the transcript as a file would.
|
||||||
const entry = toObjectRecord(parsed);
|
throw Object.assign(
|
||||||
if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) {
|
new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`),
|
||||||
const title = normalizeClaudeSessionTitle(
|
{ code: "EISDIR", path: filePath },
|
||||||
typeof entry.customTitle === "string" ? entry.customTitle : null,
|
|
||||||
);
|
);
|
||||||
if (title) {
|
|
||||||
return title;
|
|
||||||
}
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
// The needle also matches a prompt that quotes it, and a record can carry no
|
if (recordEnd > 0) pieces.push(chunk.subarray(0, recordEnd));
|
||||||
// usable name, so keep looking backwards for one that does.
|
end -= length;
|
||||||
at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1);
|
}
|
||||||
|
// The first record in the file has no newline in front of it.
|
||||||
|
return readClaudeCustomTitleFromRecord(Buffer.concat(pieces.toReversed()));
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
||||||
@@ -6898,9 +6937,8 @@ async function readClaudeRenamedSessionTitle(input: {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
configDir: string;
|
configDir: string;
|
||||||
}): Promise<string | null> {
|
}): Promise<string | null> {
|
||||||
let content: string;
|
|
||||||
try {
|
try {
|
||||||
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
|
return await readLastClaudeCustomTitle(await claudeTranscriptPath(input));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// A session with no transcript on disk has no rename to carry over. Every
|
// A session with no transcript on disk has no rename to carry over. Every
|
||||||
// other read failure is a real fault and stays explicit.
|
// other read failure is a real fault and stays explicit.
|
||||||
@@ -6909,7 +6947,6 @@ async function readClaudeRenamedSessionTitle(input: {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return readLastClaudeCustomTitle(content);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeImportablePromptPreview(text: string): string | null {
|
function normalizeImportablePromptPreview(text: string): string | null {
|
||||||
|
|||||||
@@ -150,6 +150,15 @@ function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolea
|
|||||||
|
|
||||||
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
||||||
const INTERRUPT_TIMEOUT_MS = 2_000;
|
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;
|
const CODEX_PROVIDER = "codex" as const;
|
||||||
// These native provider identities belong to Codex, not to an older custom
|
// 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.
|
// Paseo provider. Never include them in a custom provider's legacy import scan.
|
||||||
@@ -3476,6 +3485,14 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
|
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
|
||||||
private pendingAssistantMessageBoundary = false;
|
private pendingAssistantMessageBoundary = false;
|
||||||
private terminalCommandByProcessId = new Map<string, string>();
|
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<
|
private pendingUnlabeledTerminalInteractions = new Map<
|
||||||
string,
|
string,
|
||||||
Array<{ callId: string; stdin: string | null }>
|
Array<{ callId: string; stdin: string | null }>
|
||||||
@@ -4431,6 +4448,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
|
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
|
||||||
hasCodexConfig: turnStart.hasCodexConfig,
|
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) {
|
if (pendingStart.cancelRequested) {
|
||||||
throw new Error("Codex turn start was interrupted before reaching Codex");
|
throw new Error("Codex turn start was interrupted before reaching Codex");
|
||||||
}
|
}
|
||||||
@@ -5036,15 +5056,27 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
|
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
|
||||||
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
|
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
|
||||||
}
|
}
|
||||||
try {
|
const client = this.client;
|
||||||
await this.client.request(
|
const threadId = this.currentThreadId;
|
||||||
|
const interruptRequest = client.request(
|
||||||
"turn/interrupt",
|
"turn/interrupt",
|
||||||
{
|
{
|
||||||
threadId: this.currentThreadId,
|
threadId,
|
||||||
turnId,
|
turnId,
|
||||||
},
|
},
|
||||||
INTERRUPT_TIMEOUT_MS,
|
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;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isCodexAlreadyIdleInterrupt(error)) {
|
if (!isCodexAlreadyIdleInterrupt(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -5085,6 +5117,137 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
return !this.client.hasPendingRequests();
|
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> {
|
async close(): Promise<void> {
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
this.clearPendingPermissions();
|
this.clearPendingPermissions();
|
||||||
@@ -6209,6 +6372,11 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.completePendingRootCompactions();
|
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") {
|
if (parsed.status === "failed") {
|
||||||
this.emitEvent({
|
this.emitEvent({
|
||||||
type: "turn_failed",
|
type: "turn_failed",
|
||||||
@@ -6613,6 +6781,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private async handleItemCompletedNotification(
|
private async handleItemCompletedNotification(
|
||||||
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
|
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
this.recordTurnTerminalProcess(parsed);
|
||||||
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
|
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
|
||||||
// and canonical `item/*`. Render ordinary items only from the canonical
|
// and canonical `item/*`. Render ordinary items only from the canonical
|
||||||
// channel, but accept a legacy-only child announcement so it can establish
|
// channel, but accept a legacy-only child announcement so it can establish
|
||||||
@@ -6775,6 +6944,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private async handleItemStartedNotification(
|
private async handleItemStartedNotification(
|
||||||
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
|
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
this.recordTurnTerminalProcess(parsed);
|
||||||
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
|
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@@ -183,7 +183,7 @@ test("ordinary routed APIs isolate scopes and enforce one-hop, disable and origi
|
|||||||
await paseo.dispose();
|
await paseo.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("routed authority is bounded by the caller, with isolated simultaneous sessions", async () => {
|
test("routed authority isolates simultaneous sessions and preserves ordinary permission RPC errors", async () => {
|
||||||
const b = await daemon();
|
const b = await daemon();
|
||||||
const a = await daemon();
|
const a = await daemon();
|
||||||
const owner = await api(a);
|
const owner = await api(a);
|
||||||
@@ -210,33 +210,16 @@ test("routed authority is bounded by the caller, with isolated simultaneous sess
|
|||||||
code: "permission_denied",
|
code: "permission_denied",
|
||||||
});
|
});
|
||||||
expect((await owner.paseo.hosts.list()).hosts).toHaveLength(1);
|
expect((await owner.paseo.hosts.list()).hosts).toHaveLength(1);
|
||||||
await remote.dispose();
|
const directClient = createHostClient(`127.0.0.1:${b.port}`, {
|
||||||
});
|
|
||||||
|
|
||||||
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(),
|
clientId: crypto.randomUUID(),
|
||||||
sessionPermissions: ["daemon.read", "workspace.read"],
|
sessionPermissions: ["daemon.read", "workspace.read"],
|
||||||
reconnect: { enabled: false },
|
reconnect: { enabled: false },
|
||||||
}),
|
});
|
||||||
);
|
await directClient.connect();
|
||||||
for (const client of clients) {
|
const direct = createPaseoApi(directClient);
|
||||||
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 () => {
|
cleanup.push(async () => {
|
||||||
await remote.dispose();
|
|
||||||
await direct.dispose();
|
await direct.dispose();
|
||||||
await origin.dispose();
|
await directClient.close();
|
||||||
});
|
});
|
||||||
const input = { source: { kind: "directory" as const, path: b.staticDir } };
|
const input = { source: { kind: "directory" as const, path: b.staticDir } };
|
||||||
const directError = (await direct.workspaces.create(input).catch((error: unknown) => error)) as {
|
const directError = (await direct.workspaces.create(input).catch((error: unknown) => error)) as {
|
||||||
@@ -247,6 +230,7 @@ test("SDK host clients preserve ordinary permission RPC errors", async () => {
|
|||||||
};
|
};
|
||||||
expect(directError.code).toBe("access_denied");
|
expect(directError.code).toBe("access_denied");
|
||||||
expect(remoteError.code).toBe(directError.code);
|
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 () => {
|
test("distinct origin sessions cannot collide through a caller-selected route scope, including terminal binary output", async () => {
|
||||||
@@ -784,6 +768,211 @@ test("old capabilities fail before execution and authentication errors recover t
|
|||||||
expect((await online(paseo)).hosts[0]!.serverId).toBe(b.daemon.getServerId());
|
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 () => {
|
test("an unreachable saved target becomes online after its daemon starts without being added again", async () => {
|
||||||
const reservation = createServer();
|
const reservation = createServer();
|
||||||
reservation.listen(0, "127.0.0.1");
|
reservation.listen(0, "127.0.0.1");
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import { z } from "zod";
|
|||||||
import {
|
import {
|
||||||
createHostClient,
|
createHostClient,
|
||||||
createHostChannelTransport,
|
createHostChannelTransport,
|
||||||
|
createHostTransportFactory,
|
||||||
parseHostTarget,
|
parseHostTarget,
|
||||||
HostTransportBudget,
|
HostTransportBudget,
|
||||||
} from "@getpaseo/client/node";
|
} from "@getpaseo/client/node";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import type { DaemonTransportFactory } from "@getpaseo/client/internal/daemon-client-transport-types";
|
||||||
import {
|
import {
|
||||||
HostSummarySchema,
|
HostSummarySchema,
|
||||||
HostAddSchema,
|
HostAddSchema,
|
||||||
@@ -235,23 +237,30 @@ export class ConnectedHosts {
|
|||||||
if (route.registrationId === id)
|
if (route.registrationId === id)
|
||||||
route.close(code, "Host access was disabled, replaced or removed");
|
route.close(code, "Host access was disabled, replaced or removed");
|
||||||
}
|
}
|
||||||
private start(row: Saved): void {
|
private start(
|
||||||
|
row: Saved,
|
||||||
|
resume?: { failures: number; transports: DaemonTransportFactory },
|
||||||
|
): void {
|
||||||
if (!row.enabled || this.closed) return;
|
if (!row.enabled || this.closed) return;
|
||||||
|
const transports =
|
||||||
|
resume?.transports ??
|
||||||
|
createHostTransportFactory(row.connection, this.budget, {
|
||||||
|
webSocketAgent: webSocketProxyAgent,
|
||||||
|
});
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
const client = createHostClient(
|
let attempts = resume?.failures ?? 0;
|
||||||
row.connection,
|
let retry: ReturnType<typeof setTimeout> | undefined;
|
||||||
{
|
const client = createHostClient(row.connection, {
|
||||||
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
|
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
|
||||||
connectTimeoutMs: 10000,
|
connectTimeoutMs: 10000,
|
||||||
webSocketAgent: webSocketProxyAgent,
|
transportFactory: transports,
|
||||||
},
|
});
|
||||||
this.budget,
|
|
||||||
);
|
|
||||||
const live: Live = {
|
const live: Live = {
|
||||||
client,
|
client,
|
||||||
accepted: false,
|
accepted: false,
|
||||||
stop: () => {
|
stop: () => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
|
clearTimeout(retry);
|
||||||
void client.close();
|
void client.close();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -285,7 +294,7 @@ export class ConnectedHosts {
|
|||||||
if (info.features?.connectedHosts !== true || info.features?.hostMultiplexing !== true)
|
if (info.features?.connectedHosts !== true || info.features?.hostMultiplexing !== true)
|
||||||
throw new HostError(
|
throw new HostError(
|
||||||
"unsupported_capability",
|
"unsupported_capability",
|
||||||
"Update the destination daemon to use connected hosts.",
|
"Update the destination daemon to use connected hosts; retrying automatically.",
|
||||||
);
|
);
|
||||||
// Pin and persist under the same writer as management to prevent discovery races.
|
// Pin and persist under the same writer as management to prevent discovery races.
|
||||||
await this.mutate(async () => {
|
await this.mutate(async () => {
|
||||||
@@ -304,16 +313,22 @@ export class ConnectedHosts {
|
|||||||
if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return;
|
if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return;
|
||||||
Object.assign(row, next);
|
Object.assign(row, next);
|
||||||
live.accepted = true;
|
live.accepted = true;
|
||||||
|
attempts = 0;
|
||||||
this.changed();
|
this.changed();
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
row.status = "error";
|
|
||||||
row.error = connectionFailure(error, client.authFailureReason !== null) ?? {
|
row.error = connectionFailure(error, client.authFailureReason !== null) ?? {
|
||||||
code: "host_offline",
|
code: "host_offline",
|
||||||
message: "Host connection failed",
|
message: "Host connection failed",
|
||||||
};
|
};
|
||||||
|
if (terminalFailures.has(row.error.code)) {
|
||||||
|
row.status = "error";
|
||||||
row.nextRetryAt = null;
|
row.nextRetryAt = null;
|
||||||
|
} else {
|
||||||
|
stopped = true;
|
||||||
|
retry = this.scheduleAcceptanceRetry(row, attempts, transports);
|
||||||
|
}
|
||||||
this.changed();
|
this.changed();
|
||||||
await client.close();
|
await client.close();
|
||||||
}
|
}
|
||||||
@@ -329,9 +344,8 @@ export class ConnectedHosts {
|
|||||||
if (route.registrationId === row.registrationId) route.close();
|
if (route.registrationId === row.registrationId) route.close();
|
||||||
row.status = state.status === "connecting" ? "connecting" : "offline";
|
row.status = state.status === "connecting" ? "connecting" : "offline";
|
||||||
row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : null;
|
row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : null;
|
||||||
row.error =
|
if (state.status === "disconnected")
|
||||||
state.status === "disconnected"
|
row.error = connectionFailure(
|
||||||
? (connectionFailure(
|
|
||||||
Object.assign(new Error("Check the SSH configuration and daemon credentials"), {
|
Object.assign(new Error("Check the SSH configuration and daemon credentials"), {
|
||||||
code: state.failureCode,
|
code: state.failureCode,
|
||||||
}),
|
}),
|
||||||
@@ -339,8 +353,8 @@ export class ConnectedHosts {
|
|||||||
) ?? {
|
) ?? {
|
||||||
code: "host_offline",
|
code: "host_offline",
|
||||||
message: "Cannot reach host. Check the connection; retrying automatically.",
|
message: "Cannot reach host. Check the connection; retrying automatically.",
|
||||||
})
|
};
|
||||||
: null;
|
else if (row.error?.code !== "unsupported_capability") row.error = null;
|
||||||
if (row.error?.code === "authentication_failed") {
|
if (row.error?.code === "authentication_failed") {
|
||||||
row.status = "error";
|
row.status = "error";
|
||||||
row.nextRetryAt = null;
|
row.nextRetryAt = null;
|
||||||
@@ -351,6 +365,18 @@ export class ConnectedHosts {
|
|||||||
});
|
});
|
||||||
void client.connect().catch(() => {});
|
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: {
|
async openRoute(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
scopeId: string;
|
scopeId: string;
|
||||||
@@ -531,6 +557,20 @@ 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"] {
|
function connectionFailure(error: unknown, authenticationFailed: boolean): HostSummary["error"] {
|
||||||
if (error instanceof HostError) return { code: error.code, message: error.message };
|
if (error instanceof HostError) return { code: error.code, message: error.message };
|
||||||
if (error instanceof Error && "code" in error && error.code === "authentication_failed")
|
if (error instanceof Error && "code" in error && error.code === "authentication_failed")
|
||||||
|
|||||||
@@ -88,13 +88,9 @@ test.each(["uncommitted", "base", "commit"] as const)(
|
|||||||
60_000,
|
60_000,
|
||||||
);
|
);
|
||||||
|
|
||||||
test.each(
|
test.each(["single", "batch", "rename"] as const)(
|
||||||
(["uncommitted", "base"] as const).flatMap((mode) =>
|
"%s diff includes hunks from a repository subdirectory before and after commit",
|
||||||
(["single", "batch", "rename"] as const).map((shape) => ({ mode, shape })),
|
async (shape) => {
|
||||||
),
|
|
||||||
)(
|
|
||||||
"$mode $shape diff includes hunks from a repository subdirectory",
|
|
||||||
async ({ mode, shape }) => {
|
|
||||||
const ctx = await createDaemonTestContext();
|
const ctx = await createDaemonTestContext();
|
||||||
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-"));
|
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-"));
|
||||||
|
|
||||||
@@ -112,9 +108,7 @@ test.each(
|
|||||||
}
|
}
|
||||||
git(cwd, "add", ".");
|
git(cwd, "add", ".");
|
||||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
|
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
|
||||||
if (mode === "base") {
|
|
||||||
git(cwd, "checkout", "-b", "feature");
|
git(cwd, "checkout", "-b", "feature");
|
||||||
}
|
|
||||||
const filename = shape === "rename" ? "renamed.txt" : "normal.txt";
|
const filename = shape === "rename" ? "renamed.txt" : "normal.txt";
|
||||||
if (shape === "rename") {
|
if (shape === "rename") {
|
||||||
git(cwd, "mv", "sub/normal.txt", `sub/${filename}`);
|
git(cwd, "mv", "sub/normal.txt", `sub/${filename}`);
|
||||||
@@ -123,11 +117,7 @@ test.each(
|
|||||||
if (shape === "batch") {
|
if (shape === "batch") {
|
||||||
writeFileSync(join(cwd, "sibling.txt"), "new sibling\n");
|
writeFileSync(join(cwd, "sibling.txt"), "new sibling\n");
|
||||||
}
|
}
|
||||||
if (mode === "base") {
|
async function expectSubdirectoryDiff(mode: "uncommitted" | "base") {
|
||||||
git(cwd, "add", ".");
|
|
||||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
|
|
||||||
}
|
|
||||||
|
|
||||||
const compare = { mode, baseRef: "main" };
|
const compare = { mode, baseRef: "main" };
|
||||||
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
|
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
|
||||||
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
|
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
|
||||||
@@ -153,6 +143,12 @@ test.each(
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
|
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expectSubdirectoryDiff("uncommitted");
|
||||||
|
git(cwd, "add", ".");
|
||||||
|
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
|
||||||
|
await expectSubdirectoryDiff("base");
|
||||||
} finally {
|
} finally {
|
||||||
await ctx.cleanup();
|
await ctx.cleanup();
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -102,7 +102,22 @@ describe("daemon E2E", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test.each([
|
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: "download.txt", disposition: 'attachment; filename="download.txt"' },
|
||||||
{
|
{
|
||||||
fileName: "café.txt",
|
fileName: "café.txt",
|
||||||
@@ -128,27 +143,11 @@ describe("daemon E2E", () => {
|
|||||||
disposition:
|
disposition:
|
||||||
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
|
"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 filePath = path.join(cwd, fileName);
|
||||||
const fileContents = "download test payload";
|
const fileContents = `download test payload: ${fileName}`;
|
||||||
writeFileSync(filePath, fileContents, "utf-8");
|
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);
|
const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName);
|
||||||
|
|
||||||
expect(tokenResponse.error).toBeNull();
|
expect(tokenResponse.error).toBeNull();
|
||||||
@@ -167,12 +166,11 @@ describe("daemon E2E", () => {
|
|||||||
|
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
expect(body).toBe(fileContents);
|
expect(body).toBe(fileContents);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
},
|
}, 60000);
|
||||||
60000,
|
|
||||||
);
|
|
||||||
|
|
||||||
test("rejects invalid token", async () => {
|
test("rejects invalid token", async () => {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
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,6 +20,7 @@ import { DaemonConfigStore } from "../daemon-config-store.js";
|
|||||||
import { PluginService } from "./index.js";
|
import { PluginService } from "./index.js";
|
||||||
import { BuiltinPluginLoader } from "./builtin/index.js";
|
import { BuiltinPluginLoader } from "./builtin/index.js";
|
||||||
import { ManagedPluginSources } from "./managed-source.js";
|
import { ManagedPluginSources } from "./managed-source.js";
|
||||||
|
import type { PluginProviderMetadata } from "./plugin-process-protocol.js";
|
||||||
import {
|
import {
|
||||||
startNpmRegistry,
|
startNpmRegistry,
|
||||||
npmPluginPackages,
|
npmPluginPackages,
|
||||||
@@ -179,6 +180,55 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) {
|
|||||||
return { runtime, started, releaseStart, starts };
|
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", () => {
|
describe("PluginService", () => {
|
||||||
it.each([
|
it.each([
|
||||||
{ shadow: false, runtime: "subprocess" },
|
{ shadow: false, runtime: "subprocess" },
|
||||||
@@ -372,15 +422,127 @@ describe("PluginService", () => {
|
|||||||
"startup-first": { source: "directory", path: first },
|
"startup-first": { source: "directory", path: first },
|
||||||
"startup-second": { source: "directory", path: second },
|
"startup-second": { source: "directory", path: second },
|
||||||
});
|
});
|
||||||
const snapshots: string[][] = [];
|
const notifications: { pluginId: string; running: boolean }[] = [];
|
||||||
service.subscribe(() => snapshots.push(catalogIds(service)));
|
service.subscribe((pluginId) =>
|
||||||
|
notifications.push({ pluginId, running: catalogIds(service).includes(pluginId) }),
|
||||||
|
);
|
||||||
|
|
||||||
await service.start();
|
await service.start();
|
||||||
|
|
||||||
expect(snapshots).toEqual([["startup-first"], ["startup-first", "startup-second"]]);
|
expect(notifications).toEqual([
|
||||||
|
{ pluginId: "startup-first", running: true },
|
||||||
|
{ pluginId: "startup-second", running: true },
|
||||||
|
]);
|
||||||
|
expect(catalogIds(service)).toEqual(["startup-first", "startup-second"]);
|
||||||
await service.stopAllPlugins();
|
await service.stopAllPlugins();
|
||||||
}, 20_000);
|
}, 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 () => {
|
it("uses an explicit config key, exposes reload failure, and retries from disk", async () => {
|
||||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||||
roots.push(home);
|
roots.push(home);
|
||||||
|
|||||||
@@ -230,11 +230,18 @@ export class PluginService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (config.pluginsEnabled === true) {
|
if (config.pluginsEnabled === true) {
|
||||||
for (const [pluginId, source] of Object.entries(config.plugins ?? {})) {
|
let earlierPublished: Promise<unknown> = Promise.resolve();
|
||||||
if (source.enabled === false) continue;
|
await Promise.all(
|
||||||
await this.startConfigured(pluginId);
|
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);
|
this.notify(pluginId);
|
||||||
}
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.configStore.onFieldChange("pluginsEnabled", (value) => {
|
this.configStore.onFieldChange("pluginsEnabled", (value) => {
|
||||||
this.handleGlobalSwitch(value === true);
|
this.handleGlobalSwitch(value === true);
|
||||||
@@ -560,12 +567,12 @@ export class PluginService {
|
|||||||
}).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins"));
|
}).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startConfigured(pluginId: string): Promise<void> {
|
private async startConfigured(pluginId: string, publishTurn?: Promise<unknown>): Promise<void> {
|
||||||
const source = this.configStore.get().plugins?.[pluginId];
|
const source = this.configStore.get().plugins?.[pluginId];
|
||||||
if (!source || source.enabled === false || !this.canPublish(pluginId)) return;
|
if (!source || source.enabled === false || !this.canPublish(pluginId)) return;
|
||||||
this.errors.delete(pluginId);
|
this.errors.delete(pluginId);
|
||||||
try {
|
try {
|
||||||
await this.startPlugin(pluginId, source.path);
|
await this.startPlugin(pluginId, source.path, publishTurn);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.canPublish(pluginId)) this.recordFailure(pluginId, error);
|
if (this.canPublish(pluginId)) this.recordFailure(pluginId, error);
|
||||||
}
|
}
|
||||||
@@ -594,8 +601,13 @@ export class PluginService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startPlugin(pluginId: string, sourcePath: string): Promise<void> {
|
private async startPlugin(
|
||||||
|
pluginId: string,
|
||||||
|
sourcePath: string,
|
||||||
|
publishTurn?: Promise<unknown>,
|
||||||
|
): Promise<void> {
|
||||||
await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId));
|
await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId));
|
||||||
|
await publishTurn;
|
||||||
try {
|
try {
|
||||||
await this.publishProviderRegistrations(pluginId, sourcePath);
|
await this.publishProviderRegistrations(pluginId, sourcePath);
|
||||||
this.publishUsageSources(pluginId);
|
this.publishUsageSources(pluginId);
|
||||||
|
|||||||
@@ -8318,7 +8318,7 @@ export class Session {
|
|||||||
},
|
},
|
||||||
activeTurnBehavior: msg.activeTurnBehavior ?? "steer",
|
activeTurnBehavior: msg.activeTurnBehavior ?? "steer",
|
||||||
sessionMode: msg.sessionMode,
|
sessionMode: msg.sessionMode,
|
||||||
clearPendingPermissions: true,
|
clearPendingPermissions: false,
|
||||||
});
|
});
|
||||||
deliveryStatus = "queued";
|
deliveryStatus = "queued";
|
||||||
queued();
|
queued();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { flushGitCommandTrace } from "../utils/git-command-trace.js";
|
||||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
@@ -730,3 +731,103 @@ test("Git event identity retains a symlink's literal target before canonicalizin
|
|||||||
rmSync(root, { recursive: true, force: true });
|
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,6 +244,7 @@ function createService(
|
|||||||
filterEvents: (events) => events,
|
filterEvents: (events) => events,
|
||||||
verify: vi.fn(async () => {}),
|
verify: vi.fn(async () => {}),
|
||||||
})),
|
})),
|
||||||
|
workspaceFolderExists: vi.fn(async () => true),
|
||||||
...overrides,
|
...overrides,
|
||||||
} as never,
|
} as never,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
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 os from "node:os";
|
||||||
import path, { join } from "node:path";
|
import path, { join } from "node:path";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
@@ -10,11 +12,14 @@ import type {
|
|||||||
PullRequestStatusResult,
|
PullRequestStatusResult,
|
||||||
} from "../utils/checkout-git.js";
|
} from "../utils/checkout-git.js";
|
||||||
import {
|
import {
|
||||||
|
BACKGROUND_GIT_FETCH_INTERVAL_MS,
|
||||||
|
WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS,
|
||||||
WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY,
|
WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY,
|
||||||
WORKSPACE_GIT_REFRESH_CONCURRENCY,
|
WORKSPACE_GIT_REFRESH_CONCURRENCY,
|
||||||
WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS,
|
WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS,
|
||||||
WorkspaceGitServiceImpl,
|
WorkspaceGitServiceImpl,
|
||||||
type WorkspaceGitRuntimeSnapshot,
|
type WorkspaceGitRuntimeSnapshot,
|
||||||
|
workspaceFolderExists,
|
||||||
} from "./workspace-git-service.js";
|
} from "./workspace-git-service.js";
|
||||||
|
|
||||||
const REPO_CWD = path.resolve("/tmp/repo");
|
const REPO_CWD = path.resolve("/tmp/repo");
|
||||||
@@ -23,6 +28,7 @@ function createLogger() {
|
|||||||
const logger = {
|
const logger = {
|
||||||
child: () => logger,
|
child: () => logger,
|
||||||
debug: vi.fn(),
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
warn: vi.fn(),
|
warn: vi.fn(),
|
||||||
};
|
};
|
||||||
return logger;
|
return logger;
|
||||||
@@ -251,7 +257,7 @@ function createGitHubServiceStub(): ForgeService {
|
|||||||
interface CreateServiceTestOptions {
|
interface CreateServiceTestOptions {
|
||||||
subscribe?: ReturnType<typeof vi.fn>;
|
subscribe?: ReturnType<typeof vi.fn>;
|
||||||
getCheckoutStatus?: ReturnType<typeof vi.fn>;
|
getCheckoutStatus?: ReturnType<typeof vi.fn>;
|
||||||
getCheckoutSnapshotFacts?: ReturnType<typeof vi.fn>;
|
getCheckoutSnapshotFacts?: (cwd: string) => Promise<CheckoutSnapshotFacts>;
|
||||||
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
|
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
|
||||||
getCheckoutWorktreeState?: ReturnType<typeof vi.fn>;
|
getCheckoutWorktreeState?: ReturnType<typeof vi.fn>;
|
||||||
getPullRequestStatus?: ReturnType<typeof vi.fn>;
|
getPullRequestStatus?: ReturnType<typeof vi.fn>;
|
||||||
@@ -261,6 +267,7 @@ interface CreateServiceTestOptions {
|
|||||||
runGitFetch?: ReturnType<typeof vi.fn>;
|
runGitFetch?: ReturnType<typeof vi.fn>;
|
||||||
runGitCommand?: ReturnType<typeof vi.fn>;
|
runGitCommand?: ReturnType<typeof vi.fn>;
|
||||||
getWorkspaceGitSelfHealPhaseMs?: (cwd: string) => number;
|
getWorkspaceGitSelfHealPhaseMs?: (cwd: string) => number;
|
||||||
|
workspaceFolderExists?: (cwd: string) => Promise<boolean>;
|
||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +301,7 @@ function buildDefaultTestServiceDeps() {
|
|||||||
signal: null,
|
signal: null,
|
||||||
})),
|
})),
|
||||||
getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000),
|
getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000),
|
||||||
|
workspaceFolderExists: vi.fn(async () => true),
|
||||||
createWatcherLivenessCanary: vi.fn(() => ({
|
createWatcherLivenessCanary: vi.fn(() => ({
|
||||||
path: "",
|
path: "",
|
||||||
filterEvents: (events) => events,
|
filterEvents: (events) => events,
|
||||||
@@ -324,6 +332,145 @@ 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", () => {
|
describe("WorkspaceGitServiceImpl", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -1553,3 +1700,460 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
service.dispose();
|
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 { createHash } from "node:crypto";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile, stat } from "node:fs/promises";
|
||||||
import { basename, dirname, join, resolve } from "node:path";
|
import { basename, dirname, join, resolve } from "node:path";
|
||||||
import { LRUCache } from "lru-cache";
|
import { LRUCache } from "lru-cache";
|
||||||
import { CheckoutDiffCache } from "./checkout-diff-cache.js";
|
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";
|
import { createWatcherLivenessCanary } from "./watcher-liveness-canary.js";
|
||||||
|
|
||||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
|
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
|
||||||
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
export const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
||||||
const FETCH_METADATA_ECHO_TTL_MS = 5_000;
|
const FETCH_METADATA_ECHO_TTL_MS = 5_000;
|
||||||
export const WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS = 60_000;
|
export const WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS = 60_000;
|
||||||
const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
|
const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
|
||||||
@@ -384,6 +384,7 @@ interface WorkspaceGitServiceDependencies {
|
|||||||
runGitCommand: typeof runGitCommand;
|
runGitCommand: typeof runGitCommand;
|
||||||
getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitObservationReensurePhaseMs;
|
getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitObservationReensurePhaseMs;
|
||||||
createWatcherLivenessCanary: typeof createWatcherLivenessCanary;
|
createWatcherLivenessCanary: typeof createWatcherLivenessCanary;
|
||||||
|
workspaceFolderExists: (cwd: string) => Promise<boolean>;
|
||||||
now: () => Date;
|
now: () => Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,6 +436,8 @@ interface WorkspaceGitTarget {
|
|||||||
repoGitRoot: string | null;
|
repoGitRoot: string | null;
|
||||||
observationSetupPromise: Promise<void> | null;
|
observationSetupPromise: Promise<void> | null;
|
||||||
observationSetupComplete: boolean;
|
observationSetupComplete: boolean;
|
||||||
|
folderMissing: boolean;
|
||||||
|
folderRestorePromise: Promise<void> | null;
|
||||||
closed: boolean;
|
closed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,10 +574,21 @@ function buildDefaultWorkspaceGitServiceDeps(
|
|||||||
runGitCommand,
|
runGitCommand,
|
||||||
getWorkspaceGitSelfHealPhaseMs: getWorkspaceGitObservationReensurePhaseMs,
|
getWorkspaceGitSelfHealPhaseMs: getWorkspaceGitObservationReensurePhaseMs,
|
||||||
createWatcherLivenessCanary,
|
createWatcherLivenessCanary,
|
||||||
|
workspaceFolderExists,
|
||||||
now: () => new Date(),
|
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(
|
function resolveWorkspaceGitServiceDeps(
|
||||||
subscribe: SubscribeToFileChanges,
|
subscribe: SubscribeToFileChanges,
|
||||||
deps: Partial<WorkspaceGitServiceDependencies> | undefined,
|
deps: Partial<WorkspaceGitServiceDependencies> | undefined,
|
||||||
@@ -660,6 +674,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
const cwd = resolve(params.cwd);
|
const cwd = resolve(params.cwd);
|
||||||
const target = this.ensureWorkspaceTarget(cwd);
|
const target = this.ensureWorkspaceTarget(cwd);
|
||||||
target.listeners.add(listener);
|
target.listeners.add(listener);
|
||||||
|
if (target.folderMissing) {
|
||||||
|
void this.resumeWorkspaceIfFolderRestored(target);
|
||||||
|
}
|
||||||
if (target.listeners.size === 1) {
|
if (target.listeners.size === 1) {
|
||||||
this.startWorkspaceSubscriptionTimers(target);
|
this.startWorkspaceSubscriptionTimers(target);
|
||||||
}
|
}
|
||||||
@@ -751,6 +768,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
if (!request.force && target.latestSnapshot) {
|
if (!request.force && target.latestSnapshot) {
|
||||||
return target.latestSnapshot;
|
return target.latestSnapshot;
|
||||||
}
|
}
|
||||||
|
if (target.folderMissing) {
|
||||||
|
await this.resumeWorkspaceIfFolderRestored(target);
|
||||||
|
}
|
||||||
|
|
||||||
return this.requestWorkspaceSnapshot(target, request);
|
return this.requestWorkspaceSnapshot(target, request);
|
||||||
}
|
}
|
||||||
@@ -972,6 +992,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
this.assertNotDisposed();
|
this.assertNotDisposed();
|
||||||
cwd = resolve(cwd);
|
cwd = resolve(cwd);
|
||||||
const target = this.ensureWorkspaceTarget(cwd);
|
const target = this.ensureWorkspaceTarget(cwd);
|
||||||
|
if (target.folderMissing) {
|
||||||
|
await this.resumeWorkspaceIfFolderRestored(target);
|
||||||
|
}
|
||||||
await this.refreshWorkspaceTarget(target, {
|
await this.refreshWorkspaceTarget(target, {
|
||||||
force: false,
|
force: false,
|
||||||
refreshStructure: true,
|
refreshStructure: true,
|
||||||
@@ -1005,9 +1028,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
this.assertNotDisposed();
|
this.assertNotDisposed();
|
||||||
cwd = resolve(cwd);
|
cwd = resolve(cwd);
|
||||||
const target = this.workspaceTargets.get(cwd);
|
const target = this.workspaceTargets.get(cwd);
|
||||||
if (target) {
|
if (!target) {
|
||||||
this.scheduleWorkspaceRefresh(target, { queueIfBusy: false });
|
return;
|
||||||
}
|
}
|
||||||
|
if (target.folderMissing) {
|
||||||
|
void this.resumeWorkspaceIfFolderRestored(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.scheduleWorkspaceRefresh(target, { queueIfBusy: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
onWorkspaceStateMayHaveChanged(cwd: string): void {
|
onWorkspaceStateMayHaveChanged(cwd: string): void {
|
||||||
@@ -1018,6 +1046,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.invalidateForge(normalizedCwd);
|
this.invalidateForge(normalizedCwd);
|
||||||
|
if (target.folderMissing) {
|
||||||
|
void this.resumeWorkspaceIfFolderRestored(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.scheduleWorkspaceRefresh(target, {
|
this.scheduleWorkspaceRefresh(target, {
|
||||||
force: true,
|
force: true,
|
||||||
includeForge: true,
|
includeForge: true,
|
||||||
@@ -1227,6 +1259,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
repoGitRoot: null,
|
repoGitRoot: null,
|
||||||
observationSetupPromise: null,
|
observationSetupPromise: null,
|
||||||
observationSetupComplete: false,
|
observationSetupComplete: false,
|
||||||
|
folderMissing: false,
|
||||||
|
folderRestorePromise: null,
|
||||||
closed: false,
|
closed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1358,6 +1392,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
private isActiveObservedWorkspaceTarget(target: WorkspaceGitTarget): boolean {
|
private isActiveObservedWorkspaceTarget(target: WorkspaceGitTarget): boolean {
|
||||||
return (
|
return (
|
||||||
!target.closed &&
|
!target.closed &&
|
||||||
|
!target.folderMissing &&
|
||||||
target.listeners.size > 0 &&
|
target.listeners.size > 0 &&
|
||||||
this.workspaceTargets.get(target.cwd) === target
|
this.workspaceTargets.get(target.cwd) === target
|
||||||
);
|
);
|
||||||
@@ -1612,11 +1647,110 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
workspaceTarget: WorkspaceGitTarget,
|
workspaceTarget: WorkspaceGitTarget,
|
||||||
request: WorkspaceGitRefreshRequest,
|
request: WorkspaceGitRefreshRequest,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
if (await this.suspendWorkspaceIfFolderMissing(workspaceTarget)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const fingerprintBefore = workspaceTarget.latestFingerprint;
|
const fingerprintBefore = workspaceTarget.latestFingerprint;
|
||||||
await this.refreshWorkspaceTarget(workspaceTarget, request);
|
await this.refreshWorkspaceTarget(workspaceTarget, request);
|
||||||
return workspaceTarget.latestFingerprint !== fingerprintBefore;
|
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(
|
private startWorkingTreeWatchFallback(
|
||||||
target: WorkingTreeWatchTarget,
|
target: WorkingTreeWatchTarget,
|
||||||
reason: WorkingTreeWatchFallbackReason,
|
reason: WorkingTreeWatchFallbackReason,
|
||||||
@@ -1654,7 +1788,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return changed;
|
return changed;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
if (await this.deps.workspaceFolderExists(target.watchPath)) {
|
||||||
this.notifyWorkingTreeConsumers(target);
|
this.notifyWorkingTreeConsumers(target);
|
||||||
|
}
|
||||||
if (!target.closed && (target.subscription === null || target.repoRoot === null)) {
|
if (!target.closed && (target.subscription === null || target.repoRoot === null)) {
|
||||||
target.fallbackPollQuietTickCount = changes.some(Boolean)
|
target.fallbackPollQuietTickCount = changes.some(Boolean)
|
||||||
? 0
|
? 0
|
||||||
@@ -1731,6 +1867,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
if (target.closed || target.subscription) {
|
if (target.closed || target.subscription) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!(await this.deps.workspaceFolderExists(target.watchPath))) {
|
||||||
|
this.scheduleWorkingTreeWatchRecovery(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.refreshWorkingTreeIgnoredDirectories(target);
|
await this.refreshWorkingTreeIgnoredDirectories(target);
|
||||||
if (target.closed || target.subscription) {
|
if (target.closed || target.subscription) {
|
||||||
return;
|
return;
|
||||||
@@ -2786,7 +2926,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateForgePrStatusPollForTarget(target: WorkspaceGitTarget): void {
|
private updateForgePrStatusPollForTarget(target: WorkspaceGitTarget): void {
|
||||||
if (target.listeners.size === 0) {
|
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
||||||
this.stopForgePrStatusPollForTarget(target);
|
this.stopForgePrStatusPollForTarget(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3049,6 +3189,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
target: WorkspaceGitTarget,
|
target: WorkspaceGitTarget,
|
||||||
request: WorkspaceGitRefreshRequest,
|
request: WorkspaceGitRefreshRequest,
|
||||||
): Promise<WorkspaceGitRuntimeSnapshot> {
|
): Promise<WorkspaceGitRuntimeSnapshot> {
|
||||||
|
if (target.folderMissing) {
|
||||||
|
return Promise.resolve(target.latestSnapshot ?? buildNotGitSnapshot(target.cwd));
|
||||||
|
}
|
||||||
if (target.refreshState.status === "in-flight") {
|
if (target.refreshState.status === "in-flight") {
|
||||||
const active = target.refreshState.request;
|
const active = target.refreshState.request;
|
||||||
const addsWork =
|
const addsWork =
|
||||||
@@ -3191,11 +3334,19 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
try {
|
try {
|
||||||
const runRefreshGitCommand = createRunGitCommand(`workspace-refresh:${request.reason}`);
|
const runRefreshGitCommand = createRunGitCommand(`workspace-refresh:${request.reason}`);
|
||||||
const admittedSnapshot = await this.workspaceRefreshLimit(() => {
|
const admittedSnapshot = await this.workspaceRefreshLimit(() => {
|
||||||
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
if (
|
||||||
|
target.closed ||
|
||||||
|
target.folderMissing ||
|
||||||
|
this.workspaceTargets.get(target.cwd) !== target
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return this.refreshSnapshot(target, request, runRefreshGitCommand);
|
return this.refreshSnapshot(target, request, runRefreshGitCommand);
|
||||||
});
|
});
|
||||||
|
if (target.folderMissing) {
|
||||||
|
failure = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (!admittedSnapshot) {
|
if (!admittedSnapshot) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -3209,6 +3360,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
failure = { error };
|
failure = { error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (target.folderMissing) {
|
||||||
|
failure = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (this.disposed || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
if (this.disposed || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -3504,7 +3659,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
snapshot: WorkspaceGitRuntimeSnapshot,
|
snapshot: WorkspaceGitRuntimeSnapshot,
|
||||||
options?: { forceEmit?: boolean },
|
options?: { forceEmit?: boolean },
|
||||||
): void {
|
): void {
|
||||||
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
if (target.closed || target.folderMissing || this.workspaceTargets.get(target.cwd) !== target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
target.latestSnapshot = snapshot;
|
target.latestSnapshot = snapshot;
|
||||||
@@ -3660,7 +3815,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private removeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
private removeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
||||||
if (target.repoGitRoot) {
|
this.detachWorkspaceFromRepoTarget(target);
|
||||||
|
this.closeWorkspaceTarget(target);
|
||||||
|
this.workspaceTargets.delete(target.cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private detachWorkspaceFromRepoTarget(target: WorkspaceGitTarget): void {
|
||||||
|
if (!target.repoGitRoot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const repoTarget = this.repoTargets.get(target.repoGitRoot);
|
const repoTarget = this.repoTargets.get(target.repoGitRoot);
|
||||||
repoTarget?.workspaceKeys.delete(target.cwd);
|
repoTarget?.workspaceKeys.delete(target.cwd);
|
||||||
if (repoTarget && repoTarget.workspaceKeys.size === 0) {
|
if (repoTarget && repoTarget.workspaceKeys.size === 0) {
|
||||||
@@ -3671,10 +3834,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.closeWorkspaceTarget(target);
|
|
||||||
this.workspaceTargets.delete(target.cwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
private removeWorkingTreeWatchListener(cwd: string, listener: () => void): void {
|
private removeWorkingTreeWatchListener(cwd: string, listener: () => void): void {
|
||||||
const target = this.workingTreeWatchTargets.get(cwd);
|
const target = this.workingTreeWatchTargets.get(cwd);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -3719,6 +3878,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
|
|
||||||
private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
||||||
target.closed = true;
|
target.closed = true;
|
||||||
|
this.stopWorkspaceObservation(target);
|
||||||
|
target.listeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopWorkspaceObservation(target: WorkspaceGitTarget): void {
|
||||||
if (target.workingTreeWatchTarget) {
|
if (target.workingTreeWatchTarget) {
|
||||||
this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd);
|
this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd);
|
||||||
target.workingTreeWatchTarget = null;
|
target.workingTreeWatchTarget = null;
|
||||||
@@ -3727,12 +3891,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
clearTimeout(target.debounceTimer);
|
clearTimeout(target.debounceTimer);
|
||||||
target.debounceTimer = null;
|
target.debounceTimer = null;
|
||||||
}
|
}
|
||||||
|
target.pendingDebounceRequest = null;
|
||||||
if (target.observationReensureTimer) {
|
if (target.observationReensureTimer) {
|
||||||
clearTimeout(target.observationReensureTimer);
|
clearTimeout(target.observationReensureTimer);
|
||||||
target.observationReensureTimer = null;
|
target.observationReensureTimer = null;
|
||||||
}
|
}
|
||||||
this.stopForgePrStatusPollForTarget(target);
|
this.stopForgePrStatusPollForTarget(target);
|
||||||
target.listeners.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void {
|
private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
chmodSync,
|
||||||
existsSync,
|
existsSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
@@ -54,6 +55,7 @@ function makeSources(bundleSource = "export {};\n"): {
|
|||||||
mkdirSync(native, { recursive: true });
|
mkdirSync(native, { recursive: true });
|
||||||
writeFileSync(path.join(native, "pty.node"), "native");
|
writeFileSync(path.join(native, "pty.node"), "native");
|
||||||
writeFileSync(path.join(native, "spawn-helper"), "#!/bin/sh\n", { mode: 0o755 });
|
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.
|
// Another platform's build is not copied.
|
||||||
mkdirSync(path.join(nodePtyDirectory, "prebuilds", "other-platform"));
|
mkdirSync(path.join(nodePtyDirectory, "prebuilds", "other-platform"));
|
||||||
return { bundleDirectory, nodePtyDirectory };
|
return { bundleDirectory, nodePtyDirectory };
|
||||||
|
|||||||
@@ -680,51 +680,46 @@ test("macOS jobs use latest runners while preserving release architectures", ()
|
|||||||
assert.match(release, /runner: macos-26-intel\n\s+electron_arch: x64/);
|
assert.match(release, /runner: macos-26-intel\n\s+electron_arch: x64/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("macOS signing repair keeps certificate and keychain passwords separate", () => {
|
test("Nix desktop packaging resolves the workspace-local builder", () => {
|
||||||
const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8");
|
const source = readFileSync(new URL("nix/desktop-package.nix", repoRoot), "utf8");
|
||||||
const match = workflow.match(/<<'KEYCHAIN_FIX'\n([\s\S]*?)\n KEYCHAIN_FIX/);
|
const invocation = source.match(/^\s+([^\n]*electron-builder) \\\n/m)?.[1];
|
||||||
assert.ok(match, "the release workflow repairs the pinned signing tool");
|
assert.ok(invocation, "Nix invokes the desktop packaging tool");
|
||||||
const script = match[1].replace(/^ /gm, "");
|
const directory = mkdtempSync(join(tmpdir(), "nix-builder-resolution-"));
|
||||||
const original = [
|
|
||||||
"return await importCerts(keychainFile, certPaths, cscPasswords);",
|
|
||||||
"async function importCerts(keychainFile, paths, keyPasswords) {",
|
|
||||||
'["import", paths[i], "-P", password]',
|
|
||||||
'["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]',
|
|
||||||
"}",
|
|
||||||
].join("\n");
|
|
||||||
const directory = mkdtempSync(join(tmpdir(), "signing-repair-"));
|
|
||||||
try {
|
try {
|
||||||
const packageDirectory = join(directory, "node_modules/app-builder-lib");
|
const desktop = join(directory, "packages/desktop");
|
||||||
mkdirSync(join(packageDirectory, "out/codeSign"), { recursive: true });
|
mkdirSync(join(desktop, "node_modules/.bin"), { recursive: true });
|
||||||
const manifest = join(packageDirectory, "package.json");
|
mkdirSync(join(desktop, "node_modules/electron-builder"));
|
||||||
const sourcePath = join(packageDirectory, "out/codeSign/macCodeSign.js");
|
writeFileSync(
|
||||||
for (const [version, source, succeeds] of [
|
join(desktop, "node_modules/electron-builder/package.json"),
|
||||||
["26.8.1", original, true],
|
JSON.stringify({
|
||||||
["26.8.1", original.replace("cscPasswords);", "differentArguments);"), false],
|
name: "electron-builder",
|
||||||
["26.18.0", original, false],
|
version: "1.0.0",
|
||||||
]) {
|
bin: { "electron-builder": "../.bin/electron-builder" },
|
||||||
writeFileSync(manifest, JSON.stringify({ name: "app-builder-lib", version }));
|
}),
|
||||||
writeFileSync(sourcePath, source);
|
);
|
||||||
const result = spawnSync(process.execPath, ["-e", script], {
|
writeFileSync(
|
||||||
cwd: directory,
|
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",
|
encoding: "utf8",
|
||||||
});
|
});
|
||||||
const actual = readFileSync(sourcePath, "utf8");
|
|
||||||
if (succeeds) {
|
|
||||||
assert.equal(result.status, 0, result.stderr);
|
assert.equal(result.status, 0, result.stderr);
|
||||||
assert.ok(
|
assert.equal(result.stdout.trim(), "workspace-builder");
|
||||||
actual.includes("importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)"),
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
actual.includes("importCerts(keychainFile, paths, keyPasswords, keychainPassword)"),
|
|
||||||
);
|
|
||||||
assert.ok(actual.includes('["import", paths[i], "-P", password]'));
|
|
||||||
assert.ok(actual.includes('"-k", keychainPassword, keychainFile]'));
|
|
||||||
} else {
|
|
||||||
assert.notEqual(result.status, 0);
|
|
||||||
assert.equal(actual, source, "unexpected tool inputs must fail without a partial write");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(directory, { recursive: true, force: true });
|
rmSync(directory, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user