feat: add onboarding command and migrate git diff to checkout API
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest";
|
||||
import { DaemonClient, type DaemonTransport } from "./daemon-client";
|
||||
|
||||
expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
|
||||
expectTypeOf<"getHighlightedDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
|
||||
|
||||
function createMockLogger() {
|
||||
return {
|
||||
debug: vi.fn(),
|
||||
|
||||
@@ -15,9 +15,7 @@ import type {
|
||||
CreateAgentRequestMessage,
|
||||
FileDownloadTokenResponse,
|
||||
FileExplorerResponse,
|
||||
GitDiffResponse,
|
||||
GitSetupOptions,
|
||||
HighlightedDiffResponse,
|
||||
CheckoutStatusResponse,
|
||||
CheckoutCommitResponse,
|
||||
CheckoutMergeResponse,
|
||||
@@ -178,8 +176,6 @@ export type CreateAgentRequestOptions = {
|
||||
labels?: Record<string, string>;
|
||||
} & AgentConfigOverrides;
|
||||
|
||||
type GitDiffPayload = GitDiffResponse["payload"];
|
||||
type HighlightedDiffPayload = HighlightedDiffResponse["payload"];
|
||||
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
type SubscribeCheckoutDiffPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
@@ -1907,60 +1903,6 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getGitDiff(
|
||||
agentId: string,
|
||||
requestId?: string
|
||||
): Promise<GitDiffPayload> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "git_diff_request",
|
||||
agentId,
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
return this.sendRequest({
|
||||
requestId: resolvedRequestId,
|
||||
message,
|
||||
timeout: 10000,
|
||||
options: { skipQueue: true },
|
||||
select: (msg) => {
|
||||
if (msg.type !== "git_diff_response") {
|
||||
return null;
|
||||
}
|
||||
if (msg.payload.requestId !== resolvedRequestId) {
|
||||
return null;
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getHighlightedDiff(
|
||||
agentId: string,
|
||||
requestId?: string
|
||||
): Promise<HighlightedDiffPayload> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "highlighted_diff_request",
|
||||
agentId,
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
return this.sendRequest({
|
||||
requestId: resolvedRequestId,
|
||||
message,
|
||||
timeout: 10000,
|
||||
options: { skipQueue: true },
|
||||
select: (msg) => {
|
||||
if (msg.type !== "highlighted_diff_response") {
|
||||
return null;
|
||||
}
|
||||
if (msg.payload.requestId !== resolvedRequestId) {
|
||||
return null;
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async validateBranch(
|
||||
options: { cwd: string; branchName: string },
|
||||
requestId?: string
|
||||
|
||||
@@ -229,17 +229,6 @@ export class TTSManager {
|
||||
isVoiceMode,
|
||||
},
|
||||
});
|
||||
this.logger.info(
|
||||
{
|
||||
audioId,
|
||||
chunkId,
|
||||
chunkIndex,
|
||||
isLastChunk: next.done,
|
||||
bytes: chunkBuffer.length,
|
||||
isVoiceMode,
|
||||
},
|
||||
"Emitted audio_output chunk to client"
|
||||
);
|
||||
|
||||
chunkIndex += 1;
|
||||
|
||||
@@ -298,15 +287,6 @@ export class TTSManager {
|
||||
}
|
||||
|
||||
pending.pendingChunks = Math.max(0, pending.pendingChunks - 1);
|
||||
this.logger.info(
|
||||
{
|
||||
chunkId,
|
||||
audioId,
|
||||
remainingPendingChunks: pending.pendingChunks,
|
||||
streamEnded: pending.streamEnded,
|
||||
},
|
||||
"Received audio playback confirmation from client"
|
||||
);
|
||||
|
||||
if (pending.pendingChunks === 0 && pending.streamEnded) {
|
||||
pending.resolve();
|
||||
|
||||
@@ -1012,63 +1012,11 @@ describe("daemon client E2E", () => {
|
||||
expect(checkoutStatus.isGit).toBe(true);
|
||||
expect(checkoutStatus.repoRoot).toContain(cwd);
|
||||
|
||||
const diffRequestId = `diff-${Date.now()}`;
|
||||
const diffMessagePromise = waitForSignal(15000, (resolve) => {
|
||||
const unsubscribeDiff = ctx.client.on("git_diff_response", (message) => {
|
||||
if (message.type !== "git_diff_response") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.agentId !== agent.id) {
|
||||
return;
|
||||
}
|
||||
if (message.payload.requestId !== diffRequestId) {
|
||||
return;
|
||||
}
|
||||
resolve(message);
|
||||
});
|
||||
return unsubscribeDiff;
|
||||
});
|
||||
|
||||
const diffResult = await ctx.client.getGitDiff(agent.id, diffRequestId);
|
||||
const diffMessage = await diffMessagePromise;
|
||||
const diffResult = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||
expect(diffResult.error).toBeNull();
|
||||
expect(diffResult.diff).toContain("test.txt");
|
||||
expect(diffResult.diff).toContain("-original content");
|
||||
expect(diffResult.diff).toContain("+modified content");
|
||||
expect(diffResult.requestId).toBe(diffRequestId);
|
||||
expect(diffMessage.payload.agentId).toBe(agent.id);
|
||||
expect(diffMessage.payload.requestId).toBe(diffRequestId);
|
||||
|
||||
const highlightRequestId = `highlight-${Date.now()}`;
|
||||
const highlightMessagePromise = waitForSignal(15000, (resolve) => {
|
||||
const unsubscribeHighlight = ctx.client.on(
|
||||
"highlighted_diff_response",
|
||||
(message) => {
|
||||
if (message.type !== "highlighted_diff_response") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.agentId !== agent.id) {
|
||||
return;
|
||||
}
|
||||
if (message.payload.requestId !== highlightRequestId) {
|
||||
return;
|
||||
}
|
||||
resolve(message);
|
||||
}
|
||||
);
|
||||
return unsubscribeHighlight;
|
||||
});
|
||||
|
||||
const highlightResult = await ctx.client.getHighlightedDiff(
|
||||
agent.id,
|
||||
highlightRequestId
|
||||
);
|
||||
const highlightMessage = await highlightMessagePromise;
|
||||
expect(highlightResult.error).toBeNull();
|
||||
expect(Array.isArray(highlightResult.files)).toBe(true);
|
||||
expect(highlightResult.requestId).toBe(highlightRequestId);
|
||||
expect(highlightMessage.payload.agentId).toBe(agent.id);
|
||||
expect(highlightMessage.payload.requestId).toBe(highlightRequestId);
|
||||
expect(Array.isArray(diffResult.files)).toBe(true);
|
||||
expect(diffResult.files.length).toBeGreaterThan(0);
|
||||
expect(diffResult.files.some((file) => file.path === "test.txt")).toBe(true);
|
||||
|
||||
const listRequestId = `list-${Date.now()}`;
|
||||
const listMessagePromise = waitForSignal(15000, (resolve) => {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { createDaemonTestContext } from "../test-utils/index.js";
|
||||
|
||||
const RUN = process.env.PASEO_GIT_DIFF_BOTTLENECK_E2E === "1";
|
||||
const LARGE_CHANGESET_SIZE = Number.parseInt(
|
||||
process.env.PASEO_GIT_DIFF_BOTTLENECK_FILE_COUNT ?? "1200",
|
||||
10
|
||||
);
|
||||
|
||||
function tmpRepo(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "paseo-git-diff-bottleneck-"));
|
||||
}
|
||||
|
||||
function initGitRepo(cwd: string): void {
|
||||
execSync("git init -b main", { cwd, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
|
||||
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
|
||||
}
|
||||
|
||||
function seedLargeDirtyRepo(cwd: string, fileCount: number): void {
|
||||
mkdirSync(path.join(cwd, "files"), { recursive: true });
|
||||
for (let i = 0; i < fileCount; i += 1) {
|
||||
writeFileSync(path.join(cwd, "files", `f-${i}.txt`), `line ${i}\n`);
|
||||
}
|
||||
execSync("git add .", { cwd, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'init'", {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
});
|
||||
for (let i = 0; i < fileCount; i += 1) {
|
||||
writeFileSync(path.join(cwd, "files", `f-${i}.txt`), `line ${i} changed\n`);
|
||||
}
|
||||
|
||||
// Explicit binary artifact to verify we do not diff binary contents.
|
||||
writeFileSync(path.join(cwd, "blob.bin"), Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00, 0x7f]));
|
||||
}
|
||||
|
||||
const runDescribe = RUN ? describe : describe.skip;
|
||||
|
||||
runDescribe("daemon E2E git diff bottleneck profiling", () => {
|
||||
test(
|
||||
"shows per-file git diff subprocess fanout and timeout pressure",
|
||||
async () => {
|
||||
const cwd = tmpRepo();
|
||||
|
||||
try {
|
||||
initGitRepo(cwd);
|
||||
seedLargeDirtyRepo(cwd, LARGE_CHANGESET_SIZE);
|
||||
|
||||
const cliStart = performance.now();
|
||||
const cliDiff = execSync("git diff HEAD", { cwd, stdio: "pipe" }).toString();
|
||||
const cliMs = performance.now() - cliStart;
|
||||
|
||||
const ctx = await createDaemonTestContext();
|
||||
try {
|
||||
const checkoutStart = performance.now();
|
||||
const checkoutPayload = await ctx.client.getCheckoutDiff(cwd, {
|
||||
mode: "uncommitted",
|
||||
});
|
||||
const checkoutMs = performance.now() - checkoutStart;
|
||||
|
||||
expect(checkoutPayload.error).toBeNull();
|
||||
expect(checkoutPayload.files.length).toBeGreaterThanOrEqual(LARGE_CHANGESET_SIZE);
|
||||
|
||||
const binaryEntry = checkoutPayload.files.find((file) => file.path === "blob.bin");
|
||||
expect(binaryEntry).toBeTruthy();
|
||||
expect(binaryEntry?.status).toBe("binary");
|
||||
|
||||
// Keep this visible in test output for local bottleneck analysis.
|
||||
console.info(
|
||||
"[git-diff-bottleneck]",
|
||||
JSON.stringify(
|
||||
{
|
||||
fileCount: LARGE_CHANGESET_SIZE,
|
||||
cliMs: Math.round(cliMs),
|
||||
cliDiffBytes: cliDiff.length,
|
||||
checkoutMs: Math.round(checkoutMs),
|
||||
checkoutFiles: checkoutPayload.files.length,
|
||||
speedRatio: Number((checkoutMs / Math.max(cliMs, 1)).toFixed(2)),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
expect(checkoutMs).toBeLessThan(cliMs * 10);
|
||||
} finally {
|
||||
await ctx.cleanup();
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
240000
|
||||
);
|
||||
});
|
||||
@@ -110,7 +110,7 @@ describe("daemon E2E", () => {
|
||||
await ctx.cleanup();
|
||||
}, 60000);
|
||||
|
||||
describe("getGitDiff", () => {
|
||||
describe("getCheckoutDiff", () => {
|
||||
test(
|
||||
"returns diff for modified file in git repo",
|
||||
async () => {
|
||||
@@ -134,28 +134,12 @@ describe("daemon E2E", () => {
|
||||
// Modify the file (creates unstaged changes)
|
||||
writeFileSync(testFile, "modified content\n");
|
||||
|
||||
// Create agent in the git repo
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Git Diff Test",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.status).toBe("idle");
|
||||
|
||||
// Get git diff
|
||||
const result = await ctx.client.getGitDiff(agent.id);
|
||||
|
||||
// Verify diff returned without error
|
||||
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.diff).toBeTruthy();
|
||||
expect(result.diff).toContain("test.txt");
|
||||
expect(result.diff).toContain("-original content");
|
||||
expect(result.diff).toContain("+modified content");
|
||||
|
||||
// Cleanup
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
expect(result.files.length).toBeGreaterThan(0);
|
||||
const file = result.files.find((entry) => entry.path === "test.txt");
|
||||
expect(file).toBeTruthy();
|
||||
expect(file?.hunks.length).toBeGreaterThan(0);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
60000 // 1 minute timeout
|
||||
@@ -181,23 +165,11 @@ describe("daemon E2E", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
// Create agent in the git repo (no modifications)
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Git Diff Clean Test",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
|
||||
// Get git diff - should be empty
|
||||
const result = await ctx.client.getGitDiff(agent.id);
|
||||
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.diff).toBe("");
|
||||
expect(result.files).toEqual([]);
|
||||
|
||||
// Cleanup
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
60000 // 1 minute timeout
|
||||
@@ -209,24 +181,12 @@ describe("daemon E2E", () => {
|
||||
const cwd = tmpCwd();
|
||||
// Don't initialize git - just a regular directory
|
||||
|
||||
// Create agent in a non-git directory
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Git Diff Non-Git Test",
|
||||
});
|
||||
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
|
||||
// Get git diff - should return error
|
||||
const result = await ctx.client.getGitDiff(agent.id);
|
||||
|
||||
expect(result.diff).toBe("");
|
||||
expect(result.files).toEqual([]);
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(result.error).toContain("git");
|
||||
expect(result.error?.code).toBe("NOT_GIT_REPO");
|
||||
|
||||
// Cleanup
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
60000 // 1 minute timeout
|
||||
|
||||
@@ -315,7 +315,7 @@ export class DictationStreamManager {
|
||||
state.bytesSinceCommit += resampled.length;
|
||||
state.peakSinceCommit = Math.max(state.peakSinceCommit, pcm16lePeakAbs(resampled));
|
||||
try {
|
||||
this.maybeAutoCommitDictationSegment(params.dictationId, state);
|
||||
this.maybeAutoCommitDictationSegment(state);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
void this.failAndCleanupDictationStream(params.dictationId, message, true);
|
||||
@@ -482,7 +482,7 @@ export class DictationStreamManager {
|
||||
this.streams.delete(dictationId);
|
||||
}
|
||||
|
||||
private maybeAutoCommitDictationSegment(dictationId: string, state: DictationStreamState): void {
|
||||
private maybeAutoCommitDictationSegment(state: DictationStreamState): void {
|
||||
if (state.finishRequested) {
|
||||
return;
|
||||
}
|
||||
@@ -490,29 +490,12 @@ export class DictationStreamManager {
|
||||
return;
|
||||
}
|
||||
if (state.peakSinceCommit < DICTATION_SILENCE_PEAK_THRESHOLD) {
|
||||
this.logger.debug(
|
||||
{
|
||||
dictationId,
|
||||
autoCommitBytes: state.autoCommitBytes,
|
||||
bytesSinceCommit: state.bytesSinceCommit,
|
||||
peakSinceCommit: state.peakSinceCommit,
|
||||
},
|
||||
"Dictation auto-segment: clearing silence-only segment"
|
||||
);
|
||||
state.stt.clear();
|
||||
state.bytesSinceCommit = 0;
|
||||
state.peakSinceCommit = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
{
|
||||
dictationId,
|
||||
autoCommitBytes: state.autoCommitBytes,
|
||||
bytesSinceCommit: state.bytesSinceCommit,
|
||||
},
|
||||
"Dictation auto-segment: committing buffered audio"
|
||||
);
|
||||
state.bytesSinceCommit = 0;
|
||||
state.peakSinceCommit = 0;
|
||||
state.stt.commit();
|
||||
|
||||
@@ -6,6 +6,13 @@ export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
|
||||
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
|
||||
export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js";
|
||||
export { DaemonClient, type DaemonClientConfig, type ConnectionState, type DaemonEvent } from "../client/daemon-client.js";
|
||||
export {
|
||||
ensureLocalSpeechModels,
|
||||
listLocalSpeechModels,
|
||||
type LocalSpeechModelId,
|
||||
type LocalSttModelId,
|
||||
type LocalTtsModelId,
|
||||
} from "./speech/providers/local/models.js";
|
||||
|
||||
// Agent SDK types for CLI commands
|
||||
export type {
|
||||
|
||||
@@ -41,6 +41,7 @@ const SpeechProviderIdSchema = z
|
||||
|
||||
const FeatureDictationSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
stt: z
|
||||
.object({
|
||||
provider: SpeechProviderIdSchema.optional(),
|
||||
@@ -54,6 +55,7 @@ const FeatureDictationSchema = z
|
||||
|
||||
const FeatureVoiceModeSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
llm: z
|
||||
.object({
|
||||
provider: z.enum(AGENT_PROVIDER_IDS as [string, ...string[]]).optional(),
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const wsMock = vi.hoisted(() => {
|
||||
class MockWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
static instances: MockWebSocket[] = [];
|
||||
|
||||
readonly url: string;
|
||||
readonly options: unknown;
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
sent: string[] = [];
|
||||
terminateCalls = 0;
|
||||
private listeners = new Map<string, Array<(...args: any[]) => void>>();
|
||||
|
||||
constructor(url: string, options?: unknown) {
|
||||
this.url = url;
|
||||
this.options = options;
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
static reset() {
|
||||
MockWebSocket.instances = [];
|
||||
}
|
||||
|
||||
on(event: string, listener: (...args: any[]) => void) {
|
||||
const handlers = this.listeners.get(event) ?? [];
|
||||
handlers.push(listener);
|
||||
this.listeners.set(event, handlers);
|
||||
return this;
|
||||
}
|
||||
|
||||
once(event: string, listener: (...args: any[]) => void) {
|
||||
const wrapped = (...args: any[]) => {
|
||||
this.off(event, wrapped);
|
||||
listener(...args);
|
||||
};
|
||||
return this.on(event, wrapped);
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string) {
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
this.emit("close", code ?? 1000, reason ?? "");
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.terminateCalls += 1;
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
this.emit("close", 1006, "");
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
if (this.readyState !== MockWebSocket.OPEN) {
|
||||
throw new Error(`WebSocket not open (readyState=${this.readyState})`);
|
||||
}
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
open() {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
this.emit("open");
|
||||
}
|
||||
|
||||
message(data: unknown) {
|
||||
this.emit("message", data);
|
||||
}
|
||||
|
||||
error(err: unknown) {
|
||||
this.emit("error", err);
|
||||
}
|
||||
|
||||
private off(event: string, listener: (...args: any[]) => void) {
|
||||
const handlers = this.listeners.get(event) ?? [];
|
||||
this.listeners.set(
|
||||
event,
|
||||
handlers.filter((handler) => handler !== listener)
|
||||
);
|
||||
}
|
||||
|
||||
private emit(event: string, ...args: any[]) {
|
||||
const handlers = this.listeners.get(event) ?? [];
|
||||
for (const handler of [...handlers]) {
|
||||
handler(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { MockWebSocket };
|
||||
});
|
||||
|
||||
vi.mock("ws", () => ({ default: wsMock.MockWebSocket }));
|
||||
|
||||
import { startRelayTransport } from "./relay-transport";
|
||||
|
||||
function createMockLogger() {
|
||||
const logger = {
|
||||
child: vi.fn(() => logger),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
return logger;
|
||||
}
|
||||
|
||||
function hasLogMessage(mockFn: ReturnType<typeof vi.fn>, message: string): boolean {
|
||||
return mockFn.mock.calls.some((call) => call.some((arg) => arg === message));
|
||||
}
|
||||
|
||||
describe("relay-transport control lifecycle", () => {
|
||||
const controllers: Array<{ stop: () => Promise<void> }> = [];
|
||||
const MockWebSocket = wsMock.MockWebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
MockWebSocket.reset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const controller of controllers) {
|
||||
await controller.stop();
|
||||
}
|
||||
controllers.length = 0;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("logs relay_control_connected only after first valid control message", () => {
|
||||
const logger = createMockLogger();
|
||||
const controller = startRelayTransport({
|
||||
logger: logger as any,
|
||||
attachSocket: async () => {},
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
serverId: "srv_test",
|
||||
});
|
||||
controllers.push(controller);
|
||||
|
||||
const control = MockWebSocket.instances[0];
|
||||
expect(control).toBeDefined();
|
||||
|
||||
control.open();
|
||||
expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(false);
|
||||
expect(control.sent.length).toBeGreaterThan(0);
|
||||
|
||||
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
|
||||
expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(true);
|
||||
});
|
||||
|
||||
test("terminates and reconnects when control socket opens but never becomes ready", () => {
|
||||
vi.useFakeTimers();
|
||||
const logger = createMockLogger();
|
||||
const controller = startRelayTransport({
|
||||
logger: logger as any,
|
||||
attachSocket: async () => {},
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
serverId: "srv_test",
|
||||
});
|
||||
controllers.push(controller);
|
||||
|
||||
const firstControl = MockWebSocket.instances[0];
|
||||
firstControl.open();
|
||||
|
||||
vi.advanceTimersByTime(8_000);
|
||||
expect(hasLogMessage(logger.warn, "relay_control_ready_timeout_terminating")).toBe(true);
|
||||
expect(firstControl.terminateCalls).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("terminates stale control sockets in under one minute", () => {
|
||||
vi.useFakeTimers();
|
||||
const logger = createMockLogger();
|
||||
const controller = startRelayTransport({
|
||||
logger: logger as any,
|
||||
attachSocket: async () => {},
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
serverId: "srv_test",
|
||||
});
|
||||
controllers.push(controller);
|
||||
|
||||
const control = MockWebSocket.instances[0];
|
||||
control.open();
|
||||
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
|
||||
logger.warn.mockClear();
|
||||
|
||||
vi.advanceTimersByTime(40_000);
|
||||
expect(hasLogMessage(logger.warn, "relay_control_stale_terminating")).toBe(true);
|
||||
expect(control.terminateCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,10 @@ type ControlMessage =
|
||||
| { type: "ping" }
|
||||
| { type: "pong" };
|
||||
|
||||
const CONTROL_PING_INTERVAL_MS = 10_000;
|
||||
const CONTROL_STALE_TIMEOUT_MS = 30_000;
|
||||
const CONTROL_READY_TIMEOUT_MS = 8_000;
|
||||
|
||||
function tryParseControlMessage(raw: unknown): ControlMessage | null {
|
||||
try {
|
||||
const text =
|
||||
@@ -76,7 +80,9 @@ export function startRelayTransport({
|
||||
let reconnectAttempt = 0;
|
||||
const dataSockets = new Map<string, WebSocket>(); // clientId -> ws
|
||||
let controlKeepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let controlReadyTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let controlLastSeenAt = 0;
|
||||
let controlConnectionSeq = 0;
|
||||
|
||||
const stop = async (): Promise<void> => {
|
||||
stopped = true;
|
||||
@@ -88,6 +94,10 @@ export function startRelayTransport({
|
||||
clearInterval(controlKeepaliveInterval);
|
||||
controlKeepaliveInterval = null;
|
||||
}
|
||||
if (controlReadyTimeout) {
|
||||
clearTimeout(controlReadyTimeout);
|
||||
controlReadyTimeout = null;
|
||||
}
|
||||
if (controlWs) {
|
||||
try {
|
||||
controlWs.close();
|
||||
@@ -109,6 +119,7 @@ export function startRelayTransport({
|
||||
const connectControl = (): void => {
|
||||
if (stopped) return;
|
||||
|
||||
const connectionId = ++controlConnectionSeq;
|
||||
const url = buildRelayWebSocketUrl({
|
||||
endpoint: relayEndpoint,
|
||||
serverId,
|
||||
@@ -116,14 +127,46 @@ export function startRelayTransport({
|
||||
});
|
||||
const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false });
|
||||
controlWs = socket;
|
||||
let controlConnected = false;
|
||||
|
||||
const markControlReady = () => {
|
||||
if (controlWs !== socket) return;
|
||||
if (controlConnected) return;
|
||||
controlConnected = true;
|
||||
reconnectAttempt = 0;
|
||||
if (controlReadyTimeout) {
|
||||
clearTimeout(controlReadyTimeout);
|
||||
controlReadyTimeout = null;
|
||||
}
|
||||
relayLogger.info({ url, connectionId }, "relay_control_connected");
|
||||
};
|
||||
|
||||
socket.on("open", () => {
|
||||
reconnectAttempt = 0;
|
||||
if (controlWs !== socket) return;
|
||||
|
||||
controlLastSeenAt = Date.now();
|
||||
if (controlKeepaliveInterval) {
|
||||
clearInterval(controlKeepaliveInterval);
|
||||
controlKeepaliveInterval = null;
|
||||
}
|
||||
if (controlReadyTimeout) {
|
||||
clearTimeout(controlReadyTimeout);
|
||||
controlReadyTimeout = null;
|
||||
}
|
||||
controlReadyTimeout = setTimeout(() => {
|
||||
if (stopped) return;
|
||||
if (controlWs !== socket) return;
|
||||
if (controlConnected) return;
|
||||
relayLogger.warn(
|
||||
{ url, connectionId, waitedMs: CONTROL_READY_TIMEOUT_MS },
|
||||
"relay_control_ready_timeout_terminating"
|
||||
);
|
||||
try {
|
||||
socket.terminate();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, CONTROL_READY_TIMEOUT_MS);
|
||||
controlKeepaliveInterval = setInterval(() => {
|
||||
if (stopped) return;
|
||||
if (controlWs !== socket) return;
|
||||
@@ -133,8 +176,11 @@ export function startRelayTransport({
|
||||
const staleForMs = now - controlLastSeenAt;
|
||||
// If the control socket is half-open or silently dropped, ws may never emit "close".
|
||||
// Use app-level ping/pong to detect staleness and force a reconnect.
|
||||
if (staleForMs > 90_000) {
|
||||
relayLogger.warn({ url, staleForMs }, "relay_control_stale_terminating");
|
||||
if (staleForMs > CONTROL_STALE_TIMEOUT_MS) {
|
||||
relayLogger.warn(
|
||||
{ url, staleForMs, connectionId, staleTimeoutMs: CONTROL_STALE_TIMEOUT_MS },
|
||||
"relay_control_stale_terminating"
|
||||
);
|
||||
try {
|
||||
socket.terminate();
|
||||
} catch {
|
||||
@@ -146,40 +192,58 @@ export function startRelayTransport({
|
||||
try {
|
||||
socket.send(JSON.stringify({ type: "ping", ts: now }));
|
||||
} catch (error) {
|
||||
relayLogger.warn({ err: error, url }, "relay_control_ping_send_failed");
|
||||
relayLogger.warn({ err: error, url, connectionId }, "relay_control_ping_send_failed");
|
||||
try {
|
||||
socket.terminate();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, 20_000);
|
||||
relayLogger.info({ url }, "relay_control_connected");
|
||||
}, CONTROL_PING_INTERVAL_MS);
|
||||
try {
|
||||
socket.send(JSON.stringify({ type: "ping", ts: Date.now() }));
|
||||
} catch (error) {
|
||||
relayLogger.warn({ err: error, url, connectionId }, "relay_control_ping_send_failed");
|
||||
try {
|
||||
socket.terminate();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
relayLogger.debug({ url, connectionId }, "relay_control_open_waiting_for_ready");
|
||||
});
|
||||
|
||||
socket.on("close", (code, reason) => {
|
||||
if (controlWs !== socket) return;
|
||||
relayLogger.warn(
|
||||
{ code, reason: reason?.toString?.(), url },
|
||||
{ code, reason: reason?.toString?.(), url, connectionId },
|
||||
"relay_control_disconnected"
|
||||
);
|
||||
if (controlWs === socket) {
|
||||
controlWs = null;
|
||||
}
|
||||
controlWs = null;
|
||||
if (controlKeepaliveInterval) {
|
||||
clearInterval(controlKeepaliveInterval);
|
||||
controlKeepaliveInterval = null;
|
||||
}
|
||||
if (controlReadyTimeout) {
|
||||
clearTimeout(controlReadyTimeout);
|
||||
controlReadyTimeout = null;
|
||||
}
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
socket.on("error", (err) => {
|
||||
relayLogger.warn({ err, url }, "relay_error");
|
||||
if (controlWs !== socket) return;
|
||||
relayLogger.warn({ err, url, connectionId }, "relay_error");
|
||||
// close event will schedule reconnect
|
||||
});
|
||||
|
||||
socket.on("message", (data) => {
|
||||
if (controlWs !== socket) return;
|
||||
controlLastSeenAt = Date.now();
|
||||
const msg = tryParseControlMessage(data);
|
||||
if (msg) {
|
||||
markControlReady();
|
||||
}
|
||||
if (!msg) return;
|
||||
if (msg.type === "ping") {
|
||||
try {
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
type ProjectPlacementPayload,
|
||||
} from "./messages.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import { parseAndHighlightDiff, type ParsedDiffFile } from "./utils/diff-highlighter.js";
|
||||
import { TTSManager } from "./agent/tts-manager.js";
|
||||
import { STTManager } from "./agent/stt-manager.js";
|
||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
||||
@@ -581,7 +580,7 @@ export class Session {
|
||||
void this.initializeAgentMcp();
|
||||
this.subscribeToAgentEvents();
|
||||
|
||||
this.sessionLogger.info("Session created");
|
||||
this.sessionLogger.trace("Session created");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -750,7 +749,7 @@ export class Session {
|
||||
|
||||
this.agentTools = (await this.agentMcpClient.tools()) as ToolSet;
|
||||
const agentToolCount = Object.keys(this.agentTools ?? {}).length;
|
||||
this.sessionLogger.info(
|
||||
this.sessionLogger.trace(
|
||||
{ agentToolCount },
|
||||
`Agent MCP initialized with ${agentToolCount} tools`
|
||||
);
|
||||
@@ -941,9 +940,17 @@ export class Session {
|
||||
agentId,
|
||||
extractTimestamps(record)
|
||||
);
|
||||
this.sessionLogger.info(
|
||||
{ agentId, provider: record.provider },
|
||||
"Agent resumed from persistence"
|
||||
);
|
||||
} else {
|
||||
const config = buildSessionConfig(record);
|
||||
snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels });
|
||||
this.sessionLogger.info(
|
||||
{ agentId, provider: record.provider },
|
||||
"Agent created from stored config"
|
||||
);
|
||||
}
|
||||
|
||||
await this.agentManager.hydrateTimelineFromProvider(agentId);
|
||||
@@ -1215,10 +1222,6 @@ export class Session {
|
||||
);
|
||||
break;
|
||||
|
||||
case "git_diff_request":
|
||||
await this.handleGitDiffRequest(msg.agentId, msg.requestId);
|
||||
break;
|
||||
|
||||
case "checkout_status_request":
|
||||
await this.handleCheckoutStatusRequest(msg);
|
||||
break;
|
||||
@@ -1267,10 +1270,6 @@ export class Session {
|
||||
await this.handlePaseoWorktreeArchiveRequest(msg);
|
||||
break;
|
||||
|
||||
case "highlighted_diff_request":
|
||||
await this.handleHighlightedDiffRequest(msg.agentId, msg.requestId);
|
||||
break;
|
||||
|
||||
case "file_explorer_request":
|
||||
await this.handleFileExplorerRequest(msg);
|
||||
break;
|
||||
@@ -2087,11 +2086,6 @@ export class Session {
|
||||
agentId: string,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
this.sessionLogger.info(
|
||||
{ agentId },
|
||||
`Initializing agent ${agentId} on demand`
|
||||
);
|
||||
|
||||
try {
|
||||
const snapshot = await this.ensureAgentLoaded(agentId);
|
||||
await this.forwardAgentUpdate(snapshot);
|
||||
@@ -2108,11 +2102,6 @@ export class Session {
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
|
||||
this.sessionLogger.info(
|
||||
{ agentId, timelineSize, status: snapshot.lifecycle },
|
||||
`Agent ${agentId} initialized with ${timelineSize} timeline item(s); status=${snapshot.lifecycle}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId },
|
||||
@@ -3393,66 +3382,6 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle git diff request for an agent
|
||||
*/
|
||||
private async handleGitDiffRequest(agentId: string, requestId: string): Promise<void> {
|
||||
this.sessionLogger.debug(
|
||||
{ agentId },
|
||||
`Handling git diff request for agent ${agentId}`
|
||||
);
|
||||
|
||||
try {
|
||||
const agents = this.agentManager.listAgents();
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
|
||||
if (!agent) {
|
||||
this.emit({
|
||||
type: "git_diff_response",
|
||||
payload: {
|
||||
agentId,
|
||||
diff: "",
|
||||
error: `Agent not found: ${agentId}`,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const diffResult = await getCheckoutDiff(agent.cwd, { mode: "uncommitted" }, { paseoHome: this.paseoHome });
|
||||
const combinedDiff = diffResult.diff;
|
||||
|
||||
this.emit({
|
||||
type: "git_diff_response",
|
||||
payload: {
|
||||
agentId,
|
||||
diff: combinedDiff,
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
|
||||
this.sessionLogger.debug(
|
||||
{ agentId, diffBytes: combinedDiff.length },
|
||||
`Git diff for agent ${agentId} completed (${combinedDiff.length} bytes)`
|
||||
);
|
||||
} catch (error: any) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId },
|
||||
`Failed to get git diff for agent ${agentId}`
|
||||
);
|
||||
this.emit({
|
||||
type: "git_diff_response",
|
||||
payload: {
|
||||
agentId,
|
||||
diff: "",
|
||||
error: error.message,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCheckoutStatusRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>
|
||||
): Promise<void> {
|
||||
@@ -4190,12 +4119,13 @@ export class Session {
|
||||
const { cwd, requestId } = msg;
|
||||
|
||||
try {
|
||||
const status = await getPullRequestStatus(cwd);
|
||||
const prStatus = await getPullRequestStatus(cwd);
|
||||
this.emit({
|
||||
type: "checkout_pr_status_response",
|
||||
payload: {
|
||||
cwd,
|
||||
status,
|
||||
status: prStatus.status,
|
||||
githubFeaturesEnabled: prStatus.githubFeaturesEnabled,
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
@@ -4206,6 +4136,7 @@ export class Session {
|
||||
payload: {
|
||||
cwd,
|
||||
status: null,
|
||||
githubFeaturesEnabled: true,
|
||||
error: this.toCheckoutError(error),
|
||||
requestId,
|
||||
},
|
||||
@@ -4373,237 +4304,6 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle highlighted diff request - returns parsed and syntax-highlighted diff
|
||||
*/
|
||||
private async handleHighlightedDiffRequest(
|
||||
agentId: string,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
this.sessionLogger.debug(
|
||||
{ agentId },
|
||||
`Handling highlighted diff request for agent ${agentId}`
|
||||
);
|
||||
|
||||
// Maximum lines changed before we skip showing the diff content
|
||||
const MAX_DIFF_LINES = 5000;
|
||||
|
||||
try {
|
||||
const agents = this.agentManager.listAgents();
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
|
||||
if (!agent) {
|
||||
this.emit({
|
||||
type: "highlighted_diff_response",
|
||||
payload: {
|
||||
agentId,
|
||||
files: [],
|
||||
error: `Agent not found: ${agentId}`,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: Get the list of changed files with their stats (numstat gives additions/deletions per file)
|
||||
const { stdout: numstatOutput } = await execAsync(
|
||||
"git diff --numstat HEAD",
|
||||
{ cwd: agent.cwd }
|
||||
);
|
||||
|
||||
// Get file statuses (A=added, D=deleted, M=modified) to detect deleted files
|
||||
const { stdout: nameStatusOutput } = await execAsync(
|
||||
"git diff --name-status HEAD",
|
||||
{ cwd: agent.cwd }
|
||||
);
|
||||
const deletedFiles = new Set<string>();
|
||||
const addedFiles = new Set<string>();
|
||||
for (const line of nameStatusOutput.trim().split("\n").filter(Boolean)) {
|
||||
const [status, ...pathParts] = line.split("\t");
|
||||
const path = pathParts.join("\t");
|
||||
if (status === "D") {
|
||||
deletedFiles.add(path);
|
||||
} else if (status === "A") {
|
||||
addedFiles.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse numstat output: "additions\tdeletions\tfilepath" or "-\t-\tfilepath" for binary
|
||||
interface FileStats {
|
||||
path: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
isBinary: boolean;
|
||||
isTracked: boolean;
|
||||
isDeleted: boolean;
|
||||
isNew: boolean;
|
||||
}
|
||||
const fileStats: FileStats[] = [];
|
||||
|
||||
for (const line of numstatOutput.trim().split("\n").filter(Boolean)) {
|
||||
const parts = line.split("\t");
|
||||
if (parts.length >= 3) {
|
||||
const [addStr, delStr, ...pathParts] = parts;
|
||||
const path = pathParts.join("\t"); // Handle paths with tabs
|
||||
const isBinary = addStr === "-" && delStr === "-";
|
||||
fileStats.push({
|
||||
path,
|
||||
additions: isBinary ? 0 : parseInt(addStr, 10),
|
||||
deletions: isBinary ? 0 : parseInt(delStr, 10),
|
||||
isBinary,
|
||||
isTracked: true,
|
||||
isDeleted: deletedFiles.has(path),
|
||||
isNew: addedFiles.has(path),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Get untracked files
|
||||
try {
|
||||
const { stdout: untrackedFiles } = await execAsync(
|
||||
"git ls-files --others --exclude-standard",
|
||||
{ cwd: agent.cwd }
|
||||
);
|
||||
for (const filePath of untrackedFiles.trim().split("\n").filter(Boolean)) {
|
||||
// Use git's numstat with --no-index to detect binary files (cross-platform)
|
||||
// Binary files show as "-\t-\tfilepath", text files show line counts
|
||||
try {
|
||||
const { stdout: numstatLine } = await execAsync(
|
||||
`git diff --numstat --no-index /dev/null "${filePath}" || true`,
|
||||
{ cwd: agent.cwd }
|
||||
);
|
||||
const parts = numstatLine.trim().split("\t");
|
||||
const isBinary = parts[0] === "-" && parts[1] === "-";
|
||||
const additions = isBinary ? 0 : (parseInt(parts[0], 10) || 0);
|
||||
|
||||
fileStats.push({
|
||||
path: filePath,
|
||||
additions,
|
||||
deletions: 0,
|
||||
isBinary,
|
||||
isTracked: false,
|
||||
isDeleted: false,
|
||||
isNew: true,
|
||||
});
|
||||
} catch {
|
||||
// If we can't determine, assume text and try to get it
|
||||
fileStats.push({
|
||||
path: filePath,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
isBinary: false,
|
||||
isTracked: false,
|
||||
isDeleted: false,
|
||||
isNew: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors getting untracked files
|
||||
}
|
||||
|
||||
// Step 3: Fetch diffs per-file, respecting limits
|
||||
const allFiles: ParsedDiffFile[] = [];
|
||||
|
||||
for (const stats of fileStats) {
|
||||
const totalLines = stats.additions + stats.deletions;
|
||||
|
||||
// Handle binary files
|
||||
if (stats.isBinary) {
|
||||
allFiles.push({
|
||||
path: stats.path,
|
||||
isNew: stats.isNew,
|
||||
isDeleted: stats.isDeleted,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
hunks: [],
|
||||
status: "binary",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle files that are too large
|
||||
if (totalLines > MAX_DIFF_LINES) {
|
||||
allFiles.push({
|
||||
path: stats.path,
|
||||
isNew: stats.isNew,
|
||||
isDeleted: stats.isDeleted,
|
||||
additions: stats.additions,
|
||||
deletions: stats.deletions,
|
||||
hunks: [],
|
||||
status: "too_large",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch the actual diff for this file
|
||||
try {
|
||||
let fileDiff: string;
|
||||
if (stats.isTracked) {
|
||||
const { stdout } = await execAsync(
|
||||
`git diff HEAD -- "${stats.path}"`,
|
||||
{ cwd: agent.cwd }
|
||||
);
|
||||
fileDiff = stdout;
|
||||
} else {
|
||||
const { stdout } = await execAsync(
|
||||
`git diff --no-index /dev/null "${stats.path}" || true`,
|
||||
{ cwd: agent.cwd }
|
||||
);
|
||||
fileDiff = stdout;
|
||||
}
|
||||
|
||||
if (fileDiff) {
|
||||
const parsedFiles = await parseAndHighlightDiff(fileDiff, agent.cwd);
|
||||
for (const file of parsedFiles) {
|
||||
allFiles.push({ ...file, status: "ok" });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If diff fails for this file, add it with empty hunks
|
||||
allFiles.push({
|
||||
path: stats.path,
|
||||
isNew: stats.isNew,
|
||||
isDeleted: stats.isDeleted,
|
||||
additions: stats.additions,
|
||||
deletions: stats.deletions,
|
||||
hunks: [],
|
||||
status: "ok",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: "highlighted_diff_response",
|
||||
payload: {
|
||||
agentId,
|
||||
files: allFiles,
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
|
||||
this.sessionLogger.debug(
|
||||
{ agentId, fileCount: allFiles.length },
|
||||
`Highlighted diff for agent ${agentId} completed (${allFiles.length} files)`
|
||||
);
|
||||
} catch (error: any) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId },
|
||||
`Failed to get highlighted diff for agent ${agentId}`
|
||||
);
|
||||
this.emit({
|
||||
type: "highlighted_diff_response",
|
||||
payload: {
|
||||
agentId,
|
||||
files: [],
|
||||
error: error.message,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle read-only file explorer requests scoped to an agent's cwd
|
||||
*/
|
||||
@@ -5871,7 +5571,7 @@ export class Session {
|
||||
* Clean up session resources
|
||||
*/
|
||||
public async cleanup(): Promise<void> {
|
||||
this.sessionLogger.info("Cleaning up");
|
||||
this.sessionLogger.trace("Cleaning up");
|
||||
|
||||
if (this.unsubscribeAgentEvents) {
|
||||
this.unsubscribeAgentEvents();
|
||||
|
||||
@@ -73,9 +73,10 @@ const LocalSpeechResolutionSchema = z.object({
|
||||
|
||||
function persistedLocalFeatureModel(
|
||||
provider: RequestedSpeechProviders[keyof RequestedSpeechProviders]["provider"],
|
||||
enabled: boolean | undefined,
|
||||
model: string | undefined
|
||||
): string | undefined {
|
||||
if (provider !== "local") {
|
||||
if (provider !== "local" || enabled === false) {
|
||||
return undefined;
|
||||
}
|
||||
return model;
|
||||
@@ -87,9 +88,12 @@ function shouldIncludeLocalProviderConfig(params: {
|
||||
persisted: PersistedConfig;
|
||||
}): boolean {
|
||||
const localRequestedByFeature =
|
||||
params.providers.dictationStt.provider === "local" ||
|
||||
params.providers.voiceStt.provider === "local" ||
|
||||
params.providers.voiceTts.provider === "local";
|
||||
(params.providers.dictationStt.enabled !== false &&
|
||||
params.providers.dictationStt.provider === "local") ||
|
||||
(params.providers.voiceStt.enabled !== false &&
|
||||
params.providers.voiceStt.provider === "local") ||
|
||||
(params.providers.voiceTts.enabled !== false &&
|
||||
params.providers.voiceTts.provider === "local");
|
||||
|
||||
return (
|
||||
localRequestedByFeature ||
|
||||
@@ -119,6 +123,7 @@ export function resolveLocalSpeechConfig(params: {
|
||||
params.env.PASEO_DICTATION_LOCAL_STT_MODEL ??
|
||||
persistedLocalFeatureModel(
|
||||
params.providers.dictationStt.provider,
|
||||
params.providers.dictationStt.enabled,
|
||||
params.persisted.features?.dictation?.stt?.model
|
||||
) ??
|
||||
DEFAULT_LOCAL_STT_MODEL,
|
||||
@@ -126,6 +131,7 @@ export function resolveLocalSpeechConfig(params: {
|
||||
params.env.PASEO_VOICE_LOCAL_STT_MODEL ??
|
||||
persistedLocalFeatureModel(
|
||||
params.providers.voiceStt.provider,
|
||||
params.providers.voiceStt.enabled,
|
||||
params.persisted.features?.voiceMode?.stt?.model
|
||||
) ??
|
||||
DEFAULT_LOCAL_STT_MODEL,
|
||||
@@ -133,6 +139,7 @@ export function resolveLocalSpeechConfig(params: {
|
||||
params.env.PASEO_VOICE_LOCAL_TTS_MODEL ??
|
||||
persistedLocalFeatureModel(
|
||||
params.providers.voiceTts.provider,
|
||||
params.providers.voiceTts.enabled,
|
||||
params.persisted.features?.voiceMode?.tts?.model
|
||||
) ??
|
||||
DEFAULT_LOCAL_TTS_MODEL,
|
||||
|
||||
@@ -88,13 +88,22 @@ function computeRequiredLocalModelIds(params: {
|
||||
models: ResolvedLocalModels;
|
||||
}): LocalSpeechModelId[] {
|
||||
const ids = new Set<LocalSpeechModelId>();
|
||||
if (params.providers.dictationStt.provider === "local") {
|
||||
if (
|
||||
params.providers.dictationStt.enabled !== false &&
|
||||
params.providers.dictationStt.provider === "local"
|
||||
) {
|
||||
ids.add(params.models.dictationLocalSttModel);
|
||||
}
|
||||
if (params.providers.voiceStt.provider === "local") {
|
||||
if (
|
||||
params.providers.voiceStt.enabled !== false &&
|
||||
params.providers.voiceStt.provider === "local"
|
||||
) {
|
||||
ids.add(params.models.voiceLocalSttModel);
|
||||
}
|
||||
if (params.providers.voiceTts.provider === "local") {
|
||||
if (
|
||||
params.providers.voiceTts.enabled !== false &&
|
||||
params.providers.voiceTts.provider === "local"
|
||||
) {
|
||||
ids.add(params.models.voiceLocalTtsModel);
|
||||
}
|
||||
return Array.from(ids);
|
||||
@@ -258,7 +267,7 @@ export async function initializeLocalSpeechServices(params: {
|
||||
}
|
||||
};
|
||||
|
||||
if (providers.voiceStt.provider === "local") {
|
||||
if (providers.voiceStt.enabled !== false && providers.voiceStt.provider === "local") {
|
||||
if (!localConfig) {
|
||||
logger.warn(
|
||||
{ configured: false },
|
||||
@@ -274,7 +283,7 @@ export async function initializeLocalSpeechServices(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (providers.dictationStt.provider === "local") {
|
||||
if (providers.dictationStt.enabled !== false && providers.dictationStt.provider === "local") {
|
||||
if (!localConfig) {
|
||||
logger.warn(
|
||||
{ configured: false },
|
||||
@@ -297,7 +306,7 @@ export async function initializeLocalSpeechServices(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (providers.voiceTts.provider === "local") {
|
||||
if (providers.voiceTts.enabled !== false && providers.voiceTts.provider === "local") {
|
||||
if (!localConfig) {
|
||||
logger.warn(
|
||||
{ configured: false },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -53,4 +53,46 @@ describe("sherpa model downloader", () => {
|
||||
})
|
||||
).rejects.toThrow(/auto-download/i);
|
||||
});
|
||||
|
||||
test("ensureSherpaOnnxModel logs artifact download progress", async () => {
|
||||
const modelsDir = makeTmpDir();
|
||||
const progressLogs: Array<Record<string, unknown>> = [];
|
||||
|
||||
const loggerWithSpy = {
|
||||
child: () => loggerWithSpy,
|
||||
info: (obj?: unknown, msg?: string) => {
|
||||
if (msg === "Downloading model artifact" && obj && typeof obj === "object") {
|
||||
progressLogs.push(obj as Record<string, unknown>);
|
||||
}
|
||||
},
|
||||
error: () => undefined,
|
||||
} as unknown as pino.Logger;
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const payload = Buffer.alloc(128 * 1024, 7);
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: { "content-length": String(payload.length) },
|
||||
});
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
try {
|
||||
await ensureSherpaOnnxModel({
|
||||
modelsDir,
|
||||
modelId: "pocket-tts-onnx-int8",
|
||||
autoDownload: true,
|
||||
logger: loggerWithSpy,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
expect(progressLogs.length).toBeGreaterThan(0);
|
||||
const final = progressLogs.at(-1);
|
||||
expect(final?.modelId).toBe("pocket-tts-onnx-int8");
|
||||
expect(final?.pct).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { mkdir, rename, rm, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { spawn } from "node:child_process";
|
||||
import type pino from "pino";
|
||||
@@ -39,7 +39,31 @@ async function hasRequiredFiles(modelDir: string, requiredFiles: string[]): Prom
|
||||
return true;
|
||||
}
|
||||
|
||||
async function downloadToFile(url: string, outputPath: string, logger: pino.Logger): Promise<void> {
|
||||
const UNKNOWN_SIZE_PROGRESS_BYTES_STEP = 5 * 1024 * 1024;
|
||||
const UNKNOWN_SIZE_PROGRESS_MS_STEP = 1000;
|
||||
|
||||
type DownloadToFileOptions = {
|
||||
url: string;
|
||||
outputPath: string;
|
||||
logger: pino.Logger;
|
||||
modelId: SherpaOnnxModelId;
|
||||
artifact: string;
|
||||
};
|
||||
|
||||
function parseContentLength(res: Response): number | null {
|
||||
const raw = res.headers.get("content-length");
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
|
||||
const { url, outputPath, logger, modelId, artifact } = options;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`);
|
||||
@@ -51,24 +75,68 @@ async function downloadToFile(url: string, outputPath: string, logger: pino.Logg
|
||||
const tmpPath = `${outputPath}.tmp-${Date.now()}`;
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
const total = Number(res.headers.get("content-length") ?? "0");
|
||||
let downloaded = 0;
|
||||
let lastLoggedBucket = -1;
|
||||
const nodeStream = Readable.fromWeb(res.body as any);
|
||||
const totalBytes = parseContentLength(res);
|
||||
|
||||
const nodeStream = Readable.fromWeb(res.body as any).on("data", (chunk: Buffer) => {
|
||||
downloaded += chunk.length;
|
||||
if (total > 0) {
|
||||
const pct = Math.floor((downloaded / total) * 100);
|
||||
const bucket = Math.min(100, Math.floor(pct / 10) * 10);
|
||||
if (bucket >= 0 && bucket <= 100 && bucket !== lastLoggedBucket) {
|
||||
lastLoggedBucket = bucket;
|
||||
logger.info({ pct: bucket, downloaded, total }, "Downloading model artifact");
|
||||
let downloadedBytes = 0;
|
||||
let lastLoggedPct = -1;
|
||||
let lastLoggedBytes = 0;
|
||||
let lastLoggedAt = 0;
|
||||
|
||||
const logProgress = (force: boolean): void => {
|
||||
const pct =
|
||||
totalBytes && totalBytes > 0
|
||||
? Math.min(100, Math.floor((downloadedBytes * 100) / totalBytes))
|
||||
: null;
|
||||
|
||||
if (!force) {
|
||||
if (pct !== null) {
|
||||
if (pct <= lastLoggedPct) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const now = Date.now();
|
||||
const advancedBytes = downloadedBytes - lastLoggedBytes;
|
||||
if (advancedBytes < UNKNOWN_SIZE_PROGRESS_BYTES_STEP && now - lastLoggedAt < UNKNOWN_SIZE_PROGRESS_MS_STEP) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
modelId,
|
||||
artifact,
|
||||
url,
|
||||
downloadedBytes,
|
||||
totalBytes,
|
||||
pct,
|
||||
},
|
||||
"Downloading model artifact"
|
||||
);
|
||||
lastLoggedPct = pct ?? -1;
|
||||
lastLoggedBytes = downloadedBytes;
|
||||
lastLoggedAt = Date.now();
|
||||
};
|
||||
|
||||
logProgress(false);
|
||||
|
||||
const progressStream = new Transform({
|
||||
transform(chunk: Buffer, _encoding, callback) {
|
||||
downloadedBytes += chunk.length;
|
||||
logProgress(false);
|
||||
callback(null, chunk);
|
||||
},
|
||||
});
|
||||
|
||||
await pipeline(nodeStream, createWriteStream(tmpPath));
|
||||
await rename(tmpPath, outputPath);
|
||||
try {
|
||||
await pipeline(nodeStream, progressStream, createWriteStream(tmpPath));
|
||||
logProgress(true);
|
||||
await rename(tmpPath, outputPath);
|
||||
} catch (error) {
|
||||
await rm(tmpPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function extractTarArchive(archivePath: string, destDir: string): Promise<void> {
|
||||
@@ -114,60 +182,103 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption
|
||||
);
|
||||
}
|
||||
|
||||
if (spec.archiveUrl) {
|
||||
logger.info({ modelsDir: options.modelsDir, url: spec.archiveUrl }, "Model files missing; downloading");
|
||||
logger.info({ modelsDir: options.modelsDir }, "Starting model download");
|
||||
|
||||
const downloadsDir = path.join(options.modelsDir, ".downloads");
|
||||
const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname);
|
||||
const archivePath = path.join(downloadsDir, archiveFilename);
|
||||
try {
|
||||
if (spec.archiveUrl) {
|
||||
const downloadsDir = path.join(options.modelsDir, ".downloads");
|
||||
const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname);
|
||||
const archivePath = path.join(downloadsDir, archiveFilename);
|
||||
|
||||
if (!(await isNonEmptyFile(archivePath))) {
|
||||
await downloadToFile(spec.archiveUrl, archivePath, logger);
|
||||
} else {
|
||||
logger.info({ archivePath }, "Using cached archive");
|
||||
}
|
||||
|
||||
await extractTarArchive(archivePath, options.modelsDir);
|
||||
|
||||
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
|
||||
throw new Error(
|
||||
`Downloaded and extracted ${archiveFilename}, but required files are still missing in ${modelDir}.`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(archivePath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
logger.info({ modelDir }, "Model ready");
|
||||
return modelDir;
|
||||
}
|
||||
|
||||
if (spec.downloadFiles && spec.downloadFiles.length > 0) {
|
||||
logger.info({ modelsDir: options.modelsDir, fileCount: spec.downloadFiles.length }, "Model files missing; downloading");
|
||||
await mkdir(modelDir, { recursive: true });
|
||||
|
||||
for (const file of spec.downloadFiles) {
|
||||
const dst = path.join(modelDir, file.relPath);
|
||||
if (await isNonEmptyFile(dst)) {
|
||||
continue;
|
||||
if (!(await isNonEmptyFile(archivePath))) {
|
||||
await downloadToFile({
|
||||
url: spec.archiveUrl,
|
||||
outputPath: archivePath,
|
||||
logger,
|
||||
modelId: options.modelId,
|
||||
artifact: archiveFilename,
|
||||
});
|
||||
}
|
||||
await downloadToFile(file.url, dst, logger);
|
||||
}
|
||||
|
||||
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
|
||||
throw new Error(
|
||||
`Downloaded files for ${options.modelId}, but required files are still missing in ${modelDir}.`
|
||||
logger.info(
|
||||
{
|
||||
modelId: options.modelId,
|
||||
archivePath,
|
||||
modelDir,
|
||||
},
|
||||
"Extracting model archive"
|
||||
);
|
||||
await extractTarArchive(archivePath, options.modelsDir);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
modelId: options.modelId,
|
||||
modelDir,
|
||||
},
|
||||
"Verifying downloaded model files"
|
||||
);
|
||||
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
|
||||
throw new Error(
|
||||
`Downloaded and extracted ${archiveFilename}, but required files are still missing in ${modelDir}.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
modelId: options.modelId,
|
||||
archivePath,
|
||||
},
|
||||
"Finalizing model artifacts"
|
||||
);
|
||||
try {
|
||||
await rm(archivePath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
logger.info({ modelDir }, "Model download completed");
|
||||
return modelDir;
|
||||
}
|
||||
|
||||
logger.info({ modelDir }, "Model ready");
|
||||
return modelDir;
|
||||
}
|
||||
if (spec.downloadFiles && spec.downloadFiles.length > 0) {
|
||||
await mkdir(modelDir, { recursive: true });
|
||||
|
||||
throw new Error(`Model spec for ${options.modelId} has no archiveUrl or downloadFiles`);
|
||||
for (const file of spec.downloadFiles) {
|
||||
const dst = path.join(modelDir, file.relPath);
|
||||
if (await isNonEmptyFile(dst)) {
|
||||
continue;
|
||||
}
|
||||
await downloadToFile({
|
||||
url: file.url,
|
||||
outputPath: dst,
|
||||
logger,
|
||||
modelId: options.modelId,
|
||||
artifact: file.relPath,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
modelId: options.modelId,
|
||||
modelDir,
|
||||
},
|
||||
"Verifying downloaded model files"
|
||||
);
|
||||
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
|
||||
throw new Error(
|
||||
`Downloaded files for ${options.modelId}, but required files are still missing in ${modelDir}.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info({ modelDir }, "Model download completed");
|
||||
return modelDir;
|
||||
}
|
||||
|
||||
throw new Error(`Model spec for ${options.modelId} has no archiveUrl or downloadFiles`);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Model download failed");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureSherpaOnnxModels(options: {
|
||||
|
||||
@@ -74,27 +74,32 @@ export function resolveOpenAiSpeechConfig(params: {
|
||||
params.persisted.features?.dictation?.stt?.confidenceThreshold,
|
||||
sttModel:
|
||||
params.env.STT_MODEL ??
|
||||
(params.providers.voiceStt.provider === "openai"
|
||||
(params.providers.voiceStt.enabled !== false &&
|
||||
params.providers.voiceStt.provider === "openai"
|
||||
? params.persisted.features?.voiceMode?.stt?.model
|
||||
: undefined) ??
|
||||
(params.providers.dictationStt.provider === "openai"
|
||||
(params.providers.dictationStt.enabled !== false &&
|
||||
params.providers.dictationStt.provider === "openai"
|
||||
? params.persisted.features?.dictation?.stt?.model
|
||||
: undefined),
|
||||
ttsVoice:
|
||||
params.env.TTS_VOICE ??
|
||||
(params.providers.voiceTts.provider === "openai"
|
||||
(params.providers.voiceTts.enabled !== false &&
|
||||
params.providers.voiceTts.provider === "openai"
|
||||
? params.persisted.features?.voiceMode?.tts?.voice
|
||||
: undefined) ??
|
||||
"alloy",
|
||||
ttsModel:
|
||||
params.env.TTS_MODEL ??
|
||||
(params.providers.voiceTts.provider === "openai"
|
||||
(params.providers.voiceTts.enabled !== false &&
|
||||
params.providers.voiceTts.provider === "openai"
|
||||
? params.persisted.features?.voiceMode?.tts?.model
|
||||
: undefined) ??
|
||||
DEFAULT_OPENAI_TTS_MODEL,
|
||||
realtimeTranscriptionModel:
|
||||
params.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ??
|
||||
(params.providers.dictationStt.provider === "openai"
|
||||
(params.providers.dictationStt.enabled !== false &&
|
||||
params.providers.dictationStt.provider === "openai"
|
||||
? params.persisted.features?.dictation?.stt?.model
|
||||
: undefined) ??
|
||||
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
|
||||
|
||||
@@ -60,13 +60,22 @@ export function validateOpenAiCredentialRequirements(params: {
|
||||
const openAiCredentials = resolveOpenAiCredentials(openaiConfig);
|
||||
|
||||
const missingOpenAiCredentialsFor: string[] = [];
|
||||
if (providers.voiceStt.provider === "openai" && !openAiCredentials.openaiSttApiKey) {
|
||||
if (
|
||||
providers.voiceStt.enabled !== false &&
|
||||
providers.voiceStt.provider === "openai" &&
|
||||
!openAiCredentials.openaiSttApiKey
|
||||
) {
|
||||
missingOpenAiCredentialsFor.push("voice.stt");
|
||||
}
|
||||
if (providers.voiceTts.provider === "openai" && !openAiCredentials.openaiTtsApiKey) {
|
||||
if (
|
||||
providers.voiceTts.enabled !== false &&
|
||||
providers.voiceTts.provider === "openai" &&
|
||||
!openAiCredentials.openaiTtsApiKey
|
||||
) {
|
||||
missingOpenAiCredentialsFor.push("voice.tts");
|
||||
}
|
||||
if (
|
||||
providers.dictationStt.enabled !== false &&
|
||||
providers.dictationStt.provider === "openai" &&
|
||||
!openAiCredentials.openaiDictationApiKey
|
||||
) {
|
||||
@@ -104,10 +113,18 @@ export function initializeOpenAiSpeechServices(params: {
|
||||
let ttsService = existing.ttsService;
|
||||
let dictationSttService = existing.dictationSttService;
|
||||
|
||||
const needsOpenAiStt = !sttService && providers.voiceStt.provider === "openai";
|
||||
const needsOpenAiTts = !ttsService && providers.voiceTts.provider === "openai";
|
||||
const needsOpenAiStt =
|
||||
!sttService &&
|
||||
providers.voiceStt.enabled !== false &&
|
||||
providers.voiceStt.provider === "openai";
|
||||
const needsOpenAiTts =
|
||||
!ttsService &&
|
||||
providers.voiceTts.enabled !== false &&
|
||||
providers.voiceTts.provider === "openai";
|
||||
const needsOpenAiDictation =
|
||||
!dictationSttService && providers.dictationStt.provider === "openai";
|
||||
!dictationSttService &&
|
||||
providers.dictationStt.enabled !== false &&
|
||||
providers.dictationStt.provider === "openai";
|
||||
|
||||
if (
|
||||
(needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) &&
|
||||
|
||||
@@ -21,14 +21,17 @@ describe("resolveSpeechConfig", () => {
|
||||
expect(result.speech.providers.dictationStt).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceStt).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceTts).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.local).toEqual({
|
||||
modelsDir: path.join(paseoHome, "models", "local-speech"),
|
||||
@@ -91,14 +94,17 @@ describe("resolveSpeechConfig", () => {
|
||||
expect(result.speech.providers.dictationStt).toEqual({
|
||||
provider: "local",
|
||||
explicit: true,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceStt).toEqual({
|
||||
provider: "openai",
|
||||
explicit: true,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceTts).toEqual({
|
||||
provider: "local",
|
||||
explicit: true,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.local?.models.dictationStt).toBe("zipformer-bilingual-zh-en-2023-02-20");
|
||||
expect(result.speech.local?.models.voiceStt).toBe("parakeet-tdt-0.6b-v3-int8");
|
||||
@@ -127,4 +133,35 @@ describe("resolveSpeechConfig", () => {
|
||||
expect(result.speech.local?.models.voiceTts).toBe("kokoro-en-v0_19");
|
||||
expect(result.speech.local?.models.voiceTtsSpeakerId).toBe(0);
|
||||
});
|
||||
|
||||
test("respects disabled dictation and voice mode feature flags", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
features: {
|
||||
dictation: { enabled: false },
|
||||
voiceMode: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSpeechConfig({
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persisted,
|
||||
});
|
||||
|
||||
expect(result.speech.providers.dictationStt).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: false,
|
||||
});
|
||||
expect(result.speech.providers.voiceStt).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: false,
|
||||
});
|
||||
expect(result.speech.providers.voiceTts).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,25 @@ const OptionalSpeechProviderSchema = z
|
||||
.pipe(SpeechProviderIdSchema)
|
||||
.optional();
|
||||
|
||||
const OptionalBooleanFlagSchema = z
|
||||
.union([z.boolean(), z.string().trim().toLowerCase()])
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (["1", "true", "yes", "y", "on"].includes(value)) {
|
||||
return true;
|
||||
}
|
||||
if (["0", "false", "no", "n", "off"].includes(value)) {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const RequestedSpeechProvidersSchema = z.object({
|
||||
dictationStt: OptionalSpeechProviderSchema.default("local"),
|
||||
voiceStt: OptionalSpeechProviderSchema.default("local"),
|
||||
@@ -29,10 +48,12 @@ function resolveRequestedSpeechProviders(params: {
|
||||
}): RequestedSpeechProviders {
|
||||
const resolveFeatureProvider = (
|
||||
configuredValue: string | undefined,
|
||||
parsedValue: z.infer<typeof SpeechProviderIdSchema>
|
||||
parsedValue: z.infer<typeof SpeechProviderIdSchema>,
|
||||
enabled: boolean
|
||||
): RequestedSpeechProvider => ({
|
||||
provider: parsedValue,
|
||||
explicit: configuredValue !== undefined,
|
||||
enabled,
|
||||
});
|
||||
|
||||
const dictationSttProviderFromConfig =
|
||||
@@ -44,6 +65,14 @@ function resolveRequestedSpeechProviders(params: {
|
||||
const voiceTtsProviderFromConfig =
|
||||
params.env.PASEO_VOICE_TTS_PROVIDER ??
|
||||
params.persisted.features?.voiceMode?.tts?.provider;
|
||||
const dictationEnabled =
|
||||
OptionalBooleanFlagSchema.parse(
|
||||
params.env.PASEO_DICTATION_ENABLED ?? params.persisted.features?.dictation?.enabled
|
||||
) ?? true;
|
||||
const voiceModeEnabled =
|
||||
OptionalBooleanFlagSchema.parse(
|
||||
params.env.PASEO_VOICE_MODE_ENABLED ?? params.persisted.features?.voiceMode?.enabled
|
||||
) ?? true;
|
||||
|
||||
const parsed = RequestedSpeechProvidersSchema.parse({
|
||||
dictationStt: dictationSttProviderFromConfig ?? "local",
|
||||
@@ -54,15 +83,18 @@ function resolveRequestedSpeechProviders(params: {
|
||||
return {
|
||||
dictationStt: resolveFeatureProvider(
|
||||
dictationSttProviderFromConfig,
|
||||
parsed.dictationStt
|
||||
parsed.dictationStt,
|
||||
dictationEnabled
|
||||
),
|
||||
voiceStt: resolveFeatureProvider(
|
||||
voiceSttProviderFromConfig,
|
||||
parsed.voiceStt
|
||||
parsed.voiceStt,
|
||||
voiceModeEnabled
|
||||
),
|
||||
voiceTts: resolveFeatureProvider(
|
||||
voiceTtsProviderFromConfig,
|
||||
parsed.voiceTts
|
||||
parsed.voiceTts,
|
||||
voiceModeEnabled
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ function resolveRequestedSpeechProviders(
|
||||
}
|
||||
|
||||
return {
|
||||
dictationStt: { provider: "local", explicit: false },
|
||||
voiceStt: { provider: "local", explicit: false },
|
||||
voiceTts: { provider: "local", explicit: false },
|
||||
dictationStt: { provider: "local", explicit: false, enabled: true },
|
||||
voiceStt: { provider: "local", explicit: false, enabled: true },
|
||||
voiceTts: { provider: "local", explicit: false, enabled: true },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,9 +59,18 @@ export async function initializeSpeechRuntime(params: {
|
||||
logger.info(
|
||||
{
|
||||
requestedProviders: {
|
||||
dictationStt: providers.dictationStt.provider,
|
||||
voiceStt: providers.voiceStt.provider,
|
||||
voiceTts: providers.voiceTts.provider,
|
||||
dictationStt: {
|
||||
provider: providers.dictationStt.provider,
|
||||
enabled: providers.dictationStt.enabled !== false,
|
||||
},
|
||||
voiceStt: {
|
||||
provider: providers.voiceStt.provider,
|
||||
enabled: providers.voiceStt.enabled !== false,
|
||||
},
|
||||
voiceTts: {
|
||||
provider: providers.voiceTts.provider,
|
||||
enabled: providers.voiceTts.enabled !== false,
|
||||
},
|
||||
},
|
||||
availability: {
|
||||
openai: getOpenAiSpeechAvailability(openaiConfig),
|
||||
@@ -99,9 +108,11 @@ export async function initializeSpeechRuntime(params: {
|
||||
: "openai",
|
||||
};
|
||||
const unavailableFeatures = [
|
||||
!openAiSpeech.dictationSttService ? "dictation.stt" : null,
|
||||
!openAiSpeech.sttService ? "voice.stt" : null,
|
||||
!openAiSpeech.ttsService ? "voice.tts" : null,
|
||||
providers.dictationStt.enabled !== false && !openAiSpeech.dictationSttService
|
||||
? "dictation.stt"
|
||||
: null,
|
||||
providers.voiceStt.enabled !== false && !openAiSpeech.sttService ? "voice.stt" : null,
|
||||
providers.voiceTts.enabled !== false && !openAiSpeech.ttsService ? "voice.tts" : null,
|
||||
].filter((feature): feature is string => feature !== null);
|
||||
const explicitlyConfiguredUnavailableFeatures = unavailableFeatures.filter((feature) => {
|
||||
if (feature === "dictation.stt") {
|
||||
@@ -117,9 +128,18 @@ export async function initializeSpeechRuntime(params: {
|
||||
logger.error(
|
||||
{
|
||||
requestedProviders: {
|
||||
dictationStt: providers.dictationStt.provider,
|
||||
voiceStt: providers.voiceStt.provider,
|
||||
voiceTts: providers.voiceTts.provider,
|
||||
dictationStt: {
|
||||
provider: providers.dictationStt.provider,
|
||||
enabled: providers.dictationStt.enabled !== false,
|
||||
},
|
||||
voiceStt: {
|
||||
provider: providers.voiceStt.provider,
|
||||
enabled: providers.voiceStt.enabled !== false,
|
||||
},
|
||||
voiceTts: {
|
||||
provider: providers.voiceTts.provider,
|
||||
enabled: providers.voiceTts.enabled !== false,
|
||||
},
|
||||
},
|
||||
explicitProviders: {
|
||||
dictationStt: providers.dictationStt.explicit,
|
||||
@@ -140,9 +160,18 @@ export async function initializeSpeechRuntime(params: {
|
||||
logger.warn(
|
||||
{
|
||||
requestedProviders: {
|
||||
dictationStt: providers.dictationStt.provider,
|
||||
voiceStt: providers.voiceStt.provider,
|
||||
voiceTts: providers.voiceTts.provider,
|
||||
dictationStt: {
|
||||
provider: providers.dictationStt.provider,
|
||||
enabled: providers.dictationStt.enabled !== false,
|
||||
},
|
||||
voiceStt: {
|
||||
provider: providers.voiceStt.provider,
|
||||
enabled: providers.voiceStt.enabled !== false,
|
||||
},
|
||||
voiceTts: {
|
||||
provider: providers.voiceTts.provider,
|
||||
enabled: providers.voiceTts.enabled !== false,
|
||||
},
|
||||
},
|
||||
explicitProviders: {
|
||||
dictationStt: providers.dictationStt.explicit,
|
||||
|
||||
@@ -6,6 +6,7 @@ export type SpeechProviderId = z.infer<typeof SpeechProviderIdSchema>;
|
||||
export const RequestedSpeechProviderSchema = z.object({
|
||||
provider: SpeechProviderIdSchema,
|
||||
explicit: z.boolean(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
export type RequestedSpeechProvider = z.infer<typeof RequestedSpeechProviderSchema>;
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
})
|
||||
);
|
||||
|
||||
connectionLogger.info(
|
||||
connectionLogger.trace(
|
||||
{ clientId, totalSessions: this.sessions.size },
|
||||
"Client connected"
|
||||
);
|
||||
@@ -319,7 +319,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
const session = this.sessions.get(ws);
|
||||
if (!session) return;
|
||||
|
||||
connectionLogger.info(
|
||||
connectionLogger.trace(
|
||||
{ clientId, totalSessions: this.sessions.size - 1 },
|
||||
"Client disconnected"
|
||||
);
|
||||
@@ -386,28 +386,12 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
const message = parsedMessage.data;
|
||||
|
||||
const messageSummary = {
|
||||
type: message.type,
|
||||
...(message.type === "session" && message.message
|
||||
? { sessionMessageType: message.message.type }
|
||||
: {}),
|
||||
};
|
||||
const isSessionNoise =
|
||||
message.type === "session" &&
|
||||
(message.message.type === "client_heartbeat" ||
|
||||
message.message.type === "voice_audio_chunk" ||
|
||||
message.message.type === "dictation_stream_chunk");
|
||||
if (!isSessionNoise) {
|
||||
this.logger.debug(messageSummary, "Received message");
|
||||
}
|
||||
|
||||
if (message.type === "ping") {
|
||||
this.sendToClient(ws, { type: "pong" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "recording_state") {
|
||||
this.logger.debug({ isRecording: message.isRecording }, "Recording state");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -418,17 +402,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
|
||||
if (message.type === "session") {
|
||||
if (message.message.type === "create_agent_request") {
|
||||
this.logger.debug(
|
||||
{
|
||||
cwd: message.message.config.cwd,
|
||||
initialMode: message.message.config.modeId,
|
||||
worktreeName: message.message.worktreeName,
|
||||
requestId: message.message.requestId,
|
||||
},
|
||||
"create_agent_request details"
|
||||
);
|
||||
}
|
||||
await session.handleMessage(message.message);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -502,23 +475,11 @@ export class VoiceAssistantWebSocketServer {
|
||||
} {
|
||||
const activity = session.getClientActivity();
|
||||
if (!activity) {
|
||||
this.logger.debug("getClientActivityState: no activity for session");
|
||||
return { deviceType: null, focusedAgentId: null, isStale: true, appVisible: false };
|
||||
}
|
||||
const now = Date.now();
|
||||
const ageMs = now - activity.lastActivityAt.getTime();
|
||||
const isStale = ageMs >= this.ACTIVITY_THRESHOLD_MS;
|
||||
this.logger.debug(
|
||||
{
|
||||
deviceType: activity.deviceType,
|
||||
focusedAgentId: activity.focusedAgentId,
|
||||
lastActivityAt: activity.lastActivityAt.toISOString(),
|
||||
ageMs,
|
||||
isStale,
|
||||
appVisible: activity.appVisible,
|
||||
},
|
||||
"getClientActivityState"
|
||||
);
|
||||
return {
|
||||
deviceType: activity.deviceType,
|
||||
focusedAgentId: activity.focusedAgentId,
|
||||
@@ -603,16 +564,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
const allStates = clientEntries.map((e) => e.state);
|
||||
|
||||
this.logger.debug(
|
||||
{
|
||||
agentId: params.agentId,
|
||||
reason: params.reason,
|
||||
clientCount: clientEntries.length,
|
||||
allStates,
|
||||
},
|
||||
"broadcastAgentAttention"
|
||||
);
|
||||
|
||||
const hasActiveWebClient = allStates.some(
|
||||
(state) => state.deviceType === "web" && !state.isStale
|
||||
);
|
||||
@@ -627,11 +578,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
!hasActiveWebClient &&
|
||||
!hasActiveMobileForegroundClient;
|
||||
|
||||
this.logger.debug(
|
||||
{ hasActiveWebClient, hasActiveMobileForegroundClient, shouldSendPush },
|
||||
"Push gating check"
|
||||
);
|
||||
|
||||
if (shouldSendPush) {
|
||||
const tokens = this.pushTokenStore.getAllTokens();
|
||||
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
|
||||
|
||||
@@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AgentStreamMessageSchema,
|
||||
AgentStreamSnapshotMessageSchema,
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
WSOutboundMessageSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
@@ -64,4 +66,44 @@ describe("shared messages stream parsing", () => {
|
||||
});
|
||||
expect(wrapped.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects removed legacy git diff request messages", () => {
|
||||
const gitDiffParsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "git_diff_request",
|
||||
agentId: "agent-1",
|
||||
requestId: "req-1",
|
||||
});
|
||||
expect(gitDiffParsed.success).toBe(false);
|
||||
|
||||
const highlightedParsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "highlighted_diff_request",
|
||||
agentId: "agent-1",
|
||||
requestId: "req-2",
|
||||
});
|
||||
expect(highlightedParsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects removed legacy git diff response messages", () => {
|
||||
const gitDiffParsed = SessionOutboundMessageSchema.safeParse({
|
||||
type: "git_diff_response",
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
diff: "",
|
||||
error: null,
|
||||
requestId: "req-1",
|
||||
},
|
||||
});
|
||||
expect(gitDiffParsed.success).toBe(false);
|
||||
|
||||
const highlightedParsed = SessionOutboundMessageSchema.safeParse({
|
||||
type: "highlighted_diff_response",
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
files: [],
|
||||
error: null,
|
||||
requestId: "req-2",
|
||||
},
|
||||
});
|
||||
expect(highlightedParsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -696,12 +696,6 @@ export const AgentPermissionResponseMessageSchema = z.object({
|
||||
response: AgentPermissionResponseSchema,
|
||||
});
|
||||
|
||||
export const GitDiffRequestSchema = z.object({
|
||||
type: z.literal("git_diff_request"),
|
||||
agentId: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
const CheckoutErrorCodeSchema = z.enum([
|
||||
"NOT_GIT_REPO",
|
||||
"NOT_ALLOWED",
|
||||
@@ -837,12 +831,6 @@ const ParsedDiffFileSchema = z.object({
|
||||
status: z.enum(["ok", "too_large", "binary"]).optional(),
|
||||
});
|
||||
|
||||
export const HighlightedDiffRequestSchema = z.object({
|
||||
type: z.literal("highlighted_diff_request"),
|
||||
agentId: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
const FileExplorerEntrySchema = z.object({
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
@@ -1010,7 +998,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
SetAgentModelRequestMessageSchema,
|
||||
SetAgentThinkingRequestMessageSchema,
|
||||
AgentPermissionResponseMessageSchema,
|
||||
GitDiffRequestSchema,
|
||||
CheckoutStatusRequestSchema,
|
||||
SubscribeCheckoutDiffRequestSchema,
|
||||
UnsubscribeCheckoutDiffRequestSchema,
|
||||
@@ -1023,7 +1010,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ValidateBranchRequestSchema,
|
||||
PaseoWorktreeListRequestSchema,
|
||||
PaseoWorktreeArchiveRequestSchema,
|
||||
HighlightedDiffRequestSchema,
|
||||
FileExplorerRequestSchema,
|
||||
ProjectIconRequestSchema,
|
||||
FileDownloadTokenRequestSchema,
|
||||
@@ -1411,16 +1397,6 @@ export const AgentArchivedMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const GitDiffResponseSchema = z.object({
|
||||
type: z.literal("git_diff_response"),
|
||||
payload: z.object({
|
||||
agentId: z.string(),
|
||||
diff: z.string(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const AheadBehindSchema = z.object({
|
||||
ahead: z.number(),
|
||||
behind: z.number(),
|
||||
@@ -1564,6 +1540,7 @@ export const CheckoutPrStatusResponseSchema = z.object({
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
status: CheckoutPrStatusSchema.nullable(),
|
||||
githubFeaturesEnabled: z.boolean(),
|
||||
error: CheckoutErrorSchema.nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
@@ -1605,16 +1582,6 @@ export const PaseoWorktreeArchiveResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const HighlightedDiffResponseSchema = z.object({
|
||||
type: z.literal("highlighted_diff_response"),
|
||||
payload: z.object({
|
||||
agentId: z.string(),
|
||||
files: z.array(ParsedDiffFileSchema),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const FileExplorerResponseSchema = z.object({
|
||||
type: z.literal("file_explorer_response"),
|
||||
payload: z.object({
|
||||
@@ -1834,7 +1801,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
AgentPermissionResolvedMessageSchema,
|
||||
AgentDeletedMessageSchema,
|
||||
AgentArchivedMessageSchema,
|
||||
GitDiffResponseSchema,
|
||||
CheckoutStatusResponseSchema,
|
||||
SubscribeCheckoutDiffResponseSchema,
|
||||
CheckoutDiffUpdateSchema,
|
||||
@@ -1847,7 +1813,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ValidateBranchResponseSchema,
|
||||
PaseoWorktreeListResponseSchema,
|
||||
PaseoWorktreeArchiveResponseSchema,
|
||||
HighlightedDiffResponseSchema,
|
||||
FileExplorerResponseSchema,
|
||||
ProjectIconResponseSchema,
|
||||
FileDownloadTokenResponseSchema,
|
||||
@@ -1944,8 +1909,6 @@ export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessa
|
||||
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
|
||||
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
|
||||
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
||||
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
|
||||
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
|
||||
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
|
||||
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
|
||||
export type SubscribeCheckoutDiffRequest = z.infer<
|
||||
@@ -1976,8 +1939,6 @@ export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSc
|
||||
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
|
||||
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
||||
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
|
||||
export type HighlightedDiffRequest = z.infer<typeof HighlightedDiffRequestSchema>;
|
||||
export type HighlightedDiffResponse = z.infer<typeof HighlightedDiffResponseSchema>;
|
||||
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
|
||||
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const spawnCounters = vi.hoisted(() => ({
|
||||
trackedTextDiffCalls: 0,
|
||||
}));
|
||||
|
||||
vi.mock("child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("child_process")>("child_process");
|
||||
return {
|
||||
...actual,
|
||||
spawn: (...args: Parameters<typeof actual.spawn>) => {
|
||||
const [command, commandArgs] = args;
|
||||
if (command === "git" && Array.isArray(commandArgs)) {
|
||||
const normalizedArgs = commandArgs.map((arg) => String(arg));
|
||||
const isTrackedTextDiff =
|
||||
normalizedArgs[0] === "diff" &&
|
||||
normalizedArgs.includes("HEAD") &&
|
||||
!normalizedArgs.includes("--numstat") &&
|
||||
!normalizedArgs.includes("--no-index");
|
||||
if (isTrackedTextDiff) {
|
||||
spawnCounters.trackedTextDiffCalls += 1;
|
||||
}
|
||||
}
|
||||
return actual.spawn(...args);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { getCheckoutDiff } from "./checkout-git.js";
|
||||
|
||||
function initRepoWithTrackedChanges(fileCount: number): { tempDir: string; repoDir: string } {
|
||||
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "checkout-git-batch-test-")));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
|
||||
execSync(`mkdir -p ${repoDir}`);
|
||||
execSync("git init -b main", { cwd: repoDir });
|
||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir });
|
||||
execSync("git config user.name 'Test'", { cwd: repoDir });
|
||||
|
||||
for (let i = 0; i < fileCount; i += 1) {
|
||||
writeFileSync(join(repoDir, `file-${i}.txt`), `before-${i}\n`);
|
||||
}
|
||||
execSync("git add .", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir });
|
||||
|
||||
for (let i = 0; i < fileCount; i += 1) {
|
||||
writeFileSync(join(repoDir, `file-${i}.txt`), `after-${i}\n`);
|
||||
}
|
||||
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
describe("checkout git diff batching", () => {
|
||||
let tempDir: string;
|
||||
let repoDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
const setup = initRepoWithTrackedChanges(20);
|
||||
tempDir = setup.tempDir;
|
||||
repoDir = setup.repoDir;
|
||||
spawnCounters.trackedTextDiffCalls = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("uses a single tracked git diff command for tracked file diffs", async () => {
|
||||
const result = await getCheckoutDiff(repoDir, {
|
||||
mode: "uncommitted",
|
||||
includeStructured: false,
|
||||
});
|
||||
|
||||
expect(result.diff).toContain("file-0.txt");
|
||||
expect(result.diff).toContain("file-19.txt");
|
||||
expect(spawnCounters.trackedTextDiffCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "fs";
|
||||
import { mkdtempSync, rmSync, writeFileSync, realpathSync, mkdirSync, symlinkSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import {
|
||||
commitAll,
|
||||
getCheckoutDiff,
|
||||
getPullRequestStatus,
|
||||
getCheckoutStatus,
|
||||
getCheckoutStatusLite,
|
||||
mergeToBase,
|
||||
@@ -141,6 +142,27 @@ describe("checkout git utilities", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("short-circuits tracked binary files", async () => {
|
||||
const trackedBinaryPath = join(repoDir, "tracked-blob.bin");
|
||||
writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00]));
|
||||
execSync("git add tracked-blob.bin", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add tracked binary'", {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x11, 0x81, 0x00]));
|
||||
|
||||
const diff = await getCheckoutDiff(repoDir, {
|
||||
mode: "uncommitted",
|
||||
includeStructured: true,
|
||||
});
|
||||
|
||||
const entry = diff.structured?.find((file) => file.path === "tracked-blob.bin");
|
||||
expect(entry).toBeTruthy();
|
||||
expect(entry?.status).toBe("binary");
|
||||
expect(diff.diff).toContain("# tracked-blob.bin: binary diff omitted");
|
||||
});
|
||||
|
||||
it("short-circuits untracked binary files", async () => {
|
||||
const binaryPath = join(repoDir, "blob.bin");
|
||||
writeFileSync(binaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00, 0x7f, 0x00]));
|
||||
@@ -380,6 +402,29 @@ describe("checkout git utilities", () => {
|
||||
execSync(`git --git-dir ${remoteDir} show-ref --verify refs/heads/feature`);
|
||||
});
|
||||
|
||||
it("disables GitHub features when gh is unavailable", async () => {
|
||||
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
|
||||
|
||||
const fakeBinDir = join(tempDir, "fake-bin");
|
||||
mkdirSync(fakeBinDir);
|
||||
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
|
||||
symlinkSync(gitPath, join(fakeBinDir, "git"));
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = fakeBinDir;
|
||||
try {
|
||||
const status = await getPullRequestStatus(repoDir);
|
||||
expect(status.githubFeaturesEnabled).toBe(false);
|
||||
expect(status.status).toBeNull();
|
||||
} finally {
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns typed MergeConflictError on merge conflicts", async () => {
|
||||
const conflictFile = join(repoDir, "conflict.txt");
|
||||
writeFileSync(conflictFile, "base\n");
|
||||
|
||||
@@ -184,35 +184,81 @@ async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string
|
||||
|
||||
type FileStat = { additions: number; deletions: number; isBinary: boolean } | null;
|
||||
|
||||
async function tryGetNumstat(
|
||||
cwd: string,
|
||||
args: string[]
|
||||
): Promise<FileStat> {
|
||||
try {
|
||||
const { text } = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args,
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: 64 * 1024,
|
||||
acceptExitCodes: [0],
|
||||
});
|
||||
const line = text.trim().split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
|
||||
if (!line) return null;
|
||||
const [aRaw, dRaw] = line.split(/\s+/);
|
||||
if (!aRaw || !dRaw) return null;
|
||||
if (aRaw === "-" || dRaw === "-") {
|
||||
return { additions: 0, deletions: 0, isBinary: true };
|
||||
}
|
||||
const additions = Number.parseInt(aRaw, 10);
|
||||
const deletions = Number.parseInt(dRaw, 10);
|
||||
if (Number.isNaN(additions) || Number.isNaN(deletions)) {
|
||||
return null;
|
||||
}
|
||||
return { additions, deletions, isBinary: false };
|
||||
} catch {
|
||||
return null;
|
||||
function normalizeNumstatPath(pathField: string): string {
|
||||
const braceRenameMatch = pathField.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
|
||||
if (braceRenameMatch) {
|
||||
const [, prefix, , renamed, suffix] = braceRenameMatch;
|
||||
return `${prefix}${renamed}${suffix}`;
|
||||
}
|
||||
|
||||
const inlineRenameMatch = pathField.match(/^(.*) => (.*)$/);
|
||||
if (inlineRenameMatch) {
|
||||
return inlineRenameMatch[2] ?? pathField;
|
||||
}
|
||||
|
||||
return pathField;
|
||||
}
|
||||
|
||||
const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
const TRACKED_MAX_CHANGED_LINES = 40_000;
|
||||
|
||||
async function getTrackedNumstatByPath(
|
||||
cwd: string,
|
||||
ref: string
|
||||
): Promise<Map<string, FileStat>> {
|
||||
const result = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args: ["diff", "--numstat", ref],
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES,
|
||||
acceptExitCodes: [0],
|
||||
});
|
||||
|
||||
const stats = new Map<string, FileStat>();
|
||||
const lines = result.text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split("\t");
|
||||
if (parts.length < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const additionsField = parts[0] ?? "";
|
||||
const deletionsField = parts[1] ?? "";
|
||||
const rawPath = parts.slice(2).join("\t");
|
||||
const path = normalizeNumstatPath(rawPath);
|
||||
|
||||
if (!path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (additionsField === "-" || deletionsField === "-") {
|
||||
stats.set(path, { additions: 0, deletions: 0, isBinary: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
const additions = Number.parseInt(additionsField, 10);
|
||||
const deletions = Number.parseInt(deletionsField, 10);
|
||||
if (Number.isNaN(additions) || Number.isNaN(deletions)) {
|
||||
stats.set(path, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
stats.set(path, { additions, deletions, isBinary: false });
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
function isTrackedDiffTooLarge(stat: FileStat): boolean {
|
||||
if (!stat || stat.isBinary) {
|
||||
return false;
|
||||
}
|
||||
return stat.additions + stat.deletions > TRACKED_MAX_CHANGED_LINES;
|
||||
}
|
||||
|
||||
export class NotGitRepoError extends Error {
|
||||
@@ -784,49 +830,32 @@ function buildPlaceholderParsedDiffFile(
|
||||
};
|
||||
}
|
||||
|
||||
async function getPerFileDiffText(
|
||||
async function getUntrackedDiffText(
|
||||
cwd: string,
|
||||
ref: string,
|
||||
change: CheckoutFileChange
|
||||
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
|
||||
if (change.isUntracked) {
|
||||
try {
|
||||
const inspected = await inspectUntrackedFile(cwd, change.path);
|
||||
if (inspected.stat?.isBinary || inspected.truncated) {
|
||||
return { text: "", truncated: inspected.truncated, stat: inspected.stat };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to git diff path if metadata probing fails.
|
||||
try {
|
||||
const inspected = await inspectUntrackedFile(cwd, change.path);
|
||||
if (inspected.stat?.isBinary || inspected.truncated) {
|
||||
return { text: "", truncated: inspected.truncated, stat: inspected.stat };
|
||||
}
|
||||
|
||||
const result = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args: ["diff", "--no-index", "/dev/null", "--", change.path],
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: PER_FILE_DIFF_MAX_BYTES,
|
||||
acceptExitCodes: [0, 1],
|
||||
});
|
||||
return {
|
||||
text: result.text,
|
||||
truncated: result.truncated,
|
||||
stat: { additions: 0, deletions: 0, isBinary: false },
|
||||
};
|
||||
}
|
||||
|
||||
const stat = await tryGetNumstat(cwd, ["diff", "--numstat", ref, "--", change.path]);
|
||||
if (stat?.isBinary) {
|
||||
return { text: "", truncated: false, stat };
|
||||
} catch {
|
||||
// Fall through to git diff path if metadata probing fails.
|
||||
}
|
||||
|
||||
const result = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args: ["diff", ref, "--", change.path],
|
||||
args: ["diff", "--no-index", "/dev/null", "--", change.path],
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: PER_FILE_DIFF_MAX_BYTES,
|
||||
acceptExitCodes: [0, 1],
|
||||
});
|
||||
return { text: result.text, truncated: result.truncated, stat };
|
||||
return {
|
||||
text: result.text,
|
||||
truncated: result.truncated,
|
||||
stat: { additions: 0, deletions: 0, isBinary: false },
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCheckoutStatus(
|
||||
@@ -966,8 +995,111 @@ export async function getCheckoutDiff(
|
||||
}
|
||||
};
|
||||
|
||||
for (const change of changes) {
|
||||
const { text, truncated, stat } = await getPerFileDiffText(cwd, refForDiff, change);
|
||||
const trackedChanges = changes.filter((change) => !change.isUntracked);
|
||||
const untrackedChanges = changes.filter((change) => change.isUntracked === true);
|
||||
|
||||
const trackedNumstatByPath =
|
||||
trackedChanges.length > 0 ? await getTrackedNumstatByPath(cwd, refForDiff) : new Map<string, FileStat>();
|
||||
const trackedDiffPaths: string[] = [];
|
||||
const trackedPlaceholderByPath = new Map<
|
||||
string,
|
||||
{ status: "binary" | "too_large"; stat: FileStat }
|
||||
>();
|
||||
|
||||
for (const change of trackedChanges) {
|
||||
const stat = trackedNumstatByPath.get(change.path) ?? null;
|
||||
if (stat?.isBinary) {
|
||||
trackedPlaceholderByPath.set(change.path, { status: "binary", stat });
|
||||
continue;
|
||||
}
|
||||
if (isTrackedDiffTooLarge(stat)) {
|
||||
trackedPlaceholderByPath.set(change.path, { status: "too_large", stat });
|
||||
continue;
|
||||
}
|
||||
trackedDiffPaths.push(change.path);
|
||||
}
|
||||
|
||||
let trackedDiffText = "";
|
||||
let trackedDiffTruncated = false;
|
||||
if (trackedDiffPaths.length > 0) {
|
||||
const trackedDiffResult = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args: ["diff", refForDiff, "--", ...trackedDiffPaths],
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: TOTAL_DIFF_MAX_BYTES,
|
||||
});
|
||||
trackedDiffText = trackedDiffResult.text;
|
||||
trackedDiffTruncated = trackedDiffResult.truncated;
|
||||
appendDiff(trackedDiffText);
|
||||
if (trackedDiffTruncated) {
|
||||
appendDiff("# tracked diff truncated\n");
|
||||
}
|
||||
}
|
||||
|
||||
const appendTrackedPlaceholderComment = (change: CheckoutFileChange, status: "binary" | "too_large") => {
|
||||
if (status === "binary") {
|
||||
appendDiff(`# ${change.path}: binary diff omitted\n`);
|
||||
return;
|
||||
}
|
||||
appendDiff(`# ${change.path}: diff too large omitted\n`);
|
||||
};
|
||||
|
||||
if (compare.includeStructured) {
|
||||
const parsedTrackedFiles =
|
||||
trackedDiffText.length > 0 ? await parseAndHighlightDiff(trackedDiffText, cwd) : [];
|
||||
const parsedTrackedByPath = new Map(parsedTrackedFiles.map((file) => [file.path, file]));
|
||||
|
||||
for (const change of trackedChanges) {
|
||||
const placeholder = trackedPlaceholderByPath.get(change.path);
|
||||
if (placeholder) {
|
||||
structured.push(
|
||||
buildPlaceholderParsedDiffFile(change, {
|
||||
status: placeholder.status,
|
||||
stat: placeholder.stat,
|
||||
})
|
||||
);
|
||||
appendTrackedPlaceholderComment(change, placeholder.status);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = trackedNumstatByPath.get(change.path) ?? null;
|
||||
const parsedFile = parsedTrackedByPath.get(change.path);
|
||||
if (parsedFile) {
|
||||
structured.push({
|
||||
...parsedFile,
|
||||
path: change.path,
|
||||
isNew: change.isNew,
|
||||
isDeleted: change.isDeleted,
|
||||
status: "ok",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
structured.push({
|
||||
path: change.path,
|
||||
isNew: change.isNew,
|
||||
isDeleted: change.isDeleted,
|
||||
additions: stat?.additions ?? 0,
|
||||
deletions: stat?.deletions ?? 0,
|
||||
hunks: [],
|
||||
status: trackedDiffTruncated ? "too_large" : "ok",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const change of trackedChanges) {
|
||||
const placeholder = trackedPlaceholderByPath.get(change.path);
|
||||
if (placeholder) {
|
||||
appendTrackedPlaceholderComment(change, placeholder.status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const change of untrackedChanges) {
|
||||
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
|
||||
break;
|
||||
}
|
||||
const { text, truncated, stat } = await getUntrackedDiffText(cwd, change);
|
||||
|
||||
if (!compare.includeStructured) {
|
||||
if (stat?.isBinary) {
|
||||
@@ -977,9 +1109,6 @@ export async function getCheckoutDiff(
|
||||
} else {
|
||||
appendDiff(text);
|
||||
}
|
||||
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1268,6 +1397,11 @@ export interface PullRequestStatus {
|
||||
headRefName: string;
|
||||
}
|
||||
|
||||
export interface PullRequestStatusResult {
|
||||
status: PullRequestStatus | null;
|
||||
githubFeaturesEnabled: boolean;
|
||||
}
|
||||
|
||||
async function ensureGhAvailable(cwd: string): Promise<void> {
|
||||
try {
|
||||
await execAsync("gh --version", { cwd });
|
||||
@@ -1276,6 +1410,27 @@ async function ensureGhAvailable(cwd: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function getCommandErrorText(error: unknown): string {
|
||||
if (!(error instanceof Error)) {
|
||||
return String(error);
|
||||
}
|
||||
const stderr = typeof (error as any)?.stderr === "string" ? (error as any).stderr : "";
|
||||
const stdout = typeof (error as any)?.stdout === "string" ? (error as any).stdout : "";
|
||||
return `${error.message}\n${stderr}\n${stdout}`.toLowerCase();
|
||||
}
|
||||
|
||||
function isGhAuthError(error: unknown): boolean {
|
||||
const text = getCommandErrorText(error);
|
||||
return (
|
||||
text.includes("gh auth login") ||
|
||||
text.includes("not logged into any github hosts") ||
|
||||
text.includes("authentication failed") ||
|
||||
text.includes("authentication required") ||
|
||||
text.includes("bad credentials") ||
|
||||
text.includes("http 401")
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveGitHubRepo(cwd: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git config --get remote.origin.url", {
|
||||
@@ -1356,39 +1511,66 @@ export async function createPullRequest(
|
||||
return { url: parsed.url, number: parsed.number };
|
||||
}
|
||||
|
||||
export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatus | null> {
|
||||
export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatusResult> {
|
||||
await requireGitRepo(cwd);
|
||||
await ensureGhAvailable(cwd);
|
||||
const repo = await resolveGitHubRepo(cwd);
|
||||
const head = await getCurrentBranch(cwd);
|
||||
if (!repo || !head) {
|
||||
return null;
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: false,
|
||||
};
|
||||
}
|
||||
try {
|
||||
await ensureGhAvailable(cwd);
|
||||
} catch {
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: false,
|
||||
};
|
||||
}
|
||||
const owner = repo.split("/")[0];
|
||||
const { stdout } = await execFileAsync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
`repos/${repo}/pulls`,
|
||||
"-X",
|
||||
"GET",
|
||||
"-F",
|
||||
`head=${owner}:${head}`,
|
||||
"-F",
|
||||
"state=open",
|
||||
],
|
||||
{ cwd }
|
||||
);
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await execFileAsync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
`repos/${repo}/pulls`,
|
||||
"-X",
|
||||
"GET",
|
||||
"-F",
|
||||
`head=${owner}:${head}`,
|
||||
"-F",
|
||||
"state=open",
|
||||
],
|
||||
{ cwd }
|
||||
));
|
||||
} catch (error) {
|
||||
if (isGhAuthError(error)) {
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: false,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
const current = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null;
|
||||
if (!current) {
|
||||
return null;
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: current.html_url ?? current.url,
|
||||
title: current.title,
|
||||
state: current.state,
|
||||
baseRefName: current.base?.ref ?? "",
|
||||
headRefName: current.head?.ref ?? head,
|
||||
status: {
|
||||
url: current.html_url ?? current.url,
|
||||
title: current.title,
|
||||
state: current.state,
|
||||
baseRefName: current.base?.ref ?? "",
|
||||
headRefName: current.head?.ref ?? head,
|
||||
},
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user