mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c6f285930 | ||
|
|
23fb1cc85a | ||
|
|
fd1551d46e | ||
|
|
4d67f08922 | ||
|
|
35edd688b5 | ||
|
|
eed6478a7d |
@@ -1,3 +1,4 @@
|
||||
import { parseSafeInteger } from "../../utils/integer.js";
|
||||
import type { DaemonTarget } from "../../utils/daemon-target.js";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandError, CommandOptions } from "../../output/index.js";
|
||||
@@ -441,8 +442,8 @@ function buildNewAgentConfigPatch(
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string, flag: string): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
const parsed = parseSafeInteger(value);
|
||||
if (parsed === undefined || parsed <= 0) {
|
||||
throw {
|
||||
code: "INVALID_INTEGER",
|
||||
message: `${flag} must be a positive integer`,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseSafeInteger } from "../../utils/integer.js";
|
||||
import type { Command } from "commander";
|
||||
import { renderError, toCommandError } from "../../output/render.js";
|
||||
import {
|
||||
@@ -85,8 +86,8 @@ function parseLineNumber(flag: string, value?: string): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
const parsed = parseSafeInteger(value);
|
||||
if (parsed === undefined) {
|
||||
throw {
|
||||
code: "INVALID_LINE_NUMBER",
|
||||
message: `Invalid ${flag} value: ${value}`,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export function parseSafeInteger(value: string): number | undefined {
|
||||
const input = value.trim();
|
||||
const match = /^[+-]?(\d*)(?:\.(\d*))?(?:e([+-]?\d+))?$/i.exec(input);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const digits = match[1] + (match[2] ?? "");
|
||||
const parsed = Number(input);
|
||||
if (digits.length === 0 || !Number.isSafeInteger(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check the decimal value before floating-point rounding can hide a fraction.
|
||||
const decimalPlaces = (match[2]?.length ?? 0) - Number(match[3] ?? "0");
|
||||
if (decimalPlaces > 0 && /[1-9]/.test(digits.slice(-decimalPlaces))) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/schedule-run-lifecycle.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
import { createPaseoClient, type PaseoClient } from "@getpaseo/client";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { runPaseoCli } from "../test-utils/cli.js";
|
||||
|
||||
let daemon: TestPaseoDaemon;
|
||||
let client: DaemonClient;
|
||||
@@ -28,6 +29,75 @@ afterEach(async () => {
|
||||
await rm(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||
});
|
||||
|
||||
async function createQuietTerminal() {
|
||||
const workspaceId = await createWorkspace("CLI capture");
|
||||
return sdk.terminals.create({
|
||||
workspaceId,
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.stdin.resume()"],
|
||||
size: { rows: 10, cols: 40 },
|
||||
});
|
||||
}
|
||||
|
||||
test.each([
|
||||
["--end", "4503599627370496.5"],
|
||||
["--start", "1.0000000000000001"],
|
||||
["--end", "4.5035996273704965e15"],
|
||||
["--end", "1e-324"],
|
||||
["--start", "1.5"],
|
||||
["--end", "1.5"],
|
||||
["--start", "1abc"],
|
||||
["--end", "1abc"],
|
||||
["--start", ""],
|
||||
["--end", ""],
|
||||
["--start", "9007199254740993"],
|
||||
["--end", "9007199254740993"],
|
||||
])("CLI terminal capture rejects %s %j", async (flag, value) => {
|
||||
const terminal = await createQuietTerminal();
|
||||
try {
|
||||
const result = await runPaseoCli({
|
||||
daemon,
|
||||
args: ["terminal", "capture", terminal.id, flag, value, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stdout + result.stderr).toBe(1);
|
||||
expect(JSON.parse(result.stderr)).toEqual({
|
||||
error: {
|
||||
code: "INVALID_LINE_NUMBER",
|
||||
message: `Invalid ${flag} value: ${value}`,
|
||||
details: "Use an integer line number.",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await terminal.kill();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ args: [], lineCount: 10 },
|
||||
{ args: ["--start", "0", "--end", "1"], lineCount: 2 },
|
||||
{ args: ["--end", "1.0"], lineCount: 2 },
|
||||
{ args: ["--end", "10e-1"], lineCount: 2 },
|
||||
{ args: ["--end", ".1e1"], lineCount: 2 },
|
||||
{ args: ["--start", "-2", "--end", "-1"], lineCount: 2 },
|
||||
{ args: ["--scrollback", "--start", "7", "--end", "1"], lineCount: 2 },
|
||||
])("CLI terminal capture preserves valid ranges $args", async ({ args, lineCount }) => {
|
||||
const terminal = await createQuietTerminal();
|
||||
try {
|
||||
const result = await runPaseoCli({
|
||||
daemon,
|
||||
args: ["terminal", "capture", terminal.id, ...args, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
terminalId: terminal.id,
|
||||
lines: Array.from({ length: lineCount }, () => ""),
|
||||
totalLines: 10,
|
||||
});
|
||||
} finally {
|
||||
await terminal.kill();
|
||||
}
|
||||
});
|
||||
|
||||
test("SDK and workspace handles preserve ownership and actual process directories", async () => {
|
||||
const first = await createWorkspace("main");
|
||||
const second = await createWorkspace("feature-work");
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "./test-utils/index.js";
|
||||
import type { SessionOutboundMessage } from "./messages.js";
|
||||
import { getPaseoWorktreesRoot } from "../utils/worktree.js";
|
||||
import { runPaseoCli } from "./test-utils/cli.js";
|
||||
|
||||
type AgentUpdateMessage = Extract<SessionOutboundMessage, { type: "agent_update" }>;
|
||||
type WorkspaceUpdateMessage = Extract<SessionOutboundMessage, { type: "workspace_update" }>;
|
||||
@@ -42,6 +43,137 @@ function makeTempDir(prefix: string): string {
|
||||
return dir;
|
||||
}
|
||||
|
||||
test.each([
|
||||
"4503599627370496.5",
|
||||
"1.0000000000000001",
|
||||
"2.5",
|
||||
"2runs",
|
||||
"9007199254740993",
|
||||
"0",
|
||||
"-1",
|
||||
"",
|
||||
])("CLI schedule create rejects max-runs %j without saving a schedule", async (maxRuns) => {
|
||||
const cwd = makeTempDir("schedule-cli-integer-");
|
||||
const result = await runPaseoCli({
|
||||
daemon: ctx.daemon,
|
||||
args: [
|
||||
"schedule",
|
||||
"create",
|
||||
"Integer limit",
|
||||
"--cron",
|
||||
"0 0 1 1 *",
|
||||
"--provider",
|
||||
"claude",
|
||||
"--cwd",
|
||||
cwd,
|
||||
"--max-runs",
|
||||
maxRuns,
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(result.exitCode, result.stdout + result.stderr).toBe(1);
|
||||
expect(JSON.parse(result.stderr)).toEqual({
|
||||
error: { code: "INVALID_INTEGER", message: "--max-runs must be a positive integer" },
|
||||
});
|
||||
expect((await ctx.client.scheduleList()).schedules).toEqual([]);
|
||||
});
|
||||
|
||||
test.each(["2.5", "2runs", "4503599627370496.5"])(
|
||||
"CLI schedule update rejects max-runs %j without changing the schedule",
|
||||
async (maxRuns) => {
|
||||
const cwd = makeTempDir("schedule-cli-update-integer-");
|
||||
const schedule = await createNewAgentSchedule({
|
||||
prompt: "Original prompt",
|
||||
cadence: { type: "cron", expression: "0 0 1 1 *", timezone: "UTC" },
|
||||
target: { type: "new-agent", config: { provider: "claude", cwd } },
|
||||
maxRuns: 3,
|
||||
runOnCreate: false,
|
||||
});
|
||||
const before = await ctx.client.scheduleInspect({ id: schedule.id });
|
||||
const result = await runPaseoCli({
|
||||
daemon: ctx.daemon,
|
||||
args: [
|
||||
"schedule",
|
||||
"update",
|
||||
schedule.id,
|
||||
"--prompt",
|
||||
"Should not apply",
|
||||
"--max-runs",
|
||||
maxRuns,
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(result.exitCode, result.stdout + result.stderr).toBe(1);
|
||||
expect(JSON.parse(result.stderr)).toEqual({
|
||||
error: { code: "INVALID_INTEGER", message: "--max-runs must be a positive integer" },
|
||||
});
|
||||
const after = await ctx.client.scheduleInspect({ id: schedule.id });
|
||||
expect(after.error).toBeNull();
|
||||
expect(after.schedule).toEqual(before.schedule);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ limitArgs: [], maxRuns: null },
|
||||
{ limitArgs: ["--max-runs", "3"], maxRuns: 3 },
|
||||
{ limitArgs: ["--max-runs", "3.0"], maxRuns: 3 },
|
||||
{ limitArgs: ["--max-runs", "1e2"], maxRuns: 100 },
|
||||
])(
|
||||
"CLI schedule create stores max-runs $maxRuns from $limitArgs",
|
||||
async ({ limitArgs, maxRuns }) => {
|
||||
const cwd = makeTempDir("schedule-cli-valid-integer-");
|
||||
const result = await runPaseoCli({
|
||||
daemon: ctx.daemon,
|
||||
args: [
|
||||
"schedule",
|
||||
"create",
|
||||
"Integer limit",
|
||||
"--cron",
|
||||
"0 0 1 1 *",
|
||||
"--provider",
|
||||
"claude",
|
||||
"--cwd",
|
||||
cwd,
|
||||
...limitArgs,
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect((await ctx.client.scheduleList()).schedules).toEqual([
|
||||
expect.objectContaining({ maxRuns, prompt: "Integer limit" }),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test("CLI schedule update preserves, changes, and clears valid run limits", async () => {
|
||||
const cwd = makeTempDir("schedule-cli-valid-update-");
|
||||
const schedule = await createNewAgentSchedule({
|
||||
prompt: "Original prompt",
|
||||
cadence: { type: "cron", expression: "0 0 1 1 *", timezone: "UTC" },
|
||||
target: { type: "new-agent", config: { provider: "claude", cwd } },
|
||||
maxRuns: 3,
|
||||
runOnCreate: false,
|
||||
});
|
||||
for (const { args, maxRuns } of [
|
||||
{ args: ["--name", "Renamed"], maxRuns: 3 },
|
||||
{ args: ["--max-runs", "5"], maxRuns: 5 },
|
||||
{ args: ["--no-max-runs"], maxRuns: null },
|
||||
]) {
|
||||
const result = await runPaseoCli({
|
||||
daemon: ctx.daemon,
|
||||
args: ["schedule", "update", schedule.id, ...args, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect((await ctx.client.scheduleInspect({ id: schedule.id })).schedule).toMatchObject({
|
||||
maxRuns,
|
||||
name: "Renamed",
|
||||
prompt: schedule.prompt,
|
||||
target: schedule.target,
|
||||
cadence: schedule.cadence,
|
||||
});
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
function createGitRepo(): string {
|
||||
const tempRoot = makeTempDir("schedule-run-worktree-");
|
||||
const repoDir = path.join(tempRoot, "repo");
|
||||
@@ -60,7 +192,13 @@ function createGitRepo(): string {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
return repoDir;
|
||||
// Use Git's canonical root, as worktree creation does, including Windows path aliases.
|
||||
return realpathSync(
|
||||
execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
);
|
||||
}
|
||||
|
||||
async function createNewAgentSchedule(options: ScheduleCreateOptions): Promise<ScheduleSummary> {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { TestPaseoDaemon } from "./paseo-daemon.js";
|
||||
|
||||
interface CliOptions {
|
||||
daemon: Pick<TestPaseoDaemon, "port" | "paseoHome">;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
interface CliResult {
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export function runPaseoCli({ daemon, args }: CliOptions): Promise<CliResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--no-warnings",
|
||||
"--conditions=source",
|
||||
"--import",
|
||||
"tsx",
|
||||
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
|
||||
"--host",
|
||||
`127.0.0.1:${daemon.port}`,
|
||||
...args,
|
||||
],
|
||||
{
|
||||
env: { ...process.env, PASEO_HOME: daemon.paseoHome },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: 20_000,
|
||||
killSignal: "SIGKILL",
|
||||
},
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("close", (exitCode, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`CLI exited on ${signal}:\n${stdout}\n${stderr}`));
|
||||
} else {
|
||||
resolve({ exitCode, stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type WorktreeConfig,
|
||||
} from "./worktree";
|
||||
import { execFileSync } from "child_process";
|
||||
import { rm } from "node:fs/promises";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
@@ -89,8 +90,8 @@ describe("paseo worktree manager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
});
|
||||
|
||||
it("treats a worktree as paseo-owned even when its .git admin is missing", async () => {
|
||||
|
||||
Reference in New Issue
Block a user