Make workspace, agent, and schedule automation consistent (#2186)
* feat(workspaces): align agent and schedule automation Make workspace identity the shared placement boundary for MCP and CLI, while caller identity determines parentage. Keep heartbeats minimal and cron-based without changing legacy rolling interval semantics. * test(cli): expect canonical schedule cadence * fix(automation): preserve workspace and schedule compatibility * fix(automation): preserve compatibility edges * fix(workspaces): align MCP lifecycle resolution * fix(workspaces): preserve creation intent * fix(workspaces): preserve branch and schedule identity
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCli } from "./cli.js";
|
||||
|
||||
describe("canonical CLI surface", () => {
|
||||
it("shows workspace and heartbeat commands while hiding worktree compatibility", () => {
|
||||
const cli = createCli();
|
||||
const help = cli.helpInformation();
|
||||
expect(help).toContain("workspace");
|
||||
expect(help).toContain("heartbeat");
|
||||
expect(help).not.toContain("worktree");
|
||||
});
|
||||
|
||||
it("hides legacy run worktree syntax", () => {
|
||||
const run = createCli().commands.find((command) => command.name() === "run");
|
||||
expect(run?.helpInformation()).toContain("--isolation <local|worktree>");
|
||||
expect(run?.helpInformation()).not.toContain("--worktree <name>");
|
||||
});
|
||||
|
||||
it("uses background for execution and reserves detach for ownership", () => {
|
||||
const run = createCli().commands.find((command) => command.name() === "run");
|
||||
expect(run?.helpInformation()).toContain("--background");
|
||||
expect(run?.helpInformation()).not.toContain("--detach");
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import { createScheduleCommand } from "./commands/schedule/index.js";
|
||||
import { createSpeechCommand } from "./commands/speech/index.js";
|
||||
import { createTerminalCommand } from "./commands/terminal/index.js";
|
||||
import { createWorktreeCommand } from "./commands/worktree/index.js";
|
||||
import { createWorkspaceCommand } from "./commands/workspace/index.js";
|
||||
import { createHeartbeatCommand } from "./commands/heartbeat/index.js";
|
||||
import { createHubCommand } from "./commands/hub/index.js";
|
||||
import { createHooksCommand } from "./commands/hooks.js";
|
||||
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
|
||||
@@ -174,6 +176,7 @@ export function createCli(): Command {
|
||||
|
||||
// Schedule commands
|
||||
program.addCommand(createScheduleCommand());
|
||||
program.addCommand(createHeartbeatCommand());
|
||||
|
||||
// Permission commands
|
||||
program.addCommand(createPermitCommand());
|
||||
@@ -184,8 +187,11 @@ export function createCli(): Command {
|
||||
// Speech model commands
|
||||
program.addCommand(createSpeechCommand());
|
||||
|
||||
// Worktree commands
|
||||
program.addCommand(createWorktreeCommand());
|
||||
// Workspace commands
|
||||
program.addCommand(createWorkspaceCommand());
|
||||
// COMPAT(worktreeCli): legacy command alias added before workspace was the product unit.
|
||||
// Added in v0.2.0; remove after 2027-01-17.
|
||||
program.addCommand(createWorktreeCommand(), { hidden: true });
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Command } from "commander";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandError,
|
||||
CommandOptions,
|
||||
OutputSchema,
|
||||
SingleResult,
|
||||
} from "../../output/index.js";
|
||||
|
||||
interface AgentDetachResult {
|
||||
agentId: string;
|
||||
status: "detached";
|
||||
}
|
||||
|
||||
const detachSchema: OutputSchema<AgentDetachResult> = {
|
||||
idField: "agentId",
|
||||
columns: [
|
||||
{ header: "AGENT ID", field: "agentId" },
|
||||
{ header: "STATUS", field: "status" },
|
||||
],
|
||||
};
|
||||
|
||||
export async function runDetachCommand(
|
||||
agentIdArg: string,
|
||||
options: CommandOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<AgentDetachResult>> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
let client: DaemonClient;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
const agentId = resolveAgentId(
|
||||
agentIdArg,
|
||||
payload.entries.map((entry) => entry.agent),
|
||||
);
|
||||
if (!agentId) {
|
||||
throw {
|
||||
code: "AGENT_NOT_FOUND",
|
||||
message: `Agent not found: ${agentIdArg}`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
await client.detachAgent(agentId);
|
||||
return { type: "single", data: { agentId, status: "detached" }, schema: detachSchema };
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { addAttachOptions, runAttachCommand } from "./attach.js";
|
||||
import { addReloadOptions, runReloadCommand } from "./reload.js";
|
||||
import { addImportOptions, runImportCommand } from "./import.js";
|
||||
import { runUpdateCommand } from "./update.js";
|
||||
import { runDetachCommand } from "./detach.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import {
|
||||
addDaemonHostOption,
|
||||
@@ -76,6 +77,13 @@ export function createAgentCommand(): Command {
|
||||
withOutput(runReloadCommand),
|
||||
);
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.command("detach")
|
||||
.description("Make a subagent independent without stopping or moving it")
|
||||
.argument("<id>", "Agent ID, prefix, or name"),
|
||||
).action(withOutput(runDetachCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.command("update")
|
||||
|
||||
@@ -1,5 +1,52 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { runRunCommand, type AgentRunOptions } from "./run";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resolveExistingRunWorkspace,
|
||||
resolveRunCallerAgentId,
|
||||
runRunCommand,
|
||||
type AgentRunOptions,
|
||||
} from "./run";
|
||||
|
||||
describe("managed agent caller context", () => {
|
||||
it("propagates a trimmed PASEO_AGENT_ID", () => {
|
||||
expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " parent-agent " })).toBe("parent-agent");
|
||||
});
|
||||
|
||||
it("omits blank caller ids", () => {
|
||||
expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " " })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("existing run workspace resolution", () => {
|
||||
it("queries the daemon for an exact workspace id and uses its directory", async () => {
|
||||
const fetchWorkspaces = vi.fn().mockResolvedValue({
|
||||
entries: [{ id: "workspace-2", workspaceDirectory: "/workspace/two" }],
|
||||
pageInfo: { nextCursor: null },
|
||||
});
|
||||
|
||||
await expect(resolveExistingRunWorkspace({ fetchWorkspaces }, "workspace-2")).resolves.toEqual({
|
||||
id: "workspace-2",
|
||||
cwd: "/workspace/two",
|
||||
});
|
||||
expect(fetchWorkspaces).toHaveBeenCalledWith({
|
||||
filter: { query: "workspace-2" },
|
||||
page: { limit: 200 },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a workspace id absent from daemon state", async () => {
|
||||
const fetchWorkspaces = vi.fn().mockResolvedValue({
|
||||
entries: [],
|
||||
pageInfo: { nextCursor: null },
|
||||
});
|
||||
|
||||
await expect(resolveExistingRunWorkspace({ fetchWorkspaces }, "missing")).rejects.toMatchObject(
|
||||
{
|
||||
code: "WORKSPACE_NOT_FOUND",
|
||||
message: "Workspace not found: missing",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// validateRunOptions runs before the CLI ever connects to a daemon, so these
|
||||
// invalid combinations reject without one running.
|
||||
@@ -25,27 +72,23 @@ describe("runRunCommand option validation", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it("rejects --worktree combined with --workspace", async () => {
|
||||
it("rejects --isolation combined with --workspace", async () => {
|
||||
await expectInvalidOptions(
|
||||
{ worktree: "feat", workspace: "ws-1" },
|
||||
/--worktree and --workspace cannot be combined/,
|
||||
{ isolation: "worktree", workspace: "ws-1" },
|
||||
/--isolation and --workspace cannot be combined/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects --worktree combined with an ambient PASEO_WORKSPACE_ID", async () => {
|
||||
process.env.PASEO_WORKSPACE_ID = "ws-ambient";
|
||||
await expectInvalidOptions(
|
||||
{ worktree: "feat" },
|
||||
/--worktree cannot be combined with an ambient PASEO_WORKSPACE_ID/,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a bare --worktree through validation when no workspace is selected", async () => {
|
||||
// A bare --worktree with no --workspace and no ambient PASEO_WORKSPACE_ID
|
||||
it("allows explicit worktree isolation through validation", async () => {
|
||||
// Explicit isolation with no --workspace
|
||||
// must clear validation. It still fails later (provider resolution), which
|
||||
// is enough to prove the new guard did not reject it.
|
||||
await expect(
|
||||
runRunCommand("do something", { worktree: "feat", provider: undefined }, {} as never),
|
||||
runRunCommand("do something", { isolation: "worktree", provider: undefined }, {} as never),
|
||||
).rejects.not.toMatchObject({ code: "INVALID_OPTIONS" });
|
||||
});
|
||||
|
||||
it("rejects unknown workspace isolation", async () => {
|
||||
await expectInvalidOptions({ isolation: "container" }, /Unsupported workspace isolation/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,55 +18,61 @@ import { resolveProviderAndModel } from "../../utils/provider-model.js";
|
||||
export { resolveProviderAndModel } from "../../utils/provider-model.js";
|
||||
|
||||
export function addRunOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.description("Create and start an agent with a task")
|
||||
.argument("<prompt>", "The task/prompt for the agent")
|
||||
.option("-d, --detach", "Run in background (detached)")
|
||||
.option("--title <title>", "Assign a title to the agent")
|
||||
.addOption(new Option("--name <name>", "Hidden alias for --title").hideHelp())
|
||||
.option(
|
||||
"--provider <provider>",
|
||||
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
|
||||
)
|
||||
.option(
|
||||
"--model <model>",
|
||||
"Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)",
|
||||
)
|
||||
.option("--thinking <id>", "Thinking option ID to use for this run")
|
||||
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
|
||||
.option("--worktree <name>", "Create agent in a new git worktree")
|
||||
.option("--base <branch>", "Base branch for worktree (default: current branch)")
|
||||
.option(
|
||||
"--workspace <id>",
|
||||
"Run in an existing workspace (default: a new workspace is created per run; falls back to $PASEO_WORKSPACE_ID)",
|
||||
)
|
||||
.option(
|
||||
"--image <path>",
|
||||
"Attach image(s) to the initial prompt (can be used multiple times)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option("--cwd <path>", "Working directory (default: current)")
|
||||
.option(
|
||||
"--env <key=value>",
|
||||
"Set environment variable(s) for the agent process (can be used multiple times)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
"--label <key=value>",
|
||||
"Add label(s) to the agent (can be used multiple times)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
"--wait-timeout <duration>",
|
||||
"Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit",
|
||||
)
|
||||
.option(
|
||||
"--output-schema <schema>",
|
||||
"Output JSON matching the provided schema file path or inline JSON schema",
|
||||
);
|
||||
return (
|
||||
cmd
|
||||
.description("Create and start an agent with a task")
|
||||
.argument("<prompt>", "The task/prompt for the agent")
|
||||
.option("-d, --background", "Run in background")
|
||||
// COMPAT(detachRunFlag): --detach used to mean background execution, not
|
||||
// ownership transfer. Added in v0.2.0; remove after 2027-01-17.
|
||||
.addOption(new Option("--detach", "Legacy alias for --background").hideHelp())
|
||||
.option("--title <title>", "Assign a title to the agent")
|
||||
.addOption(new Option("--name <name>", "Hidden alias for --title").hideHelp())
|
||||
.option(
|
||||
"--provider <provider>",
|
||||
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
|
||||
)
|
||||
.option(
|
||||
"--model <model>",
|
||||
"Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)",
|
||||
)
|
||||
.option("--thinking <id>", "Thinking option ID to use for this run")
|
||||
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
|
||||
.option("--isolation <local|worktree>", "Create a new workspace with this isolation")
|
||||
.addOption(new Option("--worktree <name>", "Legacy workspace isolation alias").hideHelp())
|
||||
.option("--base <branch>", "Base branch for an isolated workspace")
|
||||
.option(
|
||||
"--workspace <id>",
|
||||
"Run in an existing workspace (default: a new workspace is created per run; falls back to $PASEO_WORKSPACE_ID)",
|
||||
)
|
||||
.option(
|
||||
"--image <path>",
|
||||
"Attach image(s) to the initial prompt (can be used multiple times)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option("--cwd <path>", "Working directory (default: current)")
|
||||
.option(
|
||||
"--env <key=value>",
|
||||
"Set environment variable(s) for the agent process (can be used multiple times)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
"--label <key=value>",
|
||||
"Add label(s) to the agent (can be used multiple times)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
"--wait-timeout <duration>",
|
||||
"Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit",
|
||||
)
|
||||
.option(
|
||||
"--output-schema <schema>",
|
||||
"Output JSON matching the provided schema file path or inline JSON schema",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Result type for agent run command */
|
||||
@@ -91,6 +97,7 @@ export const agentRunSchema: OutputSchema<AgentRunResult> = {
|
||||
};
|
||||
|
||||
export interface AgentRunOptions extends CommandOptions {
|
||||
background?: boolean;
|
||||
detach?: boolean;
|
||||
title?: string;
|
||||
name?: string;
|
||||
@@ -98,6 +105,7 @@ export interface AgentRunOptions extends CommandOptions {
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
mode?: string;
|
||||
isolation?: string;
|
||||
worktree?: string;
|
||||
base?: string;
|
||||
workspace?: string;
|
||||
@@ -270,40 +278,54 @@ function validateRunOptions(prompt: string, options: AgentRunOptions, outputSche
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
if (options.base && !options.worktree) {
|
||||
const createsIsolatedWorkspace = options.isolation === "worktree" || Boolean(options.worktree);
|
||||
if (options.isolation && options.isolation !== "local" && options.isolation !== "worktree") {
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--base can only be used with --worktree",
|
||||
details: "Usage: paseo agent run --worktree <name> --base <branch> <prompt>",
|
||||
message: `Unsupported workspace isolation: ${options.isolation}`,
|
||||
details: "Use --isolation local or --isolation worktree",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
if (options.base && !createsIsolatedWorkspace) {
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--base can only be used with --isolation worktree",
|
||||
details: "Usage: paseo agent run --isolation worktree --base <branch> <prompt>",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
if (options.isolation && options.workspace) {
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--isolation and --workspace cannot be combined",
|
||||
details: "Select an existing workspace or create a new one with an isolation choice",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
// COMPAT(worktreeRunFlag): --worktree implies a new worktree-isolated workspace.
|
||||
// Added in v0.2.0; remove after 2027-01-17.
|
||||
if (options.worktree && options.workspace) {
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--worktree and --workspace cannot be combined",
|
||||
details: "--workspace runs in an existing workspace; --worktree mints a new one",
|
||||
details: "Use --isolation worktree instead of the legacy --worktree flag",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
if (options.worktree && !options.workspace && process.env.PASEO_WORKSPACE_ID) {
|
||||
if (outputSchema && runsInBackground(options)) {
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--worktree cannot be combined with an ambient PASEO_WORKSPACE_ID",
|
||||
details:
|
||||
"PASEO_WORKSPACE_ID selects an existing workspace; --worktree mints a new one. Unset PASEO_WORKSPACE_ID to use --worktree.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
if (outputSchema && options.detach) {
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--output-schema cannot be used with --detach",
|
||||
message: "--output-schema cannot be used with --background",
|
||||
details: "Structured output requires waiting for the agent to finish",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
}
|
||||
|
||||
function runsInBackground(options: Pick<AgentRunOptions, "background" | "detach">): boolean {
|
||||
return Boolean(options.background || options.detach);
|
||||
}
|
||||
|
||||
function parseWaitTimeoutOption(waitTimeout: string | undefined): number {
|
||||
if (!waitTimeout) return 0;
|
||||
try {
|
||||
@@ -409,45 +431,79 @@ async function connectToDaemonOrThrow(
|
||||
// runs in. The CLI resolves one before creating any agent, so no run leans on
|
||||
// createAgent's legacy cwd->workspace fallback.
|
||||
interface RunWorkspace {
|
||||
id: string;
|
||||
id?: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface RunWorkspaceLookupClient {
|
||||
fetchWorkspaces(options: { filter: { query: string }; page: { limit: number } }): Promise<{
|
||||
entries: Array<{ id: string; workspaceDirectory: string }>;
|
||||
pageInfo: { nextCursor: string | null };
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function resolveExistingRunWorkspace(
|
||||
client: RunWorkspaceLookupClient,
|
||||
workspaceId: string,
|
||||
): Promise<RunWorkspace> {
|
||||
const result = await client.fetchWorkspaces({
|
||||
filter: { query: workspaceId },
|
||||
page: { limit: 200 },
|
||||
});
|
||||
const workspace = result.entries.find((entry) => entry.id === workspaceId);
|
||||
if (workspace) {
|
||||
return { id: workspace.id, cwd: workspace.workspaceDirectory };
|
||||
}
|
||||
|
||||
throw {
|
||||
code: "WORKSPACE_NOT_FOUND",
|
||||
message: `Workspace not found: ${workspaceId}`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
// Workspace policy for `paseo run`. Precedence:
|
||||
// 1. --workspace <id> -> run in that existing workspace
|
||||
// 2. $PASEO_WORKSPACE_ID -> exported by workspace terminals
|
||||
// 3. --worktree <name> -> mint a new worktree-backed workspace
|
||||
// 4. bare run -> mint a new local-backed workspace for cwd
|
||||
// --worktree is rejected alongside both --workspace and an ambient
|
||||
// $PASEO_WORKSPACE_ID (validateRunOptions), so worktree resolution here never
|
||||
// races an existing-workspace selection.
|
||||
// 2. $PASEO_AGENT_ID -> daemon resolves the caller's workspace
|
||||
// 3. $PASEO_WORKSPACE_ID -> exported by workspace terminals
|
||||
// 4. --isolation <kind> -> mint a new workspace with explicit isolation
|
||||
// 5. bare run -> mint a new local-backed workspace for cwd
|
||||
async function resolveRunWorkspace(
|
||||
client: ConnectedDaemonClient,
|
||||
options: AgentRunOptions,
|
||||
cwd: string,
|
||||
): Promise<RunWorkspace> {
|
||||
// An explicit --worktree mints its own workspace; --workspace and an ambient
|
||||
// PASEO_WORKSPACE_ID are both rejected alongside --worktree upstream.
|
||||
const explicit = options.worktree
|
||||
? undefined
|
||||
: options.workspace?.trim() || process.env.PASEO_WORKSPACE_ID?.trim();
|
||||
const requestedIsolation = options.isolation ?? (options.worktree ? "worktree" : undefined);
|
||||
const explicit = requestedIsolation ? undefined : options.workspace?.trim();
|
||||
if (explicit) {
|
||||
console.error(`Using workspace ${explicit}`);
|
||||
return { id: explicit, cwd };
|
||||
return resolveExistingRunWorkspace(client, explicit);
|
||||
}
|
||||
|
||||
if (!requestedIsolation && resolveRunCallerAgentId()) {
|
||||
return { cwd };
|
||||
}
|
||||
|
||||
const ambientWorkspaceId = requestedIsolation
|
||||
? undefined
|
||||
: process.env.PASEO_WORKSPACE_ID?.trim();
|
||||
if (ambientWorkspaceId) {
|
||||
console.error(`Using workspace ${ambientWorkspaceId}`);
|
||||
return resolveExistingRunWorkspace(client, ambientWorkspaceId);
|
||||
}
|
||||
|
||||
// TODO: thread the run `prompt` as firstAgentContext so workspace-level
|
||||
// title/branch generation picks up the task description (U8/U6 deferred).
|
||||
const result = options.worktree
|
||||
? await client.createWorkspace({
|
||||
source: {
|
||||
kind: "worktree",
|
||||
cwd,
|
||||
worktreeSlug: options.worktree,
|
||||
baseBranch: options.base,
|
||||
},
|
||||
})
|
||||
: await client.createWorkspace({ source: { kind: "directory", path: cwd } });
|
||||
const result =
|
||||
requestedIsolation === "worktree"
|
||||
? await client.createWorkspace({
|
||||
source: {
|
||||
kind: "worktree",
|
||||
cwd,
|
||||
worktreeSlug: options.worktree,
|
||||
baseBranch: options.base,
|
||||
},
|
||||
})
|
||||
: await client.createWorkspace({ source: { kind: "directory", path: cwd } });
|
||||
|
||||
if (!result.workspace) {
|
||||
throw {
|
||||
@@ -503,6 +559,7 @@ export async function runRunCommand(
|
||||
|
||||
const workspace = await resolveRunWorkspace(client, options, cwd);
|
||||
const workspaceId = workspace.id;
|
||||
const callerAgentId = resolveRunCallerAgentId();
|
||||
const runCwd = workspace.cwd;
|
||||
|
||||
if (outputSchema) {
|
||||
@@ -514,6 +571,7 @@ export async function runRunCommand(
|
||||
provider: resolvedProviderModel.provider,
|
||||
cwd: runCwd,
|
||||
workspaceId,
|
||||
callerAgentId,
|
||||
title: resolvedTitle,
|
||||
modeId: options.mode,
|
||||
model: resolvedProviderModel.model,
|
||||
@@ -584,6 +642,7 @@ export async function runRunCommand(
|
||||
provider: resolvedProviderModel.provider,
|
||||
cwd: runCwd,
|
||||
workspaceId,
|
||||
callerAgentId,
|
||||
title: resolvedTitle,
|
||||
modeId: options.mode,
|
||||
model: resolvedProviderModel.model,
|
||||
@@ -594,8 +653,8 @@ export async function runRunCommand(
|
||||
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
||||
});
|
||||
|
||||
// Default run behavior is foreground: wait for completion unless --detach is set.
|
||||
if (!options.detach) {
|
||||
// Default run behavior is foreground: wait for completion unless background execution is set.
|
||||
if (!runsInBackground(options)) {
|
||||
const state = await client.waitForFinish(agent.id, waitTimeoutMs);
|
||||
await client.close();
|
||||
|
||||
@@ -631,3 +690,9 @@ export async function runRunCommand(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRunCallerAgentId(
|
||||
env: { PASEO_AGENT_ID?: string } = process.env,
|
||||
): string | undefined {
|
||||
return env.PASEO_AGENT_ID?.trim() || undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Command } from "commander";
|
||||
import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
import { parseDuration } from "../../utils/duration.js";
|
||||
import {
|
||||
connectScheduleClient,
|
||||
toScheduleCommandError,
|
||||
toScheduleRow,
|
||||
type ScheduleRow,
|
||||
} from "../schedule/shared.js";
|
||||
import { scheduleSchema } from "../schedule/schema.js";
|
||||
|
||||
interface HeartbeatOptions extends CommandOptions {
|
||||
cron?: string;
|
||||
timezone?: string;
|
||||
name?: string;
|
||||
maxRuns?: string;
|
||||
expiresIn?: string;
|
||||
}
|
||||
|
||||
interface HeartbeatDeleteRow {
|
||||
id: string;
|
||||
status: "deleted";
|
||||
}
|
||||
|
||||
const heartbeatDeleteSchema: OutputSchema<HeartbeatDeleteRow> = {
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: "ID", field: "id" },
|
||||
{ header: "STATUS", field: "status" },
|
||||
],
|
||||
};
|
||||
|
||||
function requireCallerAgentId(): string {
|
||||
const agentId = process.env.PASEO_AGENT_ID?.trim();
|
||||
if (!agentId) {
|
||||
throw new Error("Heartbeat commands must run inside a Paseo agent");
|
||||
}
|
||||
return agentId;
|
||||
}
|
||||
|
||||
async function requireOwnedHeartbeat(
|
||||
client: Awaited<ReturnType<typeof connectScheduleClient>>["client"],
|
||||
id: string,
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
const payload = await client.scheduleInspect({ id });
|
||||
if (payload.error || !payload.schedule) {
|
||||
throw new Error(payload.error ?? `Heartbeat not found: ${id}`);
|
||||
}
|
||||
if (payload.schedule.target.type !== "agent" || payload.schedule.target.agentId !== agentId) {
|
||||
throw new Error(`Heartbeat ${id} does not belong to agent ${agentId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCreateHeartbeat(
|
||||
prompt: string,
|
||||
options: HeartbeatOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<ScheduleRow>> {
|
||||
const agentId = requireCallerAgentId();
|
||||
const cron = options.cron?.trim();
|
||||
if (!cron) {
|
||||
throw new Error("--cron is required");
|
||||
}
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
try {
|
||||
const maxRuns = options.maxRuns ? Number.parseInt(options.maxRuns, 10) : undefined;
|
||||
if (maxRuns !== undefined && (!Number.isSafeInteger(maxRuns) || maxRuns <= 0)) {
|
||||
throw new Error("--max-runs must be a positive integer");
|
||||
}
|
||||
const payload = await client.scheduleCreate({
|
||||
prompt: prompt.trim(),
|
||||
cadence: {
|
||||
type: "cron",
|
||||
expression: cron,
|
||||
...(options.timezone?.trim() ? { timezone: options.timezone.trim() } : {}),
|
||||
},
|
||||
target: { type: "agent", agentId },
|
||||
...(options.name?.trim() ? { name: options.name.trim() } : {}),
|
||||
...(maxRuns ? { maxRuns } : {}),
|
||||
...(options.expiresIn
|
||||
? { expiresAt: new Date(Date.now() + parseDuration(options.expiresIn)).toISOString() }
|
||||
: {}),
|
||||
});
|
||||
if (payload.error || !payload.schedule) {
|
||||
throw new Error(payload.error ?? "Heartbeat creation failed");
|
||||
}
|
||||
return { type: "single", data: toScheduleRow(payload.schedule), schema: scheduleSchema };
|
||||
} catch (error) {
|
||||
throw toScheduleCommandError("HEARTBEAT_CREATE_FAILED", "create heartbeat", error);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function runUpdateHeartbeat(
|
||||
id: string,
|
||||
options: HeartbeatOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<ScheduleRow>> {
|
||||
const agentId = requireCallerAgentId();
|
||||
const cron = options.cron?.trim();
|
||||
if (!cron) {
|
||||
throw new Error("--cron is required");
|
||||
}
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
try {
|
||||
await requireOwnedHeartbeat(client, id, agentId);
|
||||
const payload = await client.scheduleUpdate({
|
||||
id,
|
||||
cadence: {
|
||||
type: "cron",
|
||||
expression: cron,
|
||||
...(options.timezone?.trim() ? { timezone: options.timezone.trim() } : {}),
|
||||
},
|
||||
});
|
||||
if (payload.error || !payload.schedule) {
|
||||
throw new Error(payload.error ?? `Heartbeat update failed: ${id}`);
|
||||
}
|
||||
return { type: "single", data: toScheduleRow(payload.schedule), schema: scheduleSchema };
|
||||
} catch (error) {
|
||||
throw toScheduleCommandError("HEARTBEAT_UPDATE_FAILED", "update heartbeat", error);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function runDeleteHeartbeat(
|
||||
id: string,
|
||||
options: HeartbeatOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<HeartbeatDeleteRow>> {
|
||||
const agentId = requireCallerAgentId();
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
try {
|
||||
await requireOwnedHeartbeat(client, id, agentId);
|
||||
const payload = await client.scheduleDelete({ id });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return {
|
||||
type: "single",
|
||||
data: { id: payload.scheduleId, status: "deleted" },
|
||||
schema: heartbeatDeleteSchema,
|
||||
};
|
||||
} catch (error) {
|
||||
throw toScheduleCommandError("HEARTBEAT_DELETE_FAILED", "delete heartbeat", error);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export function createHeartbeatCommand(): Command {
|
||||
const heartbeat = new Command("heartbeat").description("Manage this agent's heartbeats");
|
||||
addJsonAndDaemonHostOptions(
|
||||
heartbeat
|
||||
.command("create")
|
||||
.description("Create a recurring prompt for this agent")
|
||||
.argument("<prompt>", "Prompt to send")
|
||||
.requiredOption("--cron <expr>", "Five-field cron cadence")
|
||||
.option("--timezone <iana>", "IANA time zone")
|
||||
.option("--name <name>", "Heartbeat name")
|
||||
.option("--max-runs <n>", "Maximum number of runs")
|
||||
.option("--expires-in <duration>", "Time to live"),
|
||||
).action(withOutput(runCreateHeartbeat));
|
||||
addJsonAndDaemonHostOptions(
|
||||
heartbeat
|
||||
.command("update")
|
||||
.description("Change a heartbeat cron cadence")
|
||||
.argument("<id>", "Heartbeat ID")
|
||||
.requiredOption("--cron <expr>", "Five-field cron cadence")
|
||||
.option("--timezone <iana>", "IANA time zone"),
|
||||
).action(withOutput(runUpdateHeartbeat));
|
||||
addJsonAndDaemonHostOptions(
|
||||
heartbeat.command("delete").description("Delete a heartbeat").argument("<id>", "Heartbeat ID"),
|
||||
).action(withOutput(runDeleteHeartbeat));
|
||||
return heartbeat;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Command } from "commander";
|
||||
import { Command, Option } from "commander";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
import { runCreateCommand } from "./create.js";
|
||||
@@ -19,11 +19,11 @@ export function createScheduleCommand(): Command {
|
||||
.command("create")
|
||||
.description("Create a schedule")
|
||||
.argument("<prompt>", "Prompt to run on the schedule")
|
||||
.option("--every <duration>", "Fixed interval cadence (for example: 5m, 1h)")
|
||||
.option("--every <duration>", "Cron-compatible cadence preset (for example: 5m, 1h)")
|
||||
.option("--cron <expr>", "Cron cadence expression")
|
||||
.option("--timezone <iana>", "IANA time zone for cron cadence (default: UTC)")
|
||||
.option("--name <name>", "Optional schedule name")
|
||||
.option("--target <self|new-agent|agent-id>", "Run target")
|
||||
.addOption(new Option("--target <target>", "Legacy schedule target").hideHelp())
|
||||
.option(
|
||||
"--provider <provider>",
|
||||
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
|
||||
@@ -33,8 +33,7 @@ export function createScheduleCommand(): Command {
|
||||
"Provider-specific mode (e.g. claude bypassPermissions, opencode build)",
|
||||
)
|
||||
.option("--cwd <path>", "Working directory (default: current; required with --host)")
|
||||
.option("--run-now", "Fire one immediate run on creation (only with --cron)")
|
||||
.option("--no-run-now", "Wait the full interval before the first run (only with --every)")
|
||||
.option("--run-now", "Fire one immediate run on creation")
|
||||
.option("--max-runs <n>", "Maximum number of runs")
|
||||
.option("--expires-in <duration>", "Time to live for the schedule"),
|
||||
).action(withOutput(runCreateCommand));
|
||||
@@ -81,7 +80,7 @@ export function createScheduleCommand(): Command {
|
||||
.command("update")
|
||||
.description("Update an existing schedule in place")
|
||||
.argument("<id>", "Schedule ID")
|
||||
.option("--every <duration>", "Switch to fixed interval cadence (for example: 5m, 1h)")
|
||||
.option("--every <duration>", "Cron-compatible cadence preset (for example: 5m, 1h)")
|
||||
.option("--cron <expr>", "Switch to cron cadence expression")
|
||||
.option("--timezone <iana>", "IANA time zone for cron cadence (requires --cron)")
|
||||
.option("--name <name>", "Rename the schedule (empty string clears the name)")
|
||||
|
||||
@@ -22,6 +22,9 @@ export async function runInspectCommand(
|
||||
if (payload.error || !payload.schedule) {
|
||||
throw new Error(payload.error ?? `Schedule not found: ${id}`);
|
||||
}
|
||||
if (payload.schedule.target.type !== "new-agent") {
|
||||
throw new Error(`Schedule not found: ${id}`);
|
||||
}
|
||||
const rows = createScheduleInspectRows(payload.schedule);
|
||||
return {
|
||||
type: "list",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ListResult } from "../../output/index.js";
|
||||
import { scheduleLogSchema, toScheduleLogRow, type ScheduleLogRow } from "./schema.js";
|
||||
import {
|
||||
connectScheduleClient,
|
||||
requireNewAgentSchedule,
|
||||
toScheduleCommandError,
|
||||
type ScheduleCommandOptions,
|
||||
} from "./shared.js";
|
||||
@@ -14,6 +15,7 @@ export async function runLogsCommand(
|
||||
): Promise<ListResult<ScheduleLogRow>> {
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
try {
|
||||
await requireNewAgentSchedule(client, id);
|
||||
const payload = await client.scheduleLogs({ id });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
|
||||
@@ -21,7 +21,9 @@ export async function runLsCommand(
|
||||
}
|
||||
return {
|
||||
type: "list",
|
||||
data: payload.schedules.map(toScheduleRow),
|
||||
data: payload.schedules
|
||||
.filter((schedule) => schedule.target.type === "new-agent")
|
||||
.map(toScheduleRow),
|
||||
schema: scheduleSchema,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SingleResult } from "../../output/index.js";
|
||||
import { scheduleSchema } from "./schema.js";
|
||||
import {
|
||||
connectScheduleClient,
|
||||
requireNewAgentSchedule,
|
||||
toScheduleCommandError,
|
||||
toScheduleRow,
|
||||
type ScheduleCommandOptions,
|
||||
@@ -16,6 +17,7 @@ export async function runPauseCommand(
|
||||
): Promise<SingleResult<ScheduleRow>> {
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
try {
|
||||
await requireNewAgentSchedule(client, id);
|
||||
const payload = await client.schedulePause({ id });
|
||||
if (payload.error || !payload.schedule) {
|
||||
throw new Error(payload.error ?? `Failed to pause schedule: ${id}`);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SingleResult } from "../../output/index.js";
|
||||
import { scheduleSchema } from "./schema.js";
|
||||
import {
|
||||
connectScheduleClient,
|
||||
requireNewAgentSchedule,
|
||||
toScheduleCommandError,
|
||||
toScheduleRow,
|
||||
type ScheduleCommandOptions,
|
||||
@@ -16,6 +17,7 @@ export async function runResumeCommand(
|
||||
): Promise<SingleResult<ScheduleRow>> {
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
try {
|
||||
await requireNewAgentSchedule(client, id);
|
||||
const payload = await client.scheduleResume({ id });
|
||||
if (payload.error || !payload.schedule) {
|
||||
throw new Error(payload.error ?? `Failed to resume schedule: ${id}`);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SingleResult } from "../../output/index.js";
|
||||
import { scheduleSchema } from "./schema.js";
|
||||
import {
|
||||
connectScheduleClient,
|
||||
requireNewAgentSchedule,
|
||||
toScheduleCommandError,
|
||||
toScheduleRow,
|
||||
type ScheduleCommandOptions,
|
||||
@@ -16,6 +17,7 @@ export async function runRunOnceCommand(
|
||||
): Promise<SingleResult<ScheduleRow>> {
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
try {
|
||||
await requireNewAgentSchedule(client, id);
|
||||
const payload = await client.scheduleRunOnce({ id });
|
||||
if (payload.error || !payload.schedule) {
|
||||
throw new Error(payload.error ?? `Failed to run schedule once: ${id}`);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { parseScheduleCreateInput, parseScheduleUpdateInput } from "./shared.js";
|
||||
import {
|
||||
compileEveryPresetToCron,
|
||||
parseScheduleCreateInput,
|
||||
parseScheduleUpdateInput,
|
||||
} from "./shared.js";
|
||||
|
||||
const baseOptions = {
|
||||
prompt: "do the thing",
|
||||
@@ -68,13 +72,9 @@ describe("parseScheduleCreateInput cwd/host validation", () => {
|
||||
});
|
||||
|
||||
describe("parseScheduleCreateInput first-run timing", () => {
|
||||
test("--every with no run-now flag fires immediately on creation", () => {
|
||||
test("--every compiles to cron and waits for the next slot", () => {
|
||||
const input = parseScheduleCreateInput(baseOptions);
|
||||
expect(input.runOnCreate).toBe(true);
|
||||
});
|
||||
|
||||
test("--every with --no-run-now waits the interval", () => {
|
||||
const input = parseScheduleCreateInput({ ...baseOptions, runNow: false });
|
||||
expect(input.cadence).toEqual({ type: "cron", expression: "*/5 * * * *" });
|
||||
expect(input.runOnCreate).toBe(false);
|
||||
});
|
||||
|
||||
@@ -101,22 +101,9 @@ describe("parseScheduleCreateInput first-run timing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("--every with --run-now is rejected as redundant", () => {
|
||||
expect(() => parseScheduleCreateInput({ ...baseOptions, runNow: true })).toThrow(
|
||||
expect.objectContaining({
|
||||
code: "REDUNDANT_RUN_NOW",
|
||||
message: expect.stringContaining("--run-now is redundant with --every"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("--cron with --no-run-now is rejected as redundant", () => {
|
||||
expect(() => parseScheduleCreateInput({ ...baseCron, runNow: false })).toThrow(
|
||||
expect.objectContaining({
|
||||
code: "REDUNDANT_NO_RUN_NOW",
|
||||
message: expect.stringContaining("--no-run-now is redundant with --cron"),
|
||||
}),
|
||||
);
|
||||
test("--run-now is orthogonal to a compiled preset", () => {
|
||||
const input = parseScheduleCreateInput({ ...baseOptions, runNow: true });
|
||||
expect(input.runOnCreate).toBe(true);
|
||||
});
|
||||
|
||||
test("--timezone without --cron is rejected", () => {
|
||||
@@ -159,10 +146,10 @@ describe("parseScheduleUpdateInput", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("parses --every cadence", () => {
|
||||
test("compiles --every cadence to cron", () => {
|
||||
expect(parseScheduleUpdateInput({ id: "abc", every: "5m" })).toEqual({
|
||||
id: "abc",
|
||||
cadence: { type: "every", everyMs: 5 * 60_000 },
|
||||
cadence: { type: "cron", expression: "*/5 * * * *" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,3 +258,25 @@ describe("parseScheduleUpdateInput", () => {
|
||||
).toThrow(expect.objectContaining({ code: "CONFLICTING_EXPIRES" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("compileEveryPresetToCron", () => {
|
||||
test.each([
|
||||
["1m", "*/1 * * * *"],
|
||||
["15m", "*/15 * * * *"],
|
||||
["1h", "0 * * * *"],
|
||||
["6h", "0 */6 * * *"],
|
||||
["1d", "0 0 * * *"],
|
||||
])("compiles %s", (value, expected) => {
|
||||
expect(compileEveryPresetToCron(value)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each(["30s", "7m", "5h", "2d"])("rejects rolling interval %s", (value) => {
|
||||
expect(() => compileEveryPresetToCron(value)).toThrow(
|
||||
expect.objectContaining({ code: "UNREPRESENTABLE_CADENCE" }),
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["15minutes", "junk15m", "1h-nope"])("rejects malformed preset %s", (value) => {
|
||||
expect(() => compileEveryPresetToCron(value)).toThrow("Invalid duration format");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from "./types.js";
|
||||
import { parseDuration } from "../../utils/duration.js";
|
||||
import { resolveProviderAndModel } from "../../utils/provider-model.js";
|
||||
import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence";
|
||||
|
||||
export interface ScheduleCommandOptions extends CommandOptions {
|
||||
host?: string;
|
||||
@@ -47,6 +48,16 @@ export function toScheduleCommandError(code: string, action: string, error: unkn
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireNewAgentSchedule(
|
||||
client: ScheduleDaemonClient,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
const payload = await client.scheduleInspect({ id });
|
||||
if (payload.error || !payload.schedule || payload.schedule.target.type !== "new-agent") {
|
||||
throw new Error(payload.error ?? `Schedule not found: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCadence(cadence: ScheduleCadence): string {
|
||||
if (cadence.type === "cron") {
|
||||
const timezoneSuffix = cadence.timezone ? ` (${cadence.timezone})` : "";
|
||||
@@ -92,12 +103,7 @@ function resolveScheduleTarget(args: {
|
||||
createNewAgentTarget: () => ScheduleTarget;
|
||||
}): ScheduleTarget {
|
||||
const { targetValue, hasExplicitNewAgentOption, createNewAgentTarget } = args;
|
||||
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
|
||||
|
||||
if (!targetValue) {
|
||||
if (currentAgentId && !hasExplicitNewAgentOption) {
|
||||
return { type: "self", agentId: currentAgentId };
|
||||
}
|
||||
return createNewAgentTarget();
|
||||
}
|
||||
|
||||
@@ -114,6 +120,9 @@ function resolveScheduleTarget(args: {
|
||||
}
|
||||
|
||||
if (targetValue === "self") {
|
||||
// COMPAT(scheduleSelfTarget): heartbeat creation moved to `paseo heartbeat create`.
|
||||
// Added in v0.2.0; remove after 2027-01-17.
|
||||
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
|
||||
if (!currentAgentId) {
|
||||
throw {
|
||||
code: "INVALID_TARGET",
|
||||
@@ -211,23 +220,9 @@ export function parseScheduleCreateInput(options: {
|
||||
|
||||
function resolveRunOnCreate(
|
||||
runNow: boolean | undefined,
|
||||
cadenceType: ScheduleCadence["type"],
|
||||
_cadenceType: ScheduleCadence["type"],
|
||||
): boolean {
|
||||
if (runNow === true && cadenceType === "every") {
|
||||
throw {
|
||||
code: "REDUNDANT_RUN_NOW",
|
||||
message: "--run-now is redundant with --every (interval schedules already fire on creation)",
|
||||
details: "Drop --run-now, or use --no-run-now to wait the full interval before the first run",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
if (runNow === false && cadenceType === "cron") {
|
||||
throw {
|
||||
code: "REDUNDANT_NO_RUN_NOW",
|
||||
message: "--no-run-now is redundant with --cron (cron schedules never fire on creation)",
|
||||
details: "Drop --no-run-now, or use --run-now to fire one immediate run on creation",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return runNow ?? cadenceType === "every";
|
||||
return runNow ?? false;
|
||||
}
|
||||
|
||||
export interface ScheduleUpdateOptionsInput {
|
||||
@@ -307,7 +302,7 @@ function parseCadenceFromFlags(
|
||||
} satisfies CommandError;
|
||||
}
|
||||
if (every !== undefined) {
|
||||
return { type: "every", everyMs: parseDuration(every) };
|
||||
return { type: "cron", expression: compileEveryPresetToCron(every) };
|
||||
}
|
||||
if (cron !== undefined) {
|
||||
return {
|
||||
@@ -319,6 +314,20 @@ function parseCadenceFromFlags(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function compileEveryPresetToCron(value: string): string {
|
||||
const durationMs = parseDuration(value);
|
||||
const cron = everyMsToFiveFieldCron(durationMs);
|
||||
if (cron) {
|
||||
return cron;
|
||||
}
|
||||
|
||||
throw {
|
||||
code: "UNREPRESENTABLE_CADENCE",
|
||||
message: `${value} cannot be represented faithfully by five-field cron`,
|
||||
details: "Use --cron for calendar schedules",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
function parseTimeZoneFlag(timeZone: string | undefined): string | undefined {
|
||||
if (timeZone === undefined) {
|
||||
return undefined;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import {
|
||||
connectScheduleClient,
|
||||
parseScheduleUpdateInput,
|
||||
requireNewAgentSchedule,
|
||||
toScheduleCommandError,
|
||||
type ScheduleCommandOptions,
|
||||
} from "./shared.js";
|
||||
@@ -51,6 +52,7 @@ export async function runUpdateCommand(
|
||||
});
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
try {
|
||||
await requireNewAgentSchedule(client, id);
|
||||
const payload = await client.scheduleUpdate(input);
|
||||
if (payload.error || !payload.schedule) {
|
||||
throw new Error(payload.error ?? `Failed to update schedule: ${id}`);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js";
|
||||
|
||||
interface WorkspaceArchiveResult {
|
||||
workspaceId: string;
|
||||
status: "archived";
|
||||
archivedAt: string;
|
||||
}
|
||||
|
||||
const workspaceArchiveSchema: OutputSchema<WorkspaceArchiveResult> = {
|
||||
idField: "workspaceId",
|
||||
columns: [
|
||||
{ header: "WORKSPACE ID", field: "workspaceId", width: 20 },
|
||||
{ header: "STATUS", field: "status", width: 10 },
|
||||
{ header: "ARCHIVED AT", field: "archivedAt", width: 26 },
|
||||
],
|
||||
};
|
||||
|
||||
export async function runArchiveCommand(
|
||||
workspaceId: string,
|
||||
options: { host?: string },
|
||||
_command: Command,
|
||||
): Promise<SingleResult<WorkspaceArchiveResult>> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
} satisfies CommandError;
|
||||
});
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
if (!payload.archivedAt) {
|
||||
throw new Error("Workspace archive did not return an archive timestamp");
|
||||
}
|
||||
return {
|
||||
type: "single",
|
||||
data: { workspaceId, status: "archived", archivedAt: payload.archivedAt },
|
||||
schema: workspaceArchiveSchema,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw { code: "WORKSPACE_ARCHIVE_FAILED", message } satisfies CommandError;
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildWorkspaceSource } from "./create.js";
|
||||
|
||||
describe("workspace create source", () => {
|
||||
it("maps local isolation to a directory workspace", () => {
|
||||
expect(
|
||||
buildWorkspaceSource({ isolation: "local", path: "/tmp/project", project: "project-1" }),
|
||||
).toEqual({ kind: "directory", path: "/tmp/project", projectId: "project-1" });
|
||||
});
|
||||
|
||||
it("keeps branch names separate from managed worktree slugs", () => {
|
||||
expect(
|
||||
buildWorkspaceSource({
|
||||
isolation: "worktree",
|
||||
path: "/tmp/project",
|
||||
mode: "branch-off",
|
||||
newBranch: "feature/auth",
|
||||
worktreeSlug: "feature-auth",
|
||||
base: "main",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "worktree",
|
||||
cwd: "/tmp/project",
|
||||
action: "branch-off",
|
||||
branchName: "feature/auth",
|
||||
worktreeSlug: "feature-auth",
|
||||
baseBranch: "main",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a project as the worktree source without capturing the ambient directory", () => {
|
||||
expect(
|
||||
buildWorkspaceSource({
|
||||
isolation: "worktree",
|
||||
project: "project-1",
|
||||
mode: "branch-off",
|
||||
newBranch: "fix-x",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "worktree",
|
||||
projectId: "project-1",
|
||||
action: "branch-off",
|
||||
branchName: "fix-x",
|
||||
});
|
||||
});
|
||||
|
||||
it("checks out an existing branch into a worktree workspace", () => {
|
||||
expect(
|
||||
buildWorkspaceSource({
|
||||
isolation: "worktree",
|
||||
path: "/tmp/project",
|
||||
mode: "checkout-branch",
|
||||
branch: "existing-work",
|
||||
worktreeSlug: "existing-work-copy",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "worktree",
|
||||
cwd: "/tmp/project",
|
||||
action: "checkout",
|
||||
refName: "existing-work",
|
||||
worktreeSlug: "existing-work-copy",
|
||||
});
|
||||
});
|
||||
|
||||
it("checks out a pull request into a worktree workspace", () => {
|
||||
expect(
|
||||
buildWorkspaceSource({
|
||||
isolation: "worktree",
|
||||
path: "/tmp/project",
|
||||
mode: "checkout-pr",
|
||||
prNumber: "42",
|
||||
forge: "gitlab",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "worktree",
|
||||
cwd: "/tmp/project",
|
||||
action: "checkout",
|
||||
checkoutSource: {
|
||||
kind: "change_request",
|
||||
forge: "gitlab",
|
||||
number: 42,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("lets the source checkout select the forge when it is omitted", () => {
|
||||
expect(
|
||||
buildWorkspaceSource({
|
||||
isolation: "worktree",
|
||||
path: "/tmp/project",
|
||||
mode: "checkout-pr",
|
||||
prNumber: "42",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "worktree",
|
||||
cwd: "/tmp/project",
|
||||
action: "checkout",
|
||||
checkoutSource: {
|
||||
kind: "change_request",
|
||||
number: 42,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("requires the mode-specific checkout target", () => {
|
||||
expect(() => buildWorkspaceSource({ isolation: "worktree", mode: "checkout-branch" })).toThrow(
|
||||
"--branch is required",
|
||||
);
|
||||
expect(() => buildWorkspaceSource({ isolation: "worktree", mode: "checkout-pr" })).toThrow(
|
||||
"--pr-number is required",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects worktree options for local isolation", () => {
|
||||
expect(() => buildWorkspaceSource({ isolation: "local", mode: "branch-off" })).toThrow(
|
||||
"Worktree options require --isolation worktree",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown isolation", () => {
|
||||
expect(() => buildWorkspaceSource({ isolation: "container" })).toThrow(
|
||||
"Unsupported workspace isolation",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js";
|
||||
import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js";
|
||||
|
||||
export interface WorkspaceCreateOptions extends CommandOptions {
|
||||
isolation?: string;
|
||||
path?: string;
|
||||
project?: string;
|
||||
title?: string;
|
||||
mode?: string;
|
||||
worktreeSlug?: string;
|
||||
newBranch?: string;
|
||||
base?: string;
|
||||
branch?: string;
|
||||
prNumber?: string;
|
||||
forge?: string;
|
||||
}
|
||||
|
||||
interface WorktreeSourceBase {
|
||||
kind: "worktree";
|
||||
cwd?: string;
|
||||
projectId?: string;
|
||||
worktreeSlug?: string;
|
||||
}
|
||||
|
||||
function assertOptionsAbsent(values: unknown[], message: string): void {
|
||||
if (values.some((value) => value !== undefined)) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function buildLocalWorkspaceSource(options: WorkspaceCreateOptions, path: string) {
|
||||
assertOptionsAbsent(
|
||||
[
|
||||
options.mode,
|
||||
options.worktreeSlug,
|
||||
options.newBranch,
|
||||
options.base,
|
||||
options.branch,
|
||||
options.prNumber,
|
||||
options.forge,
|
||||
],
|
||||
"Worktree options require --isolation worktree",
|
||||
);
|
||||
return {
|
||||
kind: "directory" as const,
|
||||
path,
|
||||
...(options.project ? { projectId: options.project } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBranchOffSource(options: WorkspaceCreateOptions, source: WorktreeSourceBase) {
|
||||
assertOptionsAbsent(
|
||||
[options.branch, options.prNumber, options.forge],
|
||||
"--branch, --pr-number, and --forge require a checkout mode",
|
||||
);
|
||||
return {
|
||||
...source,
|
||||
action: "branch-off" as const,
|
||||
...(options.newBranch ? { branchName: options.newBranch } : {}),
|
||||
...(options.base ? { baseBranch: options.base } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBranchCheckoutSource(options: WorkspaceCreateOptions, source: WorktreeSourceBase) {
|
||||
if (!options.branch) {
|
||||
throw new Error("--branch is required for --mode checkout-branch");
|
||||
}
|
||||
assertOptionsAbsent(
|
||||
[options.newBranch, options.base, options.prNumber, options.forge],
|
||||
"--new-branch, --base, --pr-number, and --forge are not valid for --mode checkout-branch",
|
||||
);
|
||||
return { ...source, action: "checkout" as const, refName: options.branch };
|
||||
}
|
||||
|
||||
function buildPullRequestCheckoutSource(
|
||||
options: WorkspaceCreateOptions,
|
||||
source: WorktreeSourceBase,
|
||||
) {
|
||||
if (options.prNumber === undefined || options.prNumber === "") {
|
||||
throw new Error("--pr-number is required for --mode checkout-pr");
|
||||
}
|
||||
const prNumber = Number(options.prNumber);
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
||||
throw new Error("--pr-number must be a positive integer");
|
||||
}
|
||||
assertOptionsAbsent(
|
||||
[options.newBranch, options.base, options.branch],
|
||||
"--new-branch, --base, and --branch are not valid for --mode checkout-pr",
|
||||
);
|
||||
return {
|
||||
...source,
|
||||
action: "checkout" as const,
|
||||
checkoutSource: {
|
||||
kind: "change_request" as const,
|
||||
...(options.forge ? { forge: options.forge } : {}),
|
||||
number: prNumber,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorktreeWorkspaceSource(options: WorkspaceCreateOptions, path: string | undefined) {
|
||||
const source: WorktreeSourceBase = {
|
||||
kind: "worktree",
|
||||
...(path ? { cwd: path } : {}),
|
||||
...(options.project ? { projectId: options.project } : {}),
|
||||
...(options.worktreeSlug ? { worktreeSlug: options.worktreeSlug } : {}),
|
||||
};
|
||||
switch (options.mode ?? "branch-off") {
|
||||
case "branch-off":
|
||||
return buildBranchOffSource(options, source);
|
||||
case "checkout-branch":
|
||||
return buildBranchCheckoutSource(options, source);
|
||||
case "checkout-pr":
|
||||
return buildPullRequestCheckoutSource(options, source);
|
||||
default:
|
||||
throw new Error(`Unsupported worktree mode: ${String(options.mode)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWorkspaceSource(options: WorkspaceCreateOptions) {
|
||||
if (options.isolation === "local") {
|
||||
return buildLocalWorkspaceSource(options, options.path ?? process.cwd());
|
||||
}
|
||||
if (options.isolation === "worktree") {
|
||||
const sourcePath = options.path ?? (options.project ? undefined : process.cwd());
|
||||
return buildWorktreeWorkspaceSource(options, sourcePath);
|
||||
}
|
||||
throw new Error(`Unsupported workspace isolation: ${String(options.isolation)}`);
|
||||
}
|
||||
|
||||
export async function runCreateCommand(
|
||||
options: WorkspaceCreateOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<WorkspaceRow>> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
} satisfies CommandError;
|
||||
});
|
||||
|
||||
try {
|
||||
const payload = await client.createWorkspace({
|
||||
source: buildWorkspaceSource(options),
|
||||
...(options.title ? { title: options.title } : {}),
|
||||
});
|
||||
if (!payload.workspace) {
|
||||
throw new Error(payload.error ?? "Workspace creation failed");
|
||||
}
|
||||
return { type: "single", data: toWorkspaceRow(payload.workspace), schema: workspaceSchema };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw { code: "WORKSPACE_CREATE_FAILED", message } satisfies CommandError;
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Command } from "commander";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
import { runArchiveCommand } from "./archive.js";
|
||||
import { runCreateCommand } from "./create.js";
|
||||
import { runLsCommand } from "./ls.js";
|
||||
|
||||
export function createWorkspaceCommand(): Command {
|
||||
const workspace = new Command("workspace").description("Manage workspaces");
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
workspace
|
||||
.command("create")
|
||||
.description("Create a workspace")
|
||||
.requiredOption("--isolation <local|worktree>", "Workspace isolation")
|
||||
.option("--path <path>", "Local directory or source checkout (default: current)")
|
||||
.option("--project <id>", "Existing project id")
|
||||
.option("--title <title>", "Workspace title")
|
||||
.option(
|
||||
"--mode <mode>",
|
||||
"Worktree mode: branch-off, checkout-branch, or checkout-pr (default: branch-off)",
|
||||
)
|
||||
.option("--worktree-slug <slug>", "Managed worktree path slug")
|
||||
.option("--new-branch <name>", "New branch name (--mode branch-off)")
|
||||
.option("--base <ref>", "Base ref (--mode branch-off)")
|
||||
.option("--branch <name>", "Existing branch (--mode checkout-branch)")
|
||||
.option("--pr-number <n>", "Pull request or change request number (--mode checkout-pr)")
|
||||
.option("--forge <forge>", "Forge for --mode checkout-pr (default: source checkout)"),
|
||||
).action(withOutput(runCreateCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(workspace.command("ls").description("List active workspaces")).action(
|
||||
withOutput(runLsCommand),
|
||||
);
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
workspace
|
||||
.command("archive")
|
||||
.description("Archive a workspace and everything it owns")
|
||||
.argument("<workspace-id>", "Workspace id"),
|
||||
).action(withOutput(runArchiveCommand));
|
||||
|
||||
return workspace;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandError, ListResult } from "../../output/index.js";
|
||||
import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js";
|
||||
|
||||
export async function runLsCommand(
|
||||
options: { host?: string },
|
||||
_command: Command,
|
||||
): Promise<ListResult<WorkspaceRow>> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
} satisfies CommandError;
|
||||
});
|
||||
try {
|
||||
const workspaces: WorkspaceRow[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const payload = await client.fetchWorkspaces({
|
||||
page: { limit: 200, ...(cursor ? { cursor } : {}) },
|
||||
});
|
||||
workspaces.push(...payload.entries.map(toWorkspaceRow));
|
||||
cursor = payload.pageInfo.nextCursor ?? undefined;
|
||||
} while (cursor);
|
||||
return { type: "list", data: workspaces, schema: workspaceSchema };
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
import type { OutputSchema } from "../../output/index.js";
|
||||
|
||||
export interface WorkspaceRow {
|
||||
workspaceId: string;
|
||||
project: string;
|
||||
name: string;
|
||||
isolation: "local" | "worktree";
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export const workspaceSchema: OutputSchema<WorkspaceRow> = {
|
||||
idField: "workspaceId",
|
||||
columns: [
|
||||
{ header: "WORKSPACE ID", field: "workspaceId", width: 20 },
|
||||
{ header: "PROJECT", field: "project", width: 20 },
|
||||
{ header: "NAME", field: "name", width: 22 },
|
||||
{ header: "ISOLATION", field: "isolation", width: 10 },
|
||||
{ header: "CWD", field: "cwd", width: 42 },
|
||||
],
|
||||
};
|
||||
|
||||
export function toWorkspaceRow(workspace: WorkspaceDescriptorPayload): WorkspaceRow {
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
project: workspace.projectDisplayName,
|
||||
name: workspace.name,
|
||||
isolation: workspace.workspaceKind === "worktree" ? "worktree" : "local",
|
||||
cwd: workspace.workspaceDirectory,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Parse duration string to milliseconds.
|
||||
* Supports formats like: 5m, 30s, 1h, 2h30m, 90, etc.
|
||||
* Supports formats like: 5m, 30s, 1h, 2h30m, 1d, 90, etc.
|
||||
* If no unit is specified, assumes seconds.
|
||||
*/
|
||||
export function parseDuration(input: string): number {
|
||||
@@ -11,14 +11,16 @@ export function parseDuration(input: string): number {
|
||||
return parseInt(trimmed, 10) * 1000;
|
||||
}
|
||||
|
||||
if (!/^(?:\d+[smhd])+$/.test(trimmed)) {
|
||||
throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m, 1d`);
|
||||
}
|
||||
|
||||
// Parse duration with units
|
||||
let totalMs = 0;
|
||||
const regex = /(\d+)([smh])/g;
|
||||
const regex = /(\d+)([smhd])/g;
|
||||
let match;
|
||||
let hasMatch = false;
|
||||
|
||||
while ((match = regex.exec(trimmed)) !== null) {
|
||||
hasMatch = true;
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
|
||||
@@ -32,12 +34,11 @@ export function parseDuration(input: string): number {
|
||||
case "h":
|
||||
totalMs += value * 60 * 60 * 1000;
|
||||
break;
|
||||
case "d":
|
||||
totalMs += value * 24 * 60 * 60 * 1000;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasMatch) {
|
||||
throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m`);
|
||||
}
|
||||
|
||||
return totalMs;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,8 @@ try {
|
||||
const result = await $`npx paseo run --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "run --help should exit 0");
|
||||
assert(result.stdout.includes("-d"), "help should mention -d flag");
|
||||
assert(result.stdout.includes("--detach"), "help should mention --detach flag");
|
||||
assert(result.stdout.includes("--background"), "help should mention --background flag");
|
||||
assert(!result.stdout.includes("--detach"), "help should hide legacy --detach syntax");
|
||||
assert(result.stdout.includes("--title"), "help should mention --title option");
|
||||
assert(result.stdout.includes("--provider"), "help should mention --provider option");
|
||||
assert(result.stdout.includes("--mode"), "help should mention --mode option");
|
||||
@@ -174,18 +175,18 @@ try {
|
||||
console.log("✓ run --output-schema flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 10: run --output-schema cannot be used with --detach
|
||||
// Test 10: run --output-schema cannot be used with --background
|
||||
{
|
||||
console.log("Test 10: run --output-schema cannot be used with --detach");
|
||||
console.log("Test 10: run --output-schema cannot be used with --background");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d --output-schema ${schemaPath} "test prompt"`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail with --detach and --output-schema");
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --background --output-schema ${schemaPath} "test prompt"`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail with --background and --output-schema");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(
|
||||
output.includes("--output-schema cannot be used with --detach"),
|
||||
"error should explain detach incompatibility",
|
||||
output.includes("--output-schema cannot be used with --background"),
|
||||
"error should explain background incompatibility",
|
||||
);
|
||||
console.log("✓ run --output-schema cannot be used with --detach\n");
|
||||
console.log("✓ run --output-schema cannot be used with --background\n");
|
||||
}
|
||||
|
||||
// Test 11: -q (quiet) flag is accepted with run
|
||||
|
||||
@@ -153,13 +153,13 @@ try {
|
||||
console.log("✓ --json flag is accepted with worktree ls\n");
|
||||
}
|
||||
|
||||
// Test 11: paseo --help shows worktree subcommand
|
||||
// Test 11: paseo --help keeps the compatibility command hidden
|
||||
{
|
||||
console.log("Test 11: paseo --help shows worktree subcommand");
|
||||
console.log("Test 11: paseo --help hides worktree compatibility command");
|
||||
const result = await $`npx paseo --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
|
||||
assert(result.stdout.includes("worktree"), "help should mention worktree subcommand");
|
||||
console.log("✓ paseo --help shows worktree subcommand\n");
|
||||
assert(!result.stdout.includes("worktree"), "help should not advertise worktree subcommand");
|
||||
console.log("✓ paseo --help hides worktree compatibility command\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
|
||||
@@ -53,7 +53,7 @@ try {
|
||||
assert.strictEqual(created.exitCode, 0, created.stderr);
|
||||
const createdJson = JSON.parse(created.stdout);
|
||||
assert.strictEqual(createdJson.name, "review-prs");
|
||||
assert.strictEqual(createdJson.cadence, "every:5m");
|
||||
assert.strictEqual(createdJson.cadence, "cron:*/5 * * * *");
|
||||
assert(
|
||||
typeof createdJson.target === "string" &&
|
||||
(createdJson.target.startsWith("agent:") || createdJson.target === "new-agent:claude"),
|
||||
@@ -144,6 +144,31 @@ try {
|
||||
console.log("schedule rejects provider with self target\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 1d: compatibility agent-target schedules remain deletable");
|
||||
const created = await ctx.paseo(
|
||||
[
|
||||
"schedule",
|
||||
"create",
|
||||
"Legacy heartbeat",
|
||||
"--cron",
|
||||
"0 0 1 1 *",
|
||||
"--target",
|
||||
"00000000-0000-4000-8000-000000000001",
|
||||
"--json",
|
||||
],
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
assert.strictEqual(created.exitCode, 0, created.stderr);
|
||||
const createdJson = JSON.parse(created.stdout);
|
||||
assert.strictEqual(createdJson.target, "agent:0000000");
|
||||
|
||||
const deleted = await ctx.paseo(["schedule", "delete", createdJson.id, "--json"]);
|
||||
assert.strictEqual(deleted.exitCode, 0, deleted.stderr);
|
||||
assert.strictEqual(JSON.parse(deleted.stdout).id, createdJson.id);
|
||||
console.log("compatibility agent-target schedules remain deletable\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 2: loop run/ls/inspect/logs/stop work");
|
||||
const run = await ctx.paseo(
|
||||
|
||||
Reference in New Issue
Block a user