mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b4d14672c |
+3
-5
@@ -131,11 +131,9 @@ desktop-only environment inherited by terminals opened inside Paseo from couplin
|
|||||||
a new worktree instance to the parent desktop instance's profile or single-instance
|
a new worktree instance to the parent desktop instance's profile or single-instance
|
||||||
lock.
|
lock.
|
||||||
|
|
||||||
The desktop workspace script `exec`s the dev runner so the terminal owns the runner
|
Electron remains in the desktop dev runner's process group. Closing or stopping the
|
||||||
PID. Terminal shutdown reaches the runner as `SIGHUP`; the runner stops Metro and
|
workspace-script terminal must terminate Electron with Metro; detaching Electron
|
||||||
asks Electron to quit through its normal app lifecycle. Do not add an npm wrapper or
|
leaves an orphan holding the worktree's single-instance lock and broken output pipes.
|
||||||
detach Electron: either change leaves an orphan holding the worktree's single-instance
|
|
||||||
lock and broken output pipes.
|
|
||||||
|
|
||||||
With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen
|
With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen
|
||||||
transition, and 751-pixel settings split with:
|
transition, and 751-pixel settings split with:
|
||||||
|
|||||||
@@ -88,43 +88,6 @@ describe("useDraftAgentCreateFlow", () => {
|
|||||||
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
|
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts one create request when submit is invoked twice before React rerenders", async () => {
|
|
||||||
let resolveCreate: ((value: { agentId: string; result: { id: string } }) => void) | undefined;
|
|
||||||
const createRequest = vi.fn(
|
|
||||||
() =>
|
|
||||||
new Promise<{ agentId: string; result: { id: string } }>((resolve) => {
|
|
||||||
resolveCreate = resolve;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const onCreateSuccess = vi.fn();
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useDraftAgentCreateFlow({
|
|
||||||
draftId: "draft-1",
|
|
||||||
getPendingServerId: () => "server-1",
|
|
||||||
buildDraftAgent: (attempt) => ({ attempt }),
|
|
||||||
createRequest,
|
|
||||||
onCreateSuccess,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const submission = { text: "build this", attachments: [], cwd: "/repo" };
|
|
||||||
|
|
||||||
let first: Promise<void>;
|
|
||||||
let duplicate: Promise<void>;
|
|
||||||
act(() => {
|
|
||||||
first = result.current.handleCreateFromInput(submission);
|
|
||||||
duplicate = result.current.handleCreateFromInput(submission);
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(duplicate!).rejects.toThrow();
|
|
||||||
expect(createRequest).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
resolveCreate?.({ agentId: "agent-1", result: { id: "agent-1" } });
|
|
||||||
await first!;
|
|
||||||
});
|
|
||||||
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows retrying an empty prompt when the draft still has context attachments", async () => {
|
it("allows retrying an empty prompt when the draft still has context attachments", async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
kind: "chat_history",
|
kind: "chat_history",
|
||||||
|
|||||||
@@ -84,17 +84,6 @@ interface CreateRequestContext {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertCreateAvailable(input: {
|
|
||||||
draftId: string;
|
|
||||||
isSubmitting: boolean;
|
|
||||||
alreadyLoadingMessage: string;
|
|
||||||
}): void {
|
|
||||||
const pending = useCreateFlowStore.getState().pendingByDraftId[input.draftId];
|
|
||||||
if (input.isSubmitting || pending?.lifecycle === "active") {
|
|
||||||
throw new Error(input.alreadyLoadingMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
|
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
|
||||||
draftId: string;
|
draftId: string;
|
||||||
getPendingServerId: () => string | null;
|
getPendingServerId: () => string | null;
|
||||||
@@ -250,11 +239,9 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
|
|
||||||
const handleCreateFromInput = useCallback(
|
const handleCreateFromInput = useCallback(
|
||||||
async ({ text, attachments, cwd }: SubmitContext) => {
|
async ({ text, attachments, cwd }: SubmitContext) => {
|
||||||
assertCreateAvailable({
|
if (isSubmitting) {
|
||||||
draftId,
|
throw new Error(t("composer.errors.alreadyLoading"));
|
||||||
isSubmitting,
|
}
|
||||||
alreadyLoadingMessage: t("composer.errors.alreadyLoading"),
|
|
||||||
});
|
|
||||||
|
|
||||||
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
|
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
|
||||||
const trimmedPrompt = text.trim();
|
const trimmedPrompt = text.trim();
|
||||||
|
|||||||
@@ -15,9 +15,3 @@ export function createElectronSpawnOptions({ env, colorEnv, expoDevUrl }) {
|
|||||||
export function resolveChildKillTarget(pid, detached) {
|
export function resolveChildKillTarget(pid, detached) {
|
||||||
return detached ? -pid : pid;
|
return detached ? -pid : pid;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerDevRunnerShutdownSignals({ signalSource, stop }) {
|
|
||||||
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
|
|
||||||
signalSource.on(signal, () => stop("SIGTERM"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,23 +1,7 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { readFileSync } from "node:fs";
|
import { createElectronSpawnOptions, resolveChildKillTarget } from "./dev-runner-config.mjs";
|
||||||
import {
|
|
||||||
createElectronSpawnOptions,
|
|
||||||
registerDevRunnerShutdownSignals,
|
|
||||||
resolveChildKillTarget,
|
|
||||||
} from "./dev-runner-config.mjs";
|
|
||||||
|
|
||||||
describe("desktop dev process ownership", () => {
|
describe("desktop dev process ownership", () => {
|
||||||
test("makes the signal-handling runner the workspace terminal process", () => {
|
|
||||||
const paseoConfig = JSON.parse(
|
|
||||||
readFileSync(new URL("../../../paseo.json", import.meta.url), "utf8"),
|
|
||||||
);
|
|
||||||
const devScript = readFileSync(new URL("./dev.sh", import.meta.url), "utf8");
|
|
||||||
|
|
||||||
expect(paseoConfig.scripts.desktop.command).toContain("exec ./packages/desktop/scripts/dev.sh");
|
|
||||||
expect(devScript).toContain('npm --prefix "$DESKTOP_DIR" run build:main');
|
|
||||||
expect(devScript).toContain('exec node "$SCRIPT_DIR/dev-runner.mjs"');
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps Electron in the runner process group", () => {
|
test("keeps Electron in the runner process group", () => {
|
||||||
const options = createElectronSpawnOptions({
|
const options = createElectronSpawnOptions({
|
||||||
env: { PATH: "/usr/bin" },
|
env: { PATH: "/usr/bin" },
|
||||||
@@ -39,24 +23,4 @@ describe("desktop dev process ownership", () => {
|
|||||||
expect(resolveChildKillTarget(42, true)).toBe(-42);
|
expect(resolveChildKillTarget(42, true)).toBe(-42);
|
||||||
expect(resolveChildKillTarget(42, false)).toBe(42);
|
expect(resolveChildKillTarget(42, false)).toBe(42);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("stops children when the owning terminal hangs up", () => {
|
|
||||||
const listeners = new Map();
|
|
||||||
const receivedSignals = [];
|
|
||||||
|
|
||||||
registerDevRunnerShutdownSignals({
|
|
||||||
signalSource: {
|
|
||||||
on(signal, listener) {
|
|
||||||
listeners.set(signal, listener);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
stop(signal) {
|
|
||||||
receivedSignals.push(signal);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(Array.from(listeners.keys())).toEqual(["SIGHUP", "SIGINT", "SIGTERM"]);
|
|
||||||
listeners.get("SIGHUP")();
|
|
||||||
expect(receivedSignals).toEqual(["SIGTERM"]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import path from "node:path";
|
|||||||
import { execFileSync, spawn } from "node:child_process";
|
import { execFileSync, spawn } from "node:child_process";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import {
|
import { createElectronSpawnOptions, resolveChildKillTarget } from "./dev-runner-config.mjs";
|
||||||
createElectronSpawnOptions,
|
|
||||||
registerDevRunnerShutdownSignals,
|
|
||||||
resolveChildKillTarget,
|
|
||||||
} from "./dev-runner-config.mjs";
|
|
||||||
|
|
||||||
import { resolveDevElectronArgs } from "./dev-runner-args.mjs";
|
import { resolveDevElectronArgs } from "./dev-runner-args.mjs";
|
||||||
|
|
||||||
@@ -163,7 +159,8 @@ function canConnect(port, host) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
registerDevRunnerShutdownSignals({ signalSource: process, stop: stopAll });
|
process.on("SIGINT", () => stopAll("SIGTERM"));
|
||||||
|
process.on("SIGTERM", () => stopAll("SIGTERM"));
|
||||||
|
|
||||||
spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], {
|
spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], {
|
||||||
cwd: appDir,
|
cwd: appDir,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export PASEO_DAEMON_ENDPOINT="$DAEMON_ENDPOINT"
|
|||||||
|
|
||||||
export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}"
|
export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}"
|
||||||
|
|
||||||
npm --prefix "$DESKTOP_DIR" run build:main
|
npm run build:main
|
||||||
|
|
||||||
echo "══════════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════════"
|
||||||
echo " Paseo Desktop Dev"
|
echo " Paseo Desktop Dev"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
|
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
|
||||||
import {
|
import {
|
||||||
createQuitLifecycle,
|
createQuitLifecycle,
|
||||||
registerExternalQuitSignals,
|
|
||||||
shouldStopDesktopManagedDaemonOnQuit,
|
shouldStopDesktopManagedDaemonOnQuit,
|
||||||
stopDesktopManagedDaemonOnQuitIfNeeded,
|
stopDesktopManagedDaemonOnQuitIfNeeded,
|
||||||
} from "./quit-lifecycle";
|
} from "./quit-lifecycle";
|
||||||
@@ -30,25 +29,6 @@ function waitForQuitLifecycle(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("quit-lifecycle", () => {
|
describe("quit-lifecycle", () => {
|
||||||
it("turns external termination signals into one Electron quit", () => {
|
|
||||||
const listeners = new Map<NodeJS.Signals, () => void>();
|
|
||||||
const quits: string[] = [];
|
|
||||||
|
|
||||||
registerExternalQuitSignals({
|
|
||||||
signals: {
|
|
||||||
on: (signal, listener) => {
|
|
||||||
listeners.set(signal, listener);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
quit: () => quits.push("quit"),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(Array.from(listeners.keys())).toEqual(["SIGHUP", "SIGINT", "SIGTERM"]);
|
|
||||||
listeners.get("SIGTERM")?.();
|
|
||||||
listeners.get("SIGHUP")?.();
|
|
||||||
expect(quits).toEqual(["quit"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stops by default and only keeps running when keepRunningAfterQuit is enabled", () => {
|
it("stops by default and only keeps running when keepRunningAfterQuit is enabled", () => {
|
||||||
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_STOP_ON_QUIT)).toBe(true);
|
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_STOP_ON_QUIT)).toBe(true);
|
||||||
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_KEEP_RUNNING)).toBe(false);
|
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_KEEP_RUNNING)).toBe(false);
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ interface BeforeQuitApp {
|
|||||||
exit(code: number): void;
|
exit(code: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExternalQuitSignalSource {
|
|
||||||
on(signal: NodeJS.Signals, listener: () => void): unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QuitLifecycle {
|
interface QuitLifecycle {
|
||||||
handleBeforeQuit(event: BeforeQuitEvent): void;
|
handleBeforeQuit(event: BeforeQuitEvent): void;
|
||||||
handleBeforeQuitForUpdate(): void;
|
handleBeforeQuitForUpdate(): void;
|
||||||
@@ -35,23 +31,6 @@ export interface StopOnQuitDeps {
|
|||||||
showShutdownFeedback: () => void;
|
showShutdownFeedback: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerExternalQuitSignals({
|
|
||||||
signals,
|
|
||||||
quit,
|
|
||||||
}: {
|
|
||||||
signals: ExternalQuitSignalSource;
|
|
||||||
quit: () => void;
|
|
||||||
}): void {
|
|
||||||
let quitRequested = false;
|
|
||||||
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] satisfies NodeJS.Signals[]) {
|
|
||||||
signals.on(signal, () => {
|
|
||||||
if (quitRequested) return;
|
|
||||||
quitRequested = true;
|
|
||||||
quit();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldStopDesktopManagedDaemonOnQuit(settings: QuitLifecycleSettings): boolean {
|
export function shouldStopDesktopManagedDaemonOnQuit(settings: QuitLifecycleSettings): boolean {
|
||||||
return !settings.daemon.keepRunningAfterQuit;
|
return !settings.daemon.keepRunningAfterQuit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ import {
|
|||||||
} from "./daemon/daemon-manager.js";
|
} from "./daemon/daemon-manager.js";
|
||||||
import {
|
import {
|
||||||
createQuitLifecycle,
|
createQuitLifecycle,
|
||||||
registerExternalQuitSignals,
|
|
||||||
stopDesktopManagedDaemonOnQuitIfNeeded,
|
stopDesktopManagedDaemonOnQuitIfNeeded,
|
||||||
} from "./daemon/quit-lifecycle.js";
|
} from "./daemon/quit-lifecycle.js";
|
||||||
import { runDesktopStartup } from "./desktop-startup.js";
|
import { runDesktopStartup } from "./desktop-startup.js";
|
||||||
@@ -1082,7 +1081,6 @@ const quitLifecycle = createQuitLifecycle({
|
|||||||
// electron-updater forwards this event through Electron's built-in autoUpdater.
|
// electron-updater forwards this event through Electron's built-in autoUpdater.
|
||||||
electronAutoUpdater.on("before-quit-for-update", quitLifecycle.handleBeforeQuitForUpdate);
|
electronAutoUpdater.on("before-quit-for-update", quitLifecycle.handleBeforeQuitForUpdate);
|
||||||
app.on("before-quit", quitLifecycle.handleBeforeQuit);
|
app.on("before-quit", quitLifecycle.handleBeforeQuit);
|
||||||
registerExternalQuitSignals({ signals: process, quit: () => app.quit() });
|
|
||||||
|
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
if (process.platform !== "darwin") {
|
if (process.platform !== "darwin") {
|
||||||
|
|||||||
@@ -366,6 +366,10 @@ type FetchAgentsResponsePageInfo = FetchAgentsResponsePayload["pageInfo"];
|
|||||||
type AgentUpdatesFilter = FetchAgentsRequestFilter;
|
type AgentUpdatesFilter = FetchAgentsRequestFilter;
|
||||||
type CreateAgentRequestMessage = Extract<SessionInboundMessage, { type: "create_agent_request" }>;
|
type CreateAgentRequestMessage = Extract<SessionInboundMessage, { type: "create_agent_request" }>;
|
||||||
|
|
||||||
|
interface CreatedAgentRequest {
|
||||||
|
agent: AgentSnapshotPayload;
|
||||||
|
}
|
||||||
|
|
||||||
interface ResolvedSessionCreateAgentIntent {
|
interface ResolvedSessionCreateAgentIntent {
|
||||||
config: AgentSessionConfig;
|
config: AgentSessionConfig;
|
||||||
intent: CreateAgentIntent;
|
intent: CreateAgentIntent;
|
||||||
@@ -676,6 +680,10 @@ export class Session {
|
|||||||
private readonly hubExecutionController: HubExecutionController | null;
|
private readonly hubExecutionController: HubExecutionController | null;
|
||||||
private readonly workspaceScripts: WorkspaceScriptsService;
|
private readonly workspaceScripts: WorkspaceScriptsService;
|
||||||
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
|
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
|
||||||
|
private readonly inFlightAgentCreationsByClientMessageId = new Map<
|
||||||
|
string,
|
||||||
|
Promise<CreatedAgentRequest>
|
||||||
|
>();
|
||||||
|
|
||||||
constructor(options: SessionOptions) {
|
constructor(options: SessionOptions) {
|
||||||
const {
|
const {
|
||||||
@@ -3102,10 +3110,86 @@ export class Session {
|
|||||||
* Handle create agent request
|
* Handle create agent request
|
||||||
*/
|
*/
|
||||||
private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise<void> {
|
private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise<void> {
|
||||||
|
const clientMessageId = msg.clientMessageId?.trim();
|
||||||
|
const existingCreation = clientMessageId
|
||||||
|
? this.inFlightAgentCreationsByClientMessageId.get(clientMessageId)
|
||||||
|
: undefined;
|
||||||
|
if (existingCreation) {
|
||||||
|
try {
|
||||||
|
const created = await existingCreation;
|
||||||
|
this.emitCreatedAgentResponse(msg.requestId, created.agent);
|
||||||
|
} catch (error) {
|
||||||
|
this.emitFailedAgentCreateResponse(msg.requestId, error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const creation = this.createAgentForRequest(msg);
|
||||||
|
if (clientMessageId) {
|
||||||
|
this.inFlightAgentCreationsByClientMessageId.set(clientMessageId, creation);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await creation;
|
||||||
|
this.emitCreatedAgentResponse(msg.requestId, created.agent);
|
||||||
|
} catch (error) {
|
||||||
|
const wireError = toWorktreeWireError(error);
|
||||||
|
this.sessionLogger.error({ err: error }, "Failed to create agent");
|
||||||
|
this.emitFailedAgentCreateResponse(msg.requestId, wireError);
|
||||||
|
this.emit({
|
||||||
|
type: "activity_log",
|
||||||
|
payload: {
|
||||||
|
id: uuidv4(),
|
||||||
|
timestamp: new Date(),
|
||||||
|
type: "error",
|
||||||
|
content: `Failed to create agent: ${wireError.message}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (
|
||||||
|
clientMessageId &&
|
||||||
|
this.inFlightAgentCreationsByClientMessageId.get(clientMessageId) === creation
|
||||||
|
) {
|
||||||
|
this.inFlightAgentCreationsByClientMessageId.delete(clientMessageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitCreatedAgentResponse(
|
||||||
|
requestId: string | undefined,
|
||||||
|
agent: AgentSnapshotPayload,
|
||||||
|
): void {
|
||||||
|
if (!requestId) return;
|
||||||
|
this.emit({
|
||||||
|
type: "status",
|
||||||
|
payload: {
|
||||||
|
status: "agent_created",
|
||||||
|
agentId: agent.id,
|
||||||
|
requestId,
|
||||||
|
agent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitFailedAgentCreateResponse(requestId: string | undefined, error: unknown): void {
|
||||||
|
if (!requestId) return;
|
||||||
|
const wireError = toWorktreeWireError(error);
|
||||||
|
this.emit({
|
||||||
|
type: "status",
|
||||||
|
payload: {
|
||||||
|
status: "agent_create_failed",
|
||||||
|
requestId,
|
||||||
|
error: wireError.message,
|
||||||
|
errorCode: wireError.code,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createAgentForRequest(
|
||||||
|
msg: CreateAgentRequestMessage,
|
||||||
|
): Promise<CreatedAgentRequest> {
|
||||||
const {
|
const {
|
||||||
config,
|
config,
|
||||||
worktreeName,
|
worktreeName,
|
||||||
requestId,
|
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
clientMessageId,
|
clientMessageId,
|
||||||
outputSchema,
|
outputSchema,
|
||||||
@@ -3205,50 +3289,19 @@ export class Session {
|
|||||||
agentId: snapshot.id,
|
agentId: snapshot.id,
|
||||||
createdWorktree,
|
createdWorktree,
|
||||||
});
|
});
|
||||||
if (requestId) {
|
const agentPayload = await this.buildAgentPayload(liveSnapshot);
|
||||||
const agentPayload = await this.buildAgentPayload(liveSnapshot);
|
|
||||||
this.emit({
|
|
||||||
type: "status",
|
|
||||||
payload: {
|
|
||||||
status: "agent_created",
|
|
||||||
agentId: liveSnapshot.id,
|
|
||||||
requestId,
|
|
||||||
agent: agentPayload,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sessionLogger.info(
|
this.sessionLogger.info(
|
||||||
{ agentId: snapshot.id, provider: snapshot.provider },
|
{ agentId: snapshot.id, provider: snapshot.provider },
|
||||||
`Created agent ${snapshot.id} (${snapshot.provider})`,
|
`Created agent ${snapshot.id} (${snapshot.provider})`,
|
||||||
);
|
);
|
||||||
|
return { agent: agentPayload };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.createAgentLifecycleDispatch.cleanupCreatedWorktreeAfterFailedAgentCreate({
|
await this.createAgentLifecycleDispatch.cleanupCreatedWorktreeAfterFailedAgentCreate({
|
||||||
createdWorktree: createdWorktreeForCleanup,
|
createdWorktree: createdWorktreeForCleanup,
|
||||||
createdAgentId,
|
createdAgentId,
|
||||||
});
|
});
|
||||||
const wireError = toWorktreeWireError(error);
|
throw error;
|
||||||
this.sessionLogger.error({ err: error }, "Failed to create agent");
|
|
||||||
if (requestId) {
|
|
||||||
this.emit({
|
|
||||||
type: "status",
|
|
||||||
payload: {
|
|
||||||
status: "agent_create_failed",
|
|
||||||
requestId,
|
|
||||||
error: wireError.message,
|
|
||||||
errorCode: wireError.code,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.emit({
|
|
||||||
type: "activity_log",
|
|
||||||
payload: {
|
|
||||||
id: uuidv4(),
|
|
||||||
timestamp: new Date(),
|
|
||||||
type: "error",
|
|
||||||
content: `Failed to create agent: ${wireError.message}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -872,7 +872,7 @@ test("client heartbeat clears attention for the focused terminal", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("create_agent_request keeps requested child cwd when grouped under an existing parent workspace", async () => {
|
test("create_agent_request deduplicates concurrent draft submission retries", async () => {
|
||||||
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-cwd-"));
|
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-cwd-"));
|
||||||
try {
|
try {
|
||||||
const parent = path.join(workdir, "parent");
|
const parent = path.join(workdir, "parent");
|
||||||
@@ -981,14 +981,26 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
|
|||||||
terminalManager: null,
|
terminalManager: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await session.handleMessage({
|
await Promise.all([
|
||||||
type: "create_agent_request",
|
session.handleMessage({
|
||||||
requestId: "req-create-child",
|
type: "create_agent_request",
|
||||||
config: { provider: "codex", cwd: child },
|
requestId: "req-create-child-1",
|
||||||
attachments: [],
|
clientMessageId: "draft-submission-1",
|
||||||
});
|
config: { provider: "codex", cwd: child },
|
||||||
|
attachments: [],
|
||||||
|
}),
|
||||||
|
session.handleMessage({
|
||||||
|
type: "create_agent_request",
|
||||||
|
requestId: "req-create-child-2",
|
||||||
|
clientMessageId: "draft-submission-1",
|
||||||
|
config: { provider: "codex", cwd: child },
|
||||||
|
attachments: [],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const [createdAgent] = agentManager.listAgents();
|
const createdAgents = agentManager.listAgents();
|
||||||
|
expect(createdAgents).toHaveLength(1);
|
||||||
|
const [createdAgent] = createdAgents;
|
||||||
expect(createdAgent?.cwd).toBe(child);
|
expect(createdAgent?.cwd).toBe(child);
|
||||||
const createdWorkspace = await workspaceRegistry.get(createdAgent!.workspaceId!);
|
const createdWorkspace = await workspaceRegistry.get(createdAgent!.workspaceId!);
|
||||||
expect(createdWorkspace).not.toBeNull();
|
expect(createdWorkspace).not.toBeNull();
|
||||||
@@ -1007,10 +1019,22 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
|
|||||||
projectKey: createdWorkspace!.projectId,
|
projectKey: createdWorkspace!.projectId,
|
||||||
checkout: { cwd: child },
|
checkout: { cwd: child },
|
||||||
});
|
});
|
||||||
expect(findByType(emitted, "status")?.payload).toMatchObject({
|
expect(
|
||||||
status: "agent_created",
|
emitted.filter((message) => message.type === "status").map((message) => message.payload),
|
||||||
agent: { cwd: child },
|
).toEqual(
|
||||||
});
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "agent_created",
|
||||||
|
requestId: "req-create-child-1",
|
||||||
|
agent: expect.objectContaining({ cwd: child }),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "agent_created",
|
||||||
|
requestId: "req-create-child-2",
|
||||||
|
agent: expect.objectContaining({ cwd: child }),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(workdir, { recursive: true, force: true });
|
rmSync(workdir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js";
|
import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js";
|
||||||
@@ -14,25 +16,8 @@ import {
|
|||||||
unavailableUsage,
|
unavailableUsage,
|
||||||
} from "../usage.js";
|
} from "../usage.js";
|
||||||
|
|
||||||
// Cursor stores auth in VS Code's ItemTable (state.vscdb). Modern builds keep the
|
const execFileAsync = promisify(execFile);
|
||||||
// access token as a plain JWT string under `cursorAuth/accessToken`; older builds
|
const CURSOR_SQLITE_TIMEOUT_MS = 2_000;
|
||||||
// kept a JSON blob under `cursorAuthStatus`. Read it with node:sqlite so we don't
|
|
||||||
// depend on a `sqlite3` CLI, which isn't installed by default on Windows (or on many
|
|
||||||
// Linux hosts) — a missing binary silently rendered Cursor usage unavailable.
|
|
||||||
const CURSOR_ACCESS_TOKEN_KEY = "cursorAuth/accessToken";
|
|
||||||
const CURSOR_LEGACY_AUTH_KEY = "cursorAuthStatus";
|
|
||||||
|
|
||||||
// @types/node@20 predates the node:sqlite typings; declare the slice we use.
|
|
||||||
interface CursorStateStatement {
|
|
||||||
get(...params: unknown[]): Record<string, unknown> | undefined;
|
|
||||||
}
|
|
||||||
interface CursorStateDatabase {
|
|
||||||
prepare(sql: string): CursorStateStatement;
|
|
||||||
close(): void;
|
|
||||||
}
|
|
||||||
interface NodeSqliteModule {
|
|
||||||
DatabaseSync: new (path: string, options?: { readOnly?: boolean }) => CursorStateDatabase;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CursorBillingCycleTimestampSchema = z.preprocess(
|
const CursorBillingCycleTimestampSchema = z.preprocess(
|
||||||
(value) => (typeof value === "string" || typeof value === "number" ? value : null),
|
(value) => (typeof value === "string" || typeof value === "number" ? value : null),
|
||||||
@@ -62,7 +47,6 @@ type CursorUsageResponse = z.infer<typeof CursorUsageResponseSchema>;
|
|||||||
interface CursorQuotaProviderOptions {
|
interface CursorQuotaProviderOptions {
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
fetch?: ProviderApiFetch;
|
fetch?: ProviderApiFetch;
|
||||||
homeDir?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCursorBillingCycleTimestamp(
|
function parseCursorBillingCycleTimestamp(
|
||||||
@@ -86,38 +70,14 @@ function centsToDollars(value: number | null): number | null {
|
|||||||
return value === null ? null : value / 100;
|
return value === null ? null : value / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readItemTableValue(db: CursorStateDatabase, key: string): string | null {
|
async function readCursorTokenFromSqlite(): Promise<string | null> {
|
||||||
const row = db.prepare("SELECT value FROM ItemTable WHERE key = ?").get(key);
|
|
||||||
const value = row?.["value"];
|
|
||||||
if (typeof value === "string") return value;
|
|
||||||
if (value instanceof Uint8Array) return Buffer.from(value).toString("utf8");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cursorTokenFromDb(db: CursorStateDatabase): string | null {
|
|
||||||
const modern = readItemTableValue(db, CURSOR_ACCESS_TOKEN_KEY)?.trim();
|
|
||||||
if (modern) return modern;
|
|
||||||
|
|
||||||
const legacy = readItemTableValue(db, CURSOR_LEGACY_AUTH_KEY);
|
|
||||||
if (legacy) {
|
|
||||||
try {
|
|
||||||
const parsed = CursorAuthStatusSchema.parse(JSON.parse(legacy));
|
|
||||||
if (parsed.accessToken) return parsed.accessToken;
|
|
||||||
} catch {
|
|
||||||
// ignore a malformed legacy blob
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promise<string | null> {
|
|
||||||
const dbPaths: string[] = [];
|
const dbPaths: string[] = [];
|
||||||
if (process.env["APPDATA"]) {
|
if (process.env["APPDATA"]) {
|
||||||
dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb"));
|
dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb"));
|
||||||
}
|
}
|
||||||
dbPaths.push(
|
dbPaths.push(
|
||||||
join(
|
join(
|
||||||
homeDir,
|
homedir(),
|
||||||
"Library",
|
"Library",
|
||||||
"Application Support",
|
"Application Support",
|
||||||
"Cursor",
|
"Cursor",
|
||||||
@@ -126,32 +86,22 @@ async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promi
|
|||||||
"state.vscdb",
|
"state.vscdb",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
dbPaths.push(join(homeDir, ".config", "Cursor", "User", "globalStorage", "state.vscdb"));
|
dbPaths.push(join(homedir(), ".config", "Cursor", "User", "globalStorage", "state.vscdb"));
|
||||||
|
|
||||||
// Held in a variable so TypeScript skips module resolution: @types/node@20 has no
|
|
||||||
// node:sqlite typings yet, while the runtime (Node 22+ / Electron) provides it.
|
|
||||||
const sqliteSpecifier: string = "node:sqlite";
|
|
||||||
let sqlite: NodeSqliteModule;
|
|
||||||
try {
|
|
||||||
sqlite = (await import(sqliteSpecifier)) as unknown as NodeSqliteModule;
|
|
||||||
} catch (err) {
|
|
||||||
logger.debug({ err }, "node:sqlite unavailable; cannot read Cursor state.vscdb");
|
|
||||||
return null; // runtime without node:sqlite
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const path of dbPaths) {
|
for (const path of dbPaths) {
|
||||||
if (!existsSync(path)) continue;
|
if (!existsSync(path)) continue;
|
||||||
let db: CursorStateDatabase | undefined;
|
|
||||||
try {
|
try {
|
||||||
db = new sqlite.DatabaseSync(path, { readOnly: true });
|
const { stdout } = await execFileAsync(
|
||||||
const token = cursorTokenFromDb(db);
|
"sqlite3",
|
||||||
if (token) return token;
|
[path, "SELECT value FROM ItemTable WHERE key = 'cursorAuthStatus'"],
|
||||||
} catch (err) {
|
{ timeout: CURSOR_SQLITE_TIMEOUT_MS },
|
||||||
// Locked/permission/corrupt/schema failures all land here; log so an
|
);
|
||||||
// unavailable Cursor card is diagnosable, then try the next candidate.
|
if (stdout) {
|
||||||
logger.debug({ err, path }, "Failed to read Cursor token from state.vscdb");
|
const parsed = CursorAuthStatusSchema.parse(JSON.parse(stdout.trim()));
|
||||||
} finally {
|
if (parsed.accessToken) return parsed.accessToken;
|
||||||
db?.close();
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -163,19 +113,17 @@ export class CursorQuotaProvider implements ProviderUsageFetcher {
|
|||||||
|
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private readonly fetchApi: ProviderApiFetch;
|
private readonly fetchApi: ProviderApiFetch;
|
||||||
private readonly homeDir: string;
|
|
||||||
|
|
||||||
constructor(options: CursorQuotaProviderOptions) {
|
constructor(options: CursorQuotaProviderOptions) {
|
||||||
this.logger = options.logger;
|
this.logger = options.logger;
|
||||||
this.fetchApi = options.fetch ?? fetch;
|
this.fetchApi = options.fetch ?? fetch;
|
||||||
this.homeDir = options.homeDir ?? homedir();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchUsage(): Promise<ProviderUsage> {
|
async fetchUsage(): Promise<ProviderUsage> {
|
||||||
const token =
|
const token =
|
||||||
process.env["CURSOR_ACCESS_TOKEN"] ||
|
process.env["CURSOR_ACCESS_TOKEN"] ||
|
||||||
process.env["CURSOR_TOKEN"] ||
|
process.env["CURSOR_TOKEN"] ||
|
||||||
(await readCursorTokenFromSqlite(this.homeDir, this.logger));
|
(await readCursorTokenFromSqlite());
|
||||||
|
|
||||||
if (!token) return unavailableUsage(this);
|
if (!token) return unavailableUsage(this);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
import { promises as fsPromises } from "node:fs";
|
import { promises as fsPromises } from "node:fs";
|
||||||
import { createRequire } from "node:module";
|
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -59,31 +58,6 @@ function writeKimiCredentials(dir: string, accessToken: string, overrides: objec
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// node:sqlite has no @types/node@20 typings; require it with a narrow local type.
|
|
||||||
const testRequire = createRequire(import.meta.url);
|
|
||||||
interface TestSqliteDb {
|
|
||||||
exec(sql: string): void;
|
|
||||||
prepare(sql: string): { run(...params: unknown[]): void };
|
|
||||||
close(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cursor builds have stored ItemTable values as both TEXT and BLOB. Keys map to the
|
|
||||||
// real layouts: a plain modern token or the legacy JSON object.
|
|
||||||
function writeCursorStateDb(homeDir: string, rows: Record<string, string | Uint8Array>): void {
|
|
||||||
const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage");
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
const { DatabaseSync } = testRequire("node:sqlite") as {
|
|
||||||
DatabaseSync: new (path: string) => TestSqliteDb;
|
|
||||||
};
|
|
||||||
const db = new DatabaseSync(join(dir, "state.vscdb"));
|
|
||||||
db.exec("CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)");
|
|
||||||
const insert = db.prepare("INSERT INTO ItemTable (key, value) VALUES (?, ?)");
|
|
||||||
for (const [key, value] of Object.entries(rows)) {
|
|
||||||
insert.run(key, value);
|
|
||||||
}
|
|
||||||
db.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeGrokAuth(home: string, auth: Record<string, unknown>): void {
|
function writeGrokAuth(home: string, auth: Record<string, unknown>): void {
|
||||||
mkdirSync(join(home, ".grok"), { recursive: true });
|
mkdirSync(join(home, ".grok"), { recursive: true });
|
||||||
writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth));
|
writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth));
|
||||||
@@ -398,7 +372,6 @@ describe("real provider usage fetchers", () => {
|
|||||||
platform?: typeof process.platform;
|
platform?: typeof process.platform;
|
||||||
keychain?: () => Promise<unknown | null>;
|
keychain?: () => Promise<unknown | null>;
|
||||||
kimiHomeDir?: string;
|
kimiHomeDir?: string;
|
||||||
cursorHomeDir?: string;
|
|
||||||
miniMaxConfigPath?: string;
|
miniMaxConfigPath?: string;
|
||||||
miniMaxCredentialsPath?: string;
|
miniMaxCredentialsPath?: string;
|
||||||
} = {},
|
} = {},
|
||||||
@@ -419,11 +392,7 @@ describe("real provider usage fetchers", () => {
|
|||||||
}),
|
}),
|
||||||
new CodexQuotaProvider({ logger, codexHome, fetch: fetchThroughTestDouble }),
|
new CodexQuotaProvider({ logger, codexHome, fetch: fetchThroughTestDouble }),
|
||||||
new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||||
new CursorQuotaProvider({
|
new CursorQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||||
logger,
|
|
||||||
fetch: fetchThroughTestDouble,
|
|
||||||
homeDir: options.cursorHomeDir,
|
|
||||||
}),
|
|
||||||
new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||||
new GrokQuotaProvider({
|
new GrokQuotaProvider({
|
||||||
logger,
|
logger,
|
||||||
@@ -733,75 +702,6 @@ describe("real provider usage fetchers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads the Cursor token from the modern cursorAuth/accessToken key in state.vscdb", async () => {
|
|
||||||
writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_state_jwt" });
|
|
||||||
let authorization: string | null = null;
|
|
||||||
fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
|
|
||||||
return jsonResponse({
|
|
||||||
planUsage: {
|
|
||||||
totalSpend: "1500",
|
|
||||||
includedSpend: "1000",
|
|
||||||
bonusSpend: "500",
|
|
||||||
remaining: "2500",
|
|
||||||
limit: "4000",
|
|
||||||
},
|
|
||||||
billingCycleStart: "2026-01-14T12:42:14.000Z",
|
|
||||||
billingCycleEnd: "2026-02-14T12:42:14.000Z",
|
|
||||||
});
|
|
||||||
}) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor");
|
|
||||||
|
|
||||||
expect(authorization).toBe("Bearer cursor_state_jwt");
|
|
||||||
expect(cursor).toMatchObject({
|
|
||||||
status: "available",
|
|
||||||
balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the legacy cursorAuthStatus JSON blob when the modern key is absent", async () => {
|
|
||||||
writeCursorStateDb(homeDir, {
|
|
||||||
cursorAuthStatus: Buffer.from(JSON.stringify({ accessToken: "cursor_legacy_jwt" }), "utf8"),
|
|
||||||
});
|
|
||||||
let authorization: string | null = null;
|
|
||||||
fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
|
|
||||||
return jsonResponse({
|
|
||||||
planUsage: { totalSpend: "0", remaining: "100", limit: "100" },
|
|
||||||
billingCycleStart: null,
|
|
||||||
billingCycleEnd: null,
|
|
||||||
});
|
|
||||||
}) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor");
|
|
||||||
|
|
||||||
expect(authorization).toBe("Bearer cursor_legacy_jwt");
|
|
||||||
expect(cursor.status).toBe("available");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logs a debug diagnostic and stays unavailable when state.vscdb is unreadable", async () => {
|
|
||||||
const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage");
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
writeFileSync(join(dir, "state.vscdb"), "not a sqlite database");
|
|
||||||
const logger = createLogger();
|
|
||||||
const provider = new CursorQuotaProvider({
|
|
||||||
logger,
|
|
||||||
fetch: (() => {
|
|
||||||
throw new Error("usage API should not be called without a token");
|
|
||||||
}) as unknown as typeof fetch,
|
|
||||||
homeDir,
|
|
||||||
});
|
|
||||||
|
|
||||||
const usage = await provider.fetchUsage();
|
|
||||||
|
|
||||||
expect(usage.status).toBe("unavailable");
|
|
||||||
expect((logger as unknown as { debug: ReturnType<typeof vi.fn> }).debug).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ path: expect.stringContaining("state.vscdb") }),
|
|
||||||
expect.stringContaining("Failed to read Cursor token"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fetches Z.ai usage from ZAI_API_KEY", async () => {
|
it("fetches Z.ai usage from ZAI_API_KEY", async () => {
|
||||||
process.env["ZAI_API_KEY"] = "zai_test_token";
|
process.env["ZAI_API_KEY"] = "zai_test_token";
|
||||||
fetchApi = mockFetch(
|
fetchApi = mockFetch(
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@
|
|||||||
},
|
},
|
||||||
"desktop": {
|
"desktop": {
|
||||||
"type": "service",
|
"type": "service",
|
||||||
"command": "PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"${PASEO_WORKTREE_PATH:-$PWD}\" PASEO_HOME=\"${PASEO_WORKTREE_PATH:-$PWD}/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT:-6768} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT:-6768} EXPO_PORT=${PASEO_PORT:-} exec ./packages/desktop/scripts/dev.sh"
|
"command": "PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"${PASEO_WORKTREE_PATH:-$PWD}\" PASEO_HOME=\"${PASEO_WORKTREE_PATH:-$PWD}/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT:-6768} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT:-6768} EXPO_PORT=${PASEO_PORT:-} npm run dev --workspace=@getpaseo/desktop"
|
||||||
},
|
},
|
||||||
"ios-simulator": {
|
"ios-simulator": {
|
||||||
"type": "service",
|
"type": "service",
|
||||||
|
|||||||
@@ -72,18 +72,10 @@ environments:
|
|||||||
cwd: /workspace/project
|
cwd: /workspace/project
|
||||||
worktree:
|
worktree:
|
||||||
mode: branch-off
|
mode: branch-off
|
||||||
newBranch: trigger-${{ paseo.execution.id }}
|
newBranch: paseo/review
|
||||||
base: origin/main
|
base: origin/main
|
||||||
```
|
```
|
||||||
|
|
||||||
`newBranch` is a branch-name string. Embed `${{ paseo.execution.id }}`, which renders the execution's UUID, so every execution branches off `base` on its own branch and keeps it when Hub retries or recovers that execution.
|
|
||||||
|
|
||||||
One execution is one step run, so two steps selecting the same environment get separate branches.
|
|
||||||
|
|
||||||
`${{ paseo.execution.id }}` is the only expression `newBranch` accepts. `paseo.prompt`, `paseo.context`, `paseo.inputs.*`, `values.*`, `steps.<id>.outputs.*`, and provider event fields are unavailable here, and each one fails bundle activation at the authored field, such as `.paseo/hub.yml.environments.review.worktree.newBranch`.
|
|
||||||
|
|
||||||
`${{ paseo.execution.id }}` fails activation the same way anywhere else in a bundle. `branch` and `prNumber` take literal values.
|
|
||||||
|
|
||||||
An environment is a complete named object. A step selects its name; objects are not inherited, merged, or partially overridden.
|
An environment is a complete named object. A step selects its name; objects are not inherited, merged, or partially overridden.
|
||||||
|
|
||||||
### Named agents
|
### Named agents
|
||||||
|
|||||||
@@ -80,13 +80,11 @@ To keep executions off your working tree, add a worktree:
|
|||||||
```yaml
|
```yaml
|
||||||
worktree:
|
worktree:
|
||||||
mode: branch-off
|
mode: branch-off
|
||||||
newBranch: trigger-${{ paseo.execution.id }}
|
newBranch: hub/investigation
|
||||||
base: origin/main
|
base: origin/main
|
||||||
```
|
```
|
||||||
|
|
||||||
`${{ paseo.execution.id }}` renders the execution's UUID, so every execution gets its own branch off `origin/main`.
|
See [Git worktrees](/docs/worktrees) for setup hooks and scripts.
|
||||||
|
|
||||||
[Environment fields](/docs/hub/configuration/hub-yml#environments) lists what `newBranch` accepts. See [Git worktrees](/docs/worktrees) for setup hooks and scripts.
|
|
||||||
|
|
||||||
## What Hub owns
|
## What Hub owns
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user