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:
Mohamed Boudra
2026-07-18 14:04:48 +02:00
committed by GitHub
parent 98f6611362
commit ffe76a7e57
66 changed files with 2791 additions and 924 deletions
+24
View File
@@ -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");
});
});
+8 -2
View File
@@ -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;
}
+58
View File
@@ -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);
}
}
+8
View File
@@ -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")
+59 -16
View File
@@ -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/);
});
});
+154 -89
View File
@@ -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;
}
+5 -6
View File
@@ -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);
+3 -1
View File
@@ -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");
});
});
+31 -22
View File
@@ -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;
}
+32
View File
@@ -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,
};
}
+9 -8
View File
@@ -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;
}
+9 -8
View File
@@ -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
+4 -4
View File
@@ -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
+26 -1
View File
@@ -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(