@hyperdrive.bot/paseo-cli 0.3.42 → 0.3.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/agent/archive.js +9 -10
- package/dist/commands/agent/reload.js +8 -6
- package/dist/commands/daemon/index.js +1 -0
- package/dist/commands/daemon/restart.js +48 -1
- package/dist/commands/daemon/service-manager.d.ts +49 -0
- package/dist/commands/daemon/service-manager.js +97 -0
- package/dist/commands/schedule/create.d.ts +1 -0
- package/dist/commands/schedule/create.js +1 -0
- package/dist/commands/schedule/index.js +2 -1
- package/dist/commands/schedule/shared.d.ts +1 -0
- package/dist/commands/schedule/shared.js +88 -9
- package/dist/commands/workflow/definition-file.d.ts +49 -0
- package/dist/commands/workflow/definition-file.js +154 -0
- package/dist/commands/workflow/start.d.ts +2 -1
- package/dist/commands/workflow/start.js +93 -11
- package/dist/utils/client.d.ts +33 -0
- package/dist/utils/client.js +35 -0
- package/dist/utils/duration.d.ts +1 -1
- package/dist/utils/duration.js +6 -3
- package/package.json +4 -4
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { connectToDaemon, getDaemonHost,
|
|
1
|
+
import { connectToDaemon, getDaemonHost, resolveAgentViaDaemon } from "../../utils/client.js";
|
|
2
2
|
/** Schema for archive command output */
|
|
3
3
|
export const archiveSchema = {
|
|
4
4
|
idField: "agentId",
|
|
@@ -39,21 +39,20 @@ export async function runArchiveCommand(agentIdArg, options, _command) {
|
|
|
39
39
|
throw error;
|
|
40
40
|
}
|
|
41
41
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
// Resolve through the daemon, not through a page of `fetchAgents`: that rpc
|
|
43
|
+
// caps a page at 200 entries, so matching client-side against one page could
|
|
44
|
+
// not see agents outside the newest 200 and failed for most valid IDs here.
|
|
45
|
+
const resolution = await resolveAgentViaDaemon(client, agentIdArg);
|
|
46
|
+
if (!resolution.ok) {
|
|
46
47
|
const error = {
|
|
47
48
|
code: "AGENT_NOT_FOUND",
|
|
48
49
|
message: `Agent not found: ${agentIdArg}`,
|
|
49
|
-
details:
|
|
50
|
+
details: `${resolution.error}\nUse "paseo ls" to list available agents`,
|
|
50
51
|
};
|
|
51
52
|
throw error;
|
|
52
53
|
}
|
|
53
|
-
const agent =
|
|
54
|
-
|
|
55
|
-
throw new Error(`Resolved agent missing from fetched agents: ${agentId}`);
|
|
56
|
-
}
|
|
54
|
+
const agent = resolution.agent;
|
|
55
|
+
const agentId = agent.id;
|
|
57
56
|
// Check if agent is already archived
|
|
58
57
|
if (agent.archivedAt) {
|
|
59
58
|
const error = {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { connectToDaemon, getDaemonHost,
|
|
1
|
+
import { connectToDaemon, getDaemonHost, resolveAgentViaDaemon } from "../../utils/client.js";
|
|
2
2
|
export const reloadSchema = {
|
|
3
3
|
idField: "agentId",
|
|
4
4
|
columns: [
|
|
@@ -36,17 +36,19 @@ export async function runReloadCommand(agentIdArg, options, _command) {
|
|
|
36
36
|
throw error;
|
|
37
37
|
}
|
|
38
38
|
try {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
// Resolve through the daemon, not through a page of `fetchAgents`: that rpc
|
|
40
|
+
// caps a page at 200 entries, so matching client-side against one page could
|
|
41
|
+
// not see agents outside the newest 200 and failed for most valid IDs here.
|
|
42
|
+
const resolution = await resolveAgentViaDaemon(client, agentIdArg);
|
|
43
|
+
if (!resolution.ok) {
|
|
43
44
|
const error = {
|
|
44
45
|
code: "AGENT_NOT_FOUND",
|
|
45
46
|
message: `Agent not found: ${agentIdArg}`,
|
|
46
|
-
details:
|
|
47
|
+
details: `${resolution.error}\nUse "paseo ls" to list available agents`,
|
|
47
48
|
};
|
|
48
49
|
throw error;
|
|
49
50
|
}
|
|
51
|
+
const agentId = resolution.agent.id;
|
|
50
52
|
const result = await client.refreshAgent(agentId);
|
|
51
53
|
await client.close();
|
|
52
54
|
return {
|
|
@@ -32,6 +32,7 @@ export function createDaemonCommand() {
|
|
|
32
32
|
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
|
|
33
33
|
.option("--timeout <seconds>", "Wait timeout before force step (default: 15)")
|
|
34
34
|
.option("--force", "Send SIGKILL if graceful stop times out")
|
|
35
|
+
.option("--force-detached", "Restart detached even when the daemon is managed by an init system (systemd)")
|
|
35
36
|
.option("--listen <listen>", "Listen target for restarted daemon (host:port, port, or unix socket)")
|
|
36
37
|
.option("--port <port>", "Port for restarted daemon listen target")
|
|
37
38
|
.option("--no-relay", "Disable relay on restarted daemon")
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { startLocalDaemonDetached, stopLocalDaemon, DEFAULT_STOP_TIMEOUT_MS, } from "./local-daemon.js";
|
|
1
|
+
import { startLocalDaemonDetached, stopLocalDaemon, resolveLocalDaemonState, DEFAULT_STOP_TIMEOUT_MS, } from "./local-daemon.js";
|
|
2
|
+
import { detectServiceManagement } from "./service-manager.js";
|
|
2
3
|
const restartResultSchema = {
|
|
3
4
|
idField: "action",
|
|
4
5
|
columns: [
|
|
@@ -47,10 +48,53 @@ function toStartOptions(options) {
|
|
|
47
48
|
}
|
|
48
49
|
return startOptions;
|
|
49
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Refuse to restart a daemon that an init system owns.
|
|
53
|
+
*
|
|
54
|
+
* `stop` + `startLocalDaemonDetached` is wrong twice under a service manager:
|
|
55
|
+
* the clean stop is not a failure, so a `Restart=on-failure` unit never fires
|
|
56
|
+
* and the replacement runs detached OUTSIDE the unit (systemd then reports the
|
|
57
|
+
* service as stopped while a daemon is in fact running); and the detached
|
|
58
|
+
* process inherits the invoking shell's environment instead of the unit's
|
|
59
|
+
* `Environment=`/`EnvironmentFile=`, so variables like PASEO_HOME are silently
|
|
60
|
+
* lost. Losing PASEO_HOME disables PTY recording with no diagnostic at all.
|
|
61
|
+
*/
|
|
62
|
+
function assertNotServiceManaged(startOptions) {
|
|
63
|
+
const state = resolveLocalDaemonState({ home: startOptions.home });
|
|
64
|
+
if (!state.running || !state.pidInfo) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const managed = detectServiceManagement(state.pidInfo.pid);
|
|
68
|
+
if (!managed) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const error = {
|
|
72
|
+
code: "DAEMON_SERVICE_MANAGED",
|
|
73
|
+
message: `Daemon PID ${state.pidInfo.pid} is managed by ${managed.manager} unit ${managed.unit}`,
|
|
74
|
+
details: [
|
|
75
|
+
`Restart it through its service manager instead:`,
|
|
76
|
+
` ${managed.restartCommand}`,
|
|
77
|
+
"",
|
|
78
|
+
"Restarting it here would stop the unit cleanly (so the unit's Restart= policy",
|
|
79
|
+
"never fires) and spawn a detached replacement outside it. The service manager",
|
|
80
|
+
"would report the service as stopped while a daemon kept running, and the",
|
|
81
|
+
"replacement would inherit this shell's environment rather than the unit's",
|
|
82
|
+
"Environment=/EnvironmentFile= settings, silently dropping variables such as",
|
|
83
|
+
"PASEO_HOME.",
|
|
84
|
+
"",
|
|
85
|
+
"Pass --force-detached to restart detached anyway.",
|
|
86
|
+
].join("\n"),
|
|
87
|
+
};
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
50
90
|
export async function runRestartCommand(options, _command) {
|
|
51
91
|
const timeoutMs = parseTimeoutMs(options.timeout);
|
|
52
92
|
const force = options.force === true;
|
|
93
|
+
const forceDetached = options.forceDetached === true;
|
|
53
94
|
const startOptions = toStartOptions(options);
|
|
95
|
+
if (!forceDetached) {
|
|
96
|
+
assertNotServiceManaged(startOptions);
|
|
97
|
+
}
|
|
54
98
|
try {
|
|
55
99
|
let stopResult;
|
|
56
100
|
try {
|
|
@@ -88,6 +132,9 @@ export async function runRestartCommand(options, _command) {
|
|
|
88
132
|
};
|
|
89
133
|
}
|
|
90
134
|
catch (err) {
|
|
135
|
+
if (err && typeof err === "object" && "code" in err) {
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
91
138
|
const message = err instanceof Error ? err.message : String(err);
|
|
92
139
|
const error = {
|
|
93
140
|
code: "RESTART_FAILED",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Describes an init-system unit that owns the running daemon process.
|
|
3
|
+
*/
|
|
4
|
+
export interface ServiceManagement {
|
|
5
|
+
manager: "systemd";
|
|
6
|
+
/** Leaf unit name, e.g. "paseo.service". */
|
|
7
|
+
unit: string;
|
|
8
|
+
/** True when the unit is a per-user unit, i.e. `systemctl --user`. */
|
|
9
|
+
userScoped: boolean;
|
|
10
|
+
/** The command that restarts the daemon through its service manager. */
|
|
11
|
+
restartCommand: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Parse a `/proc/<pid>/cgroup` body into the service unit that owns the process.
|
|
15
|
+
*
|
|
16
|
+
* Handles both cgroup layouts, since the path is always the last colon-delimited
|
|
17
|
+
* field:
|
|
18
|
+
* v2: `0::/system.slice/paseo.service`
|
|
19
|
+
* v1: `1:name=systemd:/system.slice/paseo.service`
|
|
20
|
+
*
|
|
21
|
+
* Only a `.service` leaf counts as managed. A daemon the CLI started detached
|
|
22
|
+
* from a shell lands in a `.scope` (`session-5.scope`, `app-*.scope`) or the root
|
|
23
|
+
* cgroup, so it is never mistaken for a service.
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseServiceManagementFromCgroup(raw: string): ServiceManagement | null;
|
|
26
|
+
export interface DetectServiceManagementDeps {
|
|
27
|
+
/** Overridable for tests. Defaults to querying systemd. */
|
|
28
|
+
readUnitMainPid?: (unit: string, userScoped: boolean) => number | null;
|
|
29
|
+
/** Overridable for tests. Defaults to reading `/proc/<pid>/cgroup`. */
|
|
30
|
+
readCgroup?: (pid: number) => string | null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Detect whether `pid` is the process an init-system unit manages, rather than a
|
|
34
|
+
* detached process this CLI owns.
|
|
35
|
+
*
|
|
36
|
+
* The cgroup alone is NOT sufficient. Every child inherits its parent's cgroup,
|
|
37
|
+
* so a daemon started from a shell that is itself running inside a unit (an
|
|
38
|
+
* agent shell under `paseo.service`, a CI job under a service) would report that
|
|
39
|
+
* unit even though the daemon is an ordinary detached process. The unit's
|
|
40
|
+
* MainPID is the discriminator, so a candidate from the cgroup is confirmed
|
|
41
|
+
* against it and dropped when it does not match.
|
|
42
|
+
*
|
|
43
|
+
* Returns null on anything less than a positive confirmation: a non-Linux
|
|
44
|
+
* platform, an unreadable cgroup, a non-service cgroup, or a host where
|
|
45
|
+
* `systemctl` cannot answer. Refusing to restart is the disruptive outcome, so
|
|
46
|
+
* uncertainty resolves to "not managed" and the normal path proceeds.
|
|
47
|
+
*/
|
|
48
|
+
export declare function detectServiceManagement(pid: number, deps?: DetectServiceManagementDeps): ServiceManagement | null;
|
|
49
|
+
//# sourceMappingURL=service-manager.d.ts.map
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
/** A `user@<uid>.service` segment is the per-user manager, never the daemon's own unit. */
|
|
4
|
+
const USER_MANAGER_UNIT = /^user@\d+\.service$/;
|
|
5
|
+
const SERVICE_UNIT = /^[A-Za-z0-9@:_.-]+\.service$/;
|
|
6
|
+
/**
|
|
7
|
+
* Parse a `/proc/<pid>/cgroup` body into the service unit that owns the process.
|
|
8
|
+
*
|
|
9
|
+
* Handles both cgroup layouts, since the path is always the last colon-delimited
|
|
10
|
+
* field:
|
|
11
|
+
* v2: `0::/system.slice/paseo.service`
|
|
12
|
+
* v1: `1:name=systemd:/system.slice/paseo.service`
|
|
13
|
+
*
|
|
14
|
+
* Only a `.service` leaf counts as managed. A daemon the CLI started detached
|
|
15
|
+
* from a shell lands in a `.scope` (`session-5.scope`, `app-*.scope`) or the root
|
|
16
|
+
* cgroup, so it is never mistaken for a service.
|
|
17
|
+
*/
|
|
18
|
+
export function parseServiceManagementFromCgroup(raw) {
|
|
19
|
+
for (const line of raw.split("\n")) {
|
|
20
|
+
const trimmed = line.trim();
|
|
21
|
+
if (!trimmed)
|
|
22
|
+
continue;
|
|
23
|
+
const pathPart = trimmed.slice(trimmed.lastIndexOf(":") + 1);
|
|
24
|
+
if (!pathPart.startsWith("/"))
|
|
25
|
+
continue;
|
|
26
|
+
const segments = pathPart.split("/").filter(Boolean);
|
|
27
|
+
const userScoped = segments.some((segment) => USER_MANAGER_UNIT.test(segment));
|
|
28
|
+
// Walk from the leaf inward: the daemon's own unit is the innermost one.
|
|
29
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
30
|
+
const segment = segments[i];
|
|
31
|
+
if (!SERVICE_UNIT.test(segment) || USER_MANAGER_UNIT.test(segment))
|
|
32
|
+
continue;
|
|
33
|
+
return {
|
|
34
|
+
manager: "systemd",
|
|
35
|
+
unit: segment,
|
|
36
|
+
userScoped,
|
|
37
|
+
restartCommand: userScoped
|
|
38
|
+
? `systemctl --user restart ${segment}`
|
|
39
|
+
: `sudo systemctl restart ${segment}`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function defaultReadCgroup(pid) {
|
|
46
|
+
try {
|
|
47
|
+
return readFileSync(`/proc/${pid}/cgroup`, "utf8");
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Ask systemd for a unit's MainPID. Returns null when systemd cannot answer. */
|
|
54
|
+
function readUnitMainPid(unit, userScoped) {
|
|
55
|
+
const args = userScoped
|
|
56
|
+
? ["--user", "show", unit, "--property=MainPID", "--value"]
|
|
57
|
+
: ["show", unit, "--property=MainPID", "--value"];
|
|
58
|
+
const result = spawnSync("systemctl", args, { encoding: "utf8", timeout: 2000 });
|
|
59
|
+
if (result.error || result.status !== 0) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const parsed = Number.parseInt((result.stdout ?? "").trim(), 10);
|
|
63
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Detect whether `pid` is the process an init-system unit manages, rather than a
|
|
67
|
+
* detached process this CLI owns.
|
|
68
|
+
*
|
|
69
|
+
* The cgroup alone is NOT sufficient. Every child inherits its parent's cgroup,
|
|
70
|
+
* so a daemon started from a shell that is itself running inside a unit (an
|
|
71
|
+
* agent shell under `paseo.service`, a CI job under a service) would report that
|
|
72
|
+
* unit even though the daemon is an ordinary detached process. The unit's
|
|
73
|
+
* MainPID is the discriminator, so a candidate from the cgroup is confirmed
|
|
74
|
+
* against it and dropped when it does not match.
|
|
75
|
+
*
|
|
76
|
+
* Returns null on anything less than a positive confirmation: a non-Linux
|
|
77
|
+
* platform, an unreadable cgroup, a non-service cgroup, or a host where
|
|
78
|
+
* `systemctl` cannot answer. Refusing to restart is the disruptive outcome, so
|
|
79
|
+
* uncertainty resolves to "not managed" and the normal path proceeds.
|
|
80
|
+
*/
|
|
81
|
+
export function detectServiceManagement(pid, deps = {}) {
|
|
82
|
+
if (process.platform !== "linux" || !Number.isInteger(pid) || pid <= 0) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const readCgroup = deps.readCgroup ?? defaultReadCgroup;
|
|
86
|
+
const raw = readCgroup(pid);
|
|
87
|
+
if (!raw) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const candidate = parseServiceManagementFromCgroup(raw);
|
|
91
|
+
if (!candidate) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const lookup = deps.readUnitMainPid ?? readUnitMainPid;
|
|
95
|
+
return lookup(candidate.unit, candidate.userScoped) === pid ? candidate : null;
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=service-manager.js.map
|
|
@@ -2,6 +2,7 @@ import type { Command } from "commander";
|
|
|
2
2
|
import type { SingleResult } from "../../output/index.js";
|
|
3
3
|
import { type ScheduleCommandOptions, type ScheduleRow } from "./shared.js";
|
|
4
4
|
export interface ScheduleCreateOptions extends ScheduleCommandOptions {
|
|
5
|
+
at?: string;
|
|
5
6
|
every?: string;
|
|
6
7
|
cron?: string;
|
|
7
8
|
timezone?: string;
|
|
@@ -5,6 +5,7 @@ export async function runCreateCommand(prompt, options, command) {
|
|
|
5
5
|
const runNow = runNowSource === "cli" ? Boolean(options.runNow) : undefined;
|
|
6
6
|
const input = parseScheduleCreateInput({
|
|
7
7
|
prompt,
|
|
8
|
+
at: options.at,
|
|
8
9
|
every: options.every,
|
|
9
10
|
cron: options.cron,
|
|
10
11
|
timezone: options.timezone,
|
|
@@ -16,7 +16,8 @@ export function createScheduleCommand() {
|
|
|
16
16
|
.command("create")
|
|
17
17
|
.description("Create a schedule")
|
|
18
18
|
.argument("<prompt>", "Prompt to run on the schedule")
|
|
19
|
-
.option("--
|
|
19
|
+
.option("--at <when>", "Run once at an instant: ISO 8601 (2026-08-21T21:30:00-03:00) or a duration from now (90m)")
|
|
20
|
+
.option("--every <duration>", "Fixed interval cadence (for example: 5m, 1h, 1d)")
|
|
20
21
|
.option("--cron <expr>", "Cron cadence expression")
|
|
21
22
|
.option("--timezone <iana>", "IANA time zone for cron cadence (default: UTC)")
|
|
22
23
|
.option("--name <name>", "Optional schedule name")
|
|
@@ -13,6 +13,7 @@ export declare function formatTarget(target: ScheduleTarget | ScheduleListItem["
|
|
|
13
13
|
export declare function formatDurationMs(durationMs: number): string;
|
|
14
14
|
export declare function parseScheduleCreateInput(options: {
|
|
15
15
|
prompt: string;
|
|
16
|
+
at?: string;
|
|
16
17
|
every?: string;
|
|
17
18
|
cron?: string;
|
|
18
19
|
timezone?: string;
|
|
@@ -102,13 +102,7 @@ export function parseScheduleCreateInput(options) {
|
|
|
102
102
|
message: "Schedule prompt cannot be empty",
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
|
-
const cadence =
|
|
106
|
-
if (!cadence) {
|
|
107
|
-
throw {
|
|
108
|
-
code: "INVALID_CADENCE",
|
|
109
|
-
message: "Specify exactly one of --every or --cron",
|
|
110
|
-
};
|
|
111
|
-
}
|
|
105
|
+
const { cadence, oneOff } = parseCreateCadence(options);
|
|
112
106
|
const cwdInput = options.cwd?.trim();
|
|
113
107
|
if (options.host !== undefined && !cwdInput) {
|
|
114
108
|
throw {
|
|
@@ -116,7 +110,7 @@ export function parseScheduleCreateInput(options) {
|
|
|
116
110
|
message: "--cwd is required when --host is specified (the local working directory will not exist on the remote daemon)",
|
|
117
111
|
};
|
|
118
112
|
}
|
|
119
|
-
const runOnCreate =
|
|
113
|
+
const { maxRuns, runOnCreate } = resolveCreateRunLimits(options, cadence, oneOff);
|
|
120
114
|
const targetValue = options.target?.trim();
|
|
121
115
|
const modeId = options.mode?.trim();
|
|
122
116
|
const hasExplicitNewAgentOption = options.provider !== undefined || options.mode !== undefined;
|
|
@@ -139,7 +133,6 @@ export function parseScheduleCreateInput(options) {
|
|
|
139
133
|
hasExplicitNewAgentOption,
|
|
140
134
|
createNewAgentTarget,
|
|
141
135
|
});
|
|
142
|
-
const maxRuns = options.maxRuns === undefined ? undefined : parsePositiveInt(options.maxRuns, "--max-runs");
|
|
143
136
|
const expiresAt = options.expiresIn === undefined
|
|
144
137
|
? undefined
|
|
145
138
|
: new Date(Date.now() + parseDuration(options.expiresIn)).toISOString();
|
|
@@ -153,6 +146,33 @@ export function parseScheduleCreateInput(options) {
|
|
|
153
146
|
...(expiresAt ? { expiresAt } : {}),
|
|
154
147
|
};
|
|
155
148
|
}
|
|
149
|
+
// Split out of parseScheduleCreateInput to keep its branch count under the
|
|
150
|
+
// complexity cap: a one-off pins both limits, everything else defers to the
|
|
151
|
+
// existing --run-now rules.
|
|
152
|
+
function resolveCreateRunLimits(options, cadence, oneOff) {
|
|
153
|
+
const explicitMaxRuns = options.maxRuns === undefined ? undefined : parsePositiveInt(options.maxRuns, "--max-runs");
|
|
154
|
+
if (!oneOff) {
|
|
155
|
+
return {
|
|
156
|
+
maxRuns: explicitMaxRuns,
|
|
157
|
+
runOnCreate: resolveRunOnCreate(options.runNow, cadence.type),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (options.runNow !== undefined) {
|
|
161
|
+
throw {
|
|
162
|
+
code: "REDUNDANT_RUN_NOW",
|
|
163
|
+
message: "--run-now/--no-run-now cannot be combined with --at",
|
|
164
|
+
details: "An --at schedule runs exactly once, at its instant",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (explicitMaxRuns !== undefined && explicitMaxRuns !== 1) {
|
|
168
|
+
throw {
|
|
169
|
+
code: "INVALID_MAX_RUNS",
|
|
170
|
+
message: "--max-runs cannot be combined with --at",
|
|
171
|
+
details: "An --at schedule runs exactly once",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return { maxRuns: 1, runOnCreate: false };
|
|
175
|
+
}
|
|
156
176
|
function resolveRunOnCreate(runNow, cadenceType) {
|
|
157
177
|
if (runNow === true && cadenceType === "every") {
|
|
158
178
|
throw {
|
|
@@ -205,6 +225,65 @@ export function parseScheduleUpdateInput(options) {
|
|
|
205
225
|
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
206
226
|
};
|
|
207
227
|
}
|
|
228
|
+
// A duration ("90m", "2h30m", "45" seconds) rather than a timestamp. Checked
|
|
229
|
+
// first so --at can accept both without Date parsing guessing at bare numbers.
|
|
230
|
+
const AT_DURATION_ONLY = /^(?:\d+|(?:\d+[smhd])+)$/;
|
|
231
|
+
function resolveAtInstant(at) {
|
|
232
|
+
if (AT_DURATION_ONLY.test(at)) {
|
|
233
|
+
return new Date(Date.now() + parseDuration(at));
|
|
234
|
+
}
|
|
235
|
+
const parsed = new Date(at);
|
|
236
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
237
|
+
throw {
|
|
238
|
+
code: "INVALID_AT",
|
|
239
|
+
message: `Invalid --at value: ${at}`,
|
|
240
|
+
details: "Use an ISO 8601 instant (2026-08-21T21:30:00-03:00) or a duration from now (90m)",
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return parsed;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* `--at` is a one-off. It lowers to an `every` cadence sized to the distance to
|
|
247
|
+
* the instant, pinned to a single run, deliberately NOT to a pinned cron: an
|
|
248
|
+
* `every` schedule stores its computed nextRunAt, so a daemon that was down at
|
|
249
|
+
* that moment fires it late on the next tick, which is what a reminder wants.
|
|
250
|
+
* A pinned cron would skip silently to the next calendar match a year out.
|
|
251
|
+
* Mirrors the MCP `at` input added in 0.3.41.
|
|
252
|
+
*/
|
|
253
|
+
function parseCreateCadence(options) {
|
|
254
|
+
const at = options.at?.trim();
|
|
255
|
+
if (at) {
|
|
256
|
+
if (options.every !== undefined || options.cron !== undefined) {
|
|
257
|
+
throw {
|
|
258
|
+
code: "INVALID_CADENCE",
|
|
259
|
+
message: "Specify exactly one of --at, --every or --cron",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
if (parseTimeZoneFlag(options.timezone) !== undefined) {
|
|
263
|
+
throw {
|
|
264
|
+
code: "INVALID_TIME_ZONE",
|
|
265
|
+
message: "--timezone can only be used with --cron",
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const instant = resolveAtInstant(at);
|
|
269
|
+
const everyMs = instant.getTime() - Date.now();
|
|
270
|
+
if (everyMs <= 0) {
|
|
271
|
+
throw {
|
|
272
|
+
code: "INVALID_AT",
|
|
273
|
+
message: `--at must be in the future: ${instant.toISOString()} has already passed`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return { cadence: { type: "every", everyMs }, oneOff: true };
|
|
277
|
+
}
|
|
278
|
+
const cadence = parseCadenceFromFlags(options.every, options.cron, options.timezone);
|
|
279
|
+
if (!cadence) {
|
|
280
|
+
throw {
|
|
281
|
+
code: "INVALID_CADENCE",
|
|
282
|
+
message: "Specify exactly one of --at, --every or --cron",
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
return { cadence, oneOff: false };
|
|
286
|
+
}
|
|
208
287
|
function parseCadenceFromFlags(every, cron, timezone) {
|
|
209
288
|
if (every !== undefined && cron !== undefined) {
|
|
210
289
|
throw {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { LoopParamField, LoopParamValues } from "@hyperdrive.bot/paseo-protocol/fleet/params";
|
|
2
|
+
import type { WorkflowDefinitionIssue } from "@hyperdrive.bot/paseo-protocol/workflow/definition";
|
|
3
|
+
/**
|
|
4
|
+
* Reading the file `paseo workflow start` was pointed at, and turning `--input KEY=value`
|
|
5
|
+
* flags into typed values.
|
|
6
|
+
*
|
|
7
|
+
* Both jobs live here rather than in `start.ts` so they are testable without a daemon,
|
|
8
|
+
* a socket or a Commander instance. This module imports no daemon-connection helper at
|
|
9
|
+
* all, and must not start: every error it raises is raised before the CLI has any reason
|
|
10
|
+
* to talk to a daemon, which is what makes "a bad file never opens a socket" a property
|
|
11
|
+
* of the code rather than of the call order in `start.ts`. The import list above is the
|
|
12
|
+
* whole guarantee, and a grep for the connection helper is what enforces it.
|
|
13
|
+
*/
|
|
14
|
+
export type WorkflowStartFile = {
|
|
15
|
+
kind: "definition";
|
|
16
|
+
raw: unknown;
|
|
17
|
+
} | {
|
|
18
|
+
kind: "task-graph";
|
|
19
|
+
raw: unknown;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Read and parse the file, and decide which of the two shapes it is.
|
|
23
|
+
*
|
|
24
|
+
* ONE parser. JSON is a subset of YAML 1.2, so a `.json` file needs no branch of its
|
|
25
|
+
* own, and the file extension is never consulted: a definition committed as `.json` and
|
|
26
|
+
* a task graph committed as `.yaml` both have to work, and an extension check would get
|
|
27
|
+
* each of them wrong in the direction that produces an error about the wrong thing.
|
|
28
|
+
*/
|
|
29
|
+
export declare function readWorkflowStartFile(filePath: string): WorkflowStartFile;
|
|
30
|
+
export interface ParsedInputFlags {
|
|
31
|
+
values: LoopParamValues;
|
|
32
|
+
issues: WorkflowDefinitionIssue[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Turn `--input KEY=VALUE` flags into values typed per the definition's declared inputs.
|
|
36
|
+
*
|
|
37
|
+
* Returns every issue rather than throwing on the first, mirroring `validateLoopParams`:
|
|
38
|
+
* an author with three bad flags fixes them once instead of re-running to discover the
|
|
39
|
+
* next one. Never returns early.
|
|
40
|
+
*
|
|
41
|
+
* It owns exactly three failure classes - flag syntax, undeclared key, and coercion to
|
|
42
|
+
* the declared type. It deliberately does NOT call `validateLoopParams`: `required`,
|
|
43
|
+
* `min`, `max` and `enum` membership are the compiler's single pass, and running them
|
|
44
|
+
* here as well would report the same mistake twice in one run. For the same reason an
|
|
45
|
+
* undeclared key is reported here AND withheld from `values`, so the compiler's own
|
|
46
|
+
* undeclared-key check never sees it and cannot double-report it.
|
|
47
|
+
*/
|
|
48
|
+
export declare function parseInputFlags(flags: string[], fields: LoopParamField[]): ParsedInputFlags;
|
|
49
|
+
//# sourceMappingURL=definition-file.d.ts.map
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import YAML from "yaml";
|
|
3
|
+
/**
|
|
4
|
+
* Read and parse the file, and decide which of the two shapes it is.
|
|
5
|
+
*
|
|
6
|
+
* ONE parser. JSON is a subset of YAML 1.2, so a `.json` file needs no branch of its
|
|
7
|
+
* own, and the file extension is never consulted: a definition committed as `.json` and
|
|
8
|
+
* a task graph committed as `.yaml` both have to work, and an extension check would get
|
|
9
|
+
* each of them wrong in the direction that produces an error about the wrong thing.
|
|
10
|
+
*/
|
|
11
|
+
export function readWorkflowStartFile(filePath) {
|
|
12
|
+
let parsed;
|
|
13
|
+
try {
|
|
14
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
15
|
+
parsed = YAML.parse(raw);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
// An unreadable or unparseable file has no top-level `version` key, so the rule
|
|
19
|
+
// below already classifies it as a task graph. Raising the pre-existing task-graph
|
|
20
|
+
// error here is what keeps today's behaviour for the malformed-JSON case.
|
|
21
|
+
throw {
|
|
22
|
+
code: "WORKFLOW_GRAPH_INVALID",
|
|
23
|
+
message: `Could not read/parse task graph at ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
24
|
+
details: "Expected a JSON file matching the daemon's task-graph shape.",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
// `Object.hasOwn`, never truthiness: `version: 0`, `version: null` and `version: "1"`
|
|
28
|
+
// are all definitions their author got wrong, and they must reach the definition
|
|
29
|
+
// parser's version gate. A truthiness check would send them down the task-graph
|
|
30
|
+
// branch, which reports a graph-shape error about a file nobody wrote as a graph.
|
|
31
|
+
const isDefinition = typeof parsed === "object" &&
|
|
32
|
+
parsed !== null &&
|
|
33
|
+
!Array.isArray(parsed) &&
|
|
34
|
+
Object.hasOwn(parsed, "version");
|
|
35
|
+
return isDefinition ? { kind: "definition", raw: parsed } : { kind: "task-graph", raw: parsed };
|
|
36
|
+
}
|
|
37
|
+
/** The six spellings `boolean` accepts, listed once so the check and the message agree. */
|
|
38
|
+
const TRUE_SPELLINGS = ["true", "1", "yes"];
|
|
39
|
+
const FALSE_SPELLINGS = ["false", "0", "no"];
|
|
40
|
+
/**
|
|
41
|
+
* Turn `--input KEY=VALUE` flags into values typed per the definition's declared inputs.
|
|
42
|
+
*
|
|
43
|
+
* Returns every issue rather than throwing on the first, mirroring `validateLoopParams`:
|
|
44
|
+
* an author with three bad flags fixes them once instead of re-running to discover the
|
|
45
|
+
* next one. Never returns early.
|
|
46
|
+
*
|
|
47
|
+
* It owns exactly three failure classes - flag syntax, undeclared key, and coercion to
|
|
48
|
+
* the declared type. It deliberately does NOT call `validateLoopParams`: `required`,
|
|
49
|
+
* `min`, `max` and `enum` membership are the compiler's single pass, and running them
|
|
50
|
+
* here as well would report the same mistake twice in one run. For the same reason an
|
|
51
|
+
* undeclared key is reported here AND withheld from `values`, so the compiler's own
|
|
52
|
+
* undeclared-key check never sees it and cannot double-report it.
|
|
53
|
+
*/
|
|
54
|
+
export function parseInputFlags(flags, fields) {
|
|
55
|
+
const values = {};
|
|
56
|
+
const issues = [];
|
|
57
|
+
const byKey = new Map(fields.map((field) => [field.key, field]));
|
|
58
|
+
// Sorted once: the declared-key list is embedded in an error message, and a message
|
|
59
|
+
// whose word order depends on declaration order is not assertable.
|
|
60
|
+
const declaredKeys = [...byKey.keys()].sort();
|
|
61
|
+
const declaredSuffix = declaredKeys.length > 0
|
|
62
|
+
? `Declared inputs are: ${declaredKeys.join(", ")}.`
|
|
63
|
+
: "This definition declares no inputs.";
|
|
64
|
+
for (const flag of flags) {
|
|
65
|
+
// First `=` only, so a value containing `=` survives intact: NOTE=a=b is "a=b".
|
|
66
|
+
const eq = flag.indexOf("=");
|
|
67
|
+
if (eq === -1) {
|
|
68
|
+
issues.push({
|
|
69
|
+
path: "--input",
|
|
70
|
+
message: `"${flag}" is not KEY=VALUE. Pass an input as --input KEY=VALUE.`,
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const key = flag.slice(0, eq).trim();
|
|
75
|
+
const text = flag.slice(eq + 1);
|
|
76
|
+
const field = byKey.get(key);
|
|
77
|
+
if (!field) {
|
|
78
|
+
issues.push({
|
|
79
|
+
path: `inputs[${key}]`,
|
|
80
|
+
message: `"${key}" is not an input this definition declares. ${declaredSuffix}`,
|
|
81
|
+
});
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const coerced = coerceInputValue(field, text);
|
|
85
|
+
if (!coerced.ok) {
|
|
86
|
+
issues.push({ path: `inputs[${key}]`, message: coerced.message });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// Last wins on a repeated key, by plain assignment. Repeating a flag is how every
|
|
90
|
+
// other repeatable option in this CLI behaves and is not an issue on its own.
|
|
91
|
+
values[key] = coerced.value;
|
|
92
|
+
}
|
|
93
|
+
return { values, issues };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Coerce one flag's text to one field's declared type.
|
|
97
|
+
*
|
|
98
|
+
* A `switch` over the closed type vocabulary rather than a chain of `if`s, so every
|
|
99
|
+
* branch is forced to name its own failure text and a new param type fails to compile
|
|
100
|
+
* here instead of silently falling through to "string".
|
|
101
|
+
*/
|
|
102
|
+
function coerceInputValue(field, text) {
|
|
103
|
+
switch (field.type) {
|
|
104
|
+
case "string":
|
|
105
|
+
// Verbatim, not trimmed: leading or trailing space in a value destined for a
|
|
106
|
+
// prompt is the author's business, not the CLI's.
|
|
107
|
+
return { ok: true, value: text };
|
|
108
|
+
case "enum":
|
|
109
|
+
// Also verbatim. Membership against `options` is the compiler's check, so a bad
|
|
110
|
+
// choice is reported once, there, with the allowed values listed.
|
|
111
|
+
return { ok: true, value: text };
|
|
112
|
+
case "number":
|
|
113
|
+
return coerceNumber(field, text);
|
|
114
|
+
case "boolean":
|
|
115
|
+
return coerceBoolean(field, text);
|
|
116
|
+
case "string-list":
|
|
117
|
+
return {
|
|
118
|
+
ok: true,
|
|
119
|
+
value: text
|
|
120
|
+
.trim()
|
|
121
|
+
.split(",")
|
|
122
|
+
.map((entry) => entry.trim())
|
|
123
|
+
.filter((entry) => entry.length > 0),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function coerceNumber(field, text) {
|
|
128
|
+
const trimmed = text.trim();
|
|
129
|
+
// The empty check comes FIRST and that ordering is the whole point: `Number("")` is
|
|
130
|
+
// `0`, so `--input COUNT=` would otherwise launch a workflow with a count of zero the
|
|
131
|
+
// author never typed. An empty number is not a value; an omitted flag is.
|
|
132
|
+
if (trimmed.length === 0) {
|
|
133
|
+
return { ok: false, message: `${field.label} was passed with no value; expected a number.` };
|
|
134
|
+
}
|
|
135
|
+
const parsed = Number(trimmed);
|
|
136
|
+
if (!Number.isFinite(parsed)) {
|
|
137
|
+
return { ok: false, message: `${field.label} must be a number; got "${text}".` };
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, value: parsed };
|
|
140
|
+
}
|
|
141
|
+
function coerceBoolean(field, text) {
|
|
142
|
+
const normalized = text.trim().toLowerCase();
|
|
143
|
+
if (TRUE_SPELLINGS.includes(normalized)) {
|
|
144
|
+
return { ok: true, value: true };
|
|
145
|
+
}
|
|
146
|
+
if (FALSE_SPELLINGS.includes(normalized)) {
|
|
147
|
+
return { ok: true, value: false };
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
message: `${field.label} must be one of: ${[...TRUE_SPELLINGS, ...FALSE_SPELLINGS].join(", ")}; got "${text}".`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
//# sourceMappingURL=definition-file.js.map
|
|
@@ -12,6 +12,7 @@ export interface WorkflowStartOptions extends CommandOptions {
|
|
|
12
12
|
cwd?: string;
|
|
13
13
|
title?: string;
|
|
14
14
|
detach?: boolean;
|
|
15
|
+
input?: string[];
|
|
15
16
|
}
|
|
16
17
|
export declare function addWorkflowStartOptions(command: Command): Command;
|
|
17
18
|
/**
|
|
@@ -21,6 +22,6 @@ export declare function addWorkflowStartOptions(command: Command): Command;
|
|
|
21
22
|
*/
|
|
22
23
|
export declare function buildWorkflowStatusUrl(host: string, workflowId: string): string;
|
|
23
24
|
export type WorkflowStartResult = SingleResult<StartRow>;
|
|
24
|
-
export declare function runWorkflowStartCommand(
|
|
25
|
+
export declare function runWorkflowStartCommand(filePath: string, options: WorkflowStartOptions, _command: Command): Promise<WorkflowStartResult>;
|
|
25
26
|
export {};
|
|
26
27
|
//# sourceMappingURL=start.d.ts.map
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { WorkflowTaskGraphSchema } from "@hyperdrive.bot/paseo-protocol/messages";
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { WorkflowTaskGraphSchema, } from "@hyperdrive.bot/paseo-protocol/messages";
|
|
3
|
+
import { defaultLoopParams } from "@hyperdrive.bot/paseo-protocol/fleet/params";
|
|
4
|
+
import { parseWorkflowDefinition, WorkflowDefinitionError, } from "@hyperdrive.bot/paseo-protocol/workflow/definition";
|
|
5
|
+
import { compileWorkflowDefinition } from "@hyperdrive.bot/paseo-protocol/workflow/compile";
|
|
3
6
|
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
|
7
|
+
import { collectMultiple } from "../../utils/command-options.js";
|
|
8
|
+
import { parseInputFlags, readWorkflowStartFile } from "./definition-file.js";
|
|
4
9
|
const startSchema = {
|
|
5
10
|
idField: "workflowId",
|
|
6
11
|
columns: [
|
|
@@ -16,8 +21,9 @@ const startSchema = {
|
|
|
16
21
|
};
|
|
17
22
|
export function addWorkflowStartOptions(command) {
|
|
18
23
|
return command
|
|
19
|
-
.description("Launch a pre-built task-graph into the daemon
|
|
20
|
-
.argument("<
|
|
24
|
+
.description("Launch a workflow definition (compiled here) or a pre-built task-graph into the daemon")
|
|
25
|
+
.argument("<file>", "Path to a workflow definition (YAML or JSON) or a pre-built task-graph JSON file")
|
|
26
|
+
.option("--input <key=value>", "Definition input value (repeatable)", collectMultiple, [])
|
|
21
27
|
.option("--provider <provider>", "Agent provider each child runs (default: claude)")
|
|
22
28
|
.option("--model <model>", "Optional provider model each child is spawned with")
|
|
23
29
|
.option("--cwd <dir>", "Working directory all child agents inherit (default: cwd)")
|
|
@@ -46,21 +52,95 @@ export function buildWorkflowStatusUrl(host, workflowId) {
|
|
|
46
52
|
const scheme = trimmed.includes("ssl=true") ? "https" : "http";
|
|
47
53
|
return `${scheme}://${authority}/api/workflows/${workflowId}`;
|
|
48
54
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
/** One rendering of an issue list into `CommandError.details`, so the throw sites cannot drift. */
|
|
56
|
+
function issuesToDetails(issues) {
|
|
57
|
+
return issues.map((issue) => `${issue.path}: ${issue.message}`);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The pre-built path: today's behaviour, unchanged, plus one refusal.
|
|
61
|
+
*
|
|
62
|
+
* `--input` here is an error rather than a no-op. A task graph has no `inputs:`
|
|
63
|
+
* vocabulary, so accepting the flag and discarding it would read as configured and
|
|
64
|
+
* behave as absent — the exact silent acceptance this command exists to refuse.
|
|
65
|
+
*/
|
|
66
|
+
function resolveTaskGraph(filePath, raw, inputFlags) {
|
|
67
|
+
if (inputFlags.length > 0) {
|
|
68
|
+
throw {
|
|
69
|
+
code: "WORKFLOW_INPUT_INVALID",
|
|
70
|
+
message: `--input is only supported for workflow definitions; ${filePath} is a pre-built task graph (no top-level "version" key).`,
|
|
71
|
+
details: inputFlags,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
53
74
|
try {
|
|
54
|
-
|
|
55
|
-
graph = WorkflowTaskGraphSchema.parse(JSON.parse(raw));
|
|
75
|
+
return { graph: WorkflowTaskGraphSchema.parse(raw) };
|
|
56
76
|
}
|
|
57
77
|
catch (error) {
|
|
58
78
|
throw {
|
|
59
79
|
code: "WORKFLOW_GRAPH_INVALID",
|
|
60
|
-
message: `Could not read/parse task graph at ${
|
|
80
|
+
message: `Could not read/parse task graph at ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
61
81
|
details: "Expected a JSON file matching the daemon's task-graph shape.",
|
|
62
82
|
};
|
|
63
83
|
}
|
|
84
|
+
}
|
|
85
|
+
/** Parse the definition, read the flags, compile — in that order, each reporting every issue. */
|
|
86
|
+
function resolveDefinition(filePath, raw, inputFlags, options) {
|
|
87
|
+
const parsed = parseWorkflowDefinition(raw);
|
|
88
|
+
if (!parsed.ok) {
|
|
89
|
+
throw {
|
|
90
|
+
code: "WORKFLOW_DEFINITION_INVALID",
|
|
91
|
+
message: `Workflow definition at ${filePath} is invalid (${parsed.issues.length} issue(s)).`,
|
|
92
|
+
details: issuesToDetails(parsed.issues),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const definition = parsed.definition;
|
|
96
|
+
const flags = parseInputFlags(inputFlags, definition.inputs);
|
|
97
|
+
if (flags.issues.length > 0) {
|
|
98
|
+
throw {
|
|
99
|
+
code: "WORKFLOW_INPUT_INVALID",
|
|
100
|
+
message: `Could not read --input values for ${filePath} (${flags.issues.length} issue(s)).`,
|
|
101
|
+
details: issuesToDetails(flags.issues),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// Flags on the RIGHT: a supplied --input always beats a declared default.
|
|
105
|
+
const values = { ...defaultLoopParams({ fields: definition.inputs }), ...flags.values };
|
|
106
|
+
try {
|
|
107
|
+
// The compiler is pure by contract, so the three ambient values it needs are
|
|
108
|
+
// stamped here — the CLI is the only layer allowed to read a clock, a uuid or the
|
|
109
|
+
// process cwd. `session.id` is NOT the workflow id; the daemon mints its own.
|
|
110
|
+
return compileWorkflowDefinition({
|
|
111
|
+
definition,
|
|
112
|
+
values,
|
|
113
|
+
session: {
|
|
114
|
+
id: randomUUID(),
|
|
115
|
+
createdAt: new Date().toISOString(),
|
|
116
|
+
outputDirectory: options.cwd ?? process.cwd(),
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
// Anything that is not a definition error is a genuine bug and must keep its stack
|
|
122
|
+
// trace rather than be relabelled as user error.
|
|
123
|
+
if (!(error instanceof WorkflowDefinitionError))
|
|
124
|
+
throw error;
|
|
125
|
+
// One deterministic routing rule: all-`inputs[` paths means the values were wrong,
|
|
126
|
+
// anything else means the definition was. Either way every issue lands in `details`.
|
|
127
|
+
const allInputIssues = error.issues.every((issue) => issue.path.startsWith("inputs["));
|
|
128
|
+
throw {
|
|
129
|
+
code: allInputIssues ? "WORKFLOW_INPUT_INVALID" : "WORKFLOW_DEFINITION_INVALID",
|
|
130
|
+
message: `Could not compile workflow definition at ${filePath} (${error.issues.length} issue(s)).`,
|
|
131
|
+
details: issuesToDetails(error.issues),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export async function runWorkflowStartCommand(filePath, options, _command) {
|
|
136
|
+
// Read, compile and validate entirely client-side, BEFORE any socket is opened: a bad
|
|
137
|
+
// definition must fail with its issue list even when the daemon is down. The daemon
|
|
138
|
+
// re-validates the graph against the same schema at its own parse boundary.
|
|
139
|
+
const file = readWorkflowStartFile(filePath);
|
|
140
|
+
const inputFlags = options.input ?? [];
|
|
141
|
+
const { graph, agentPresets } = file.kind === "task-graph"
|
|
142
|
+
? resolveTaskGraph(filePath, file.raw, inputFlags)
|
|
143
|
+
: resolveDefinition(filePath, file.raw, inputFlags, options);
|
|
64
144
|
const host = getDaemonHost({ host: options.host });
|
|
65
145
|
let client;
|
|
66
146
|
try {
|
|
@@ -81,6 +161,8 @@ export async function runWorkflowStartCommand(graphPath, options, _command) {
|
|
|
81
161
|
cwd: options.cwd ?? process.cwd(),
|
|
82
162
|
...(options.model ? { model: options.model } : {}),
|
|
83
163
|
...(options.title ? { title: options.title } : {}),
|
|
164
|
+
// Conditional so the task-graph branch's payload stays identical to today's.
|
|
165
|
+
...(agentPresets && Object.keys(agentPresets).length > 0 ? { agentPresets } : {}),
|
|
84
166
|
});
|
|
85
167
|
await client.close();
|
|
86
168
|
return {
|
package/dist/utils/client.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AgentSnapshotPayload } from "@hyperdrive.bot/paseo-protocol/messages";
|
|
1
2
|
import { DaemonClient } from "@hyperdrive.bot/paseo-client/internal/daemon-client";
|
|
2
3
|
export interface ConnectOptions {
|
|
3
4
|
host?: string;
|
|
@@ -40,5 +41,37 @@ interface AgentLike {
|
|
|
40
41
|
* Returns the full agent ID if found, null otherwise.
|
|
41
42
|
*/
|
|
42
43
|
export declare function resolveAgentId(idOrName: string, agents: AgentLike[]): string | null;
|
|
44
|
+
/** Minimal surface of `DaemonClient` needed to resolve an agent identifier. */
|
|
45
|
+
export interface DaemonAgentFetcher {
|
|
46
|
+
fetchAgent(options: {
|
|
47
|
+
agentId: string;
|
|
48
|
+
}): Promise<{
|
|
49
|
+
agent: AgentSnapshotPayload;
|
|
50
|
+
} | null>;
|
|
51
|
+
}
|
|
52
|
+
/** Outcome of resolving an agent identifier through the daemon. */
|
|
53
|
+
export type DaemonAgentResolution = {
|
|
54
|
+
ok: true;
|
|
55
|
+
agent: AgentSnapshotPayload;
|
|
56
|
+
} | {
|
|
57
|
+
ok: false;
|
|
58
|
+
error: string;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Resolve an agent identifier (full ID, unambiguous ID prefix, or exact title)
|
|
62
|
+
* through the daemon and return the matching snapshot.
|
|
63
|
+
*
|
|
64
|
+
* Always prefer this over `fetchAgents()` + `resolveAgentId()`. `fetch_agents`
|
|
65
|
+
* is a PAGED rpc and the daemon caps a page at 200 entries by default
|
|
66
|
+
* (`request.page?.limit ?? 200` in the server session handler), so matching an
|
|
67
|
+
* identifier against a single unpaged response only ever searches the 200 most
|
|
68
|
+
* recently updated agents for that filter. On a host with more than 200 agents
|
|
69
|
+
* most valid IDs then resolve to "not found", and asking for
|
|
70
|
+
* `includeArchived: true` makes it strictly worse rather than better, because
|
|
71
|
+
* archived agents compete for the same 200 slots and push live ones out of the
|
|
72
|
+
* window. `fetch_agent` resolves server-side across the whole agent store with
|
|
73
|
+
* no cap, which is why `paseo agent inspect` never had this bug.
|
|
74
|
+
*/
|
|
75
|
+
export declare function resolveAgentViaDaemon(client: DaemonAgentFetcher, idOrName: string): Promise<DaemonAgentResolution>;
|
|
43
76
|
export {};
|
|
44
77
|
//# sourceMappingURL=client.d.ts.map
|
package/dist/utils/client.js
CHANGED
|
@@ -327,4 +327,39 @@ export function resolveAgentId(idOrName, agents) {
|
|
|
327
327
|
}
|
|
328
328
|
return null;
|
|
329
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* Resolve an agent identifier (full ID, unambiguous ID prefix, or exact title)
|
|
332
|
+
* through the daemon and return the matching snapshot.
|
|
333
|
+
*
|
|
334
|
+
* Always prefer this over `fetchAgents()` + `resolveAgentId()`. `fetch_agents`
|
|
335
|
+
* is a PAGED rpc and the daemon caps a page at 200 entries by default
|
|
336
|
+
* (`request.page?.limit ?? 200` in the server session handler), so matching an
|
|
337
|
+
* identifier against a single unpaged response only ever searches the 200 most
|
|
338
|
+
* recently updated agents for that filter. On a host with more than 200 agents
|
|
339
|
+
* most valid IDs then resolve to "not found", and asking for
|
|
340
|
+
* `includeArchived: true` makes it strictly worse rather than better, because
|
|
341
|
+
* archived agents compete for the same 200 slots and push live ones out of the
|
|
342
|
+
* window. `fetch_agent` resolves server-side across the whole agent store with
|
|
343
|
+
* no cap, which is why `paseo agent inspect` never had this bug.
|
|
344
|
+
*/
|
|
345
|
+
export async function resolveAgentViaDaemon(client, idOrName) {
|
|
346
|
+
const trimmed = idOrName.trim();
|
|
347
|
+
if (!trimmed) {
|
|
348
|
+
return { ok: false, error: "Agent identifier cannot be empty" };
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
const result = await client.fetchAgent({ agentId: trimmed });
|
|
352
|
+
if (!result) {
|
|
353
|
+
return { ok: false, error: `Agent not found: ${trimmed}` };
|
|
354
|
+
}
|
|
355
|
+
return { ok: true, agent: result.agent };
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
// The daemon reports "not found" AND "ambiguous prefix/title" as an error
|
|
359
|
+
// payload on fetch_agent_response, which the client rethrows. Surfacing its
|
|
360
|
+
// message verbatim is an improvement on the old client-side resolver, which
|
|
361
|
+
// silently picked the first match for an ambiguous prefix.
|
|
362
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
330
365
|
//# sourceMappingURL=client.js.map
|
package/dist/utils/duration.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Parse duration string to milliseconds.
|
|
3
|
-
* Supports formats like: 5m, 30s, 1h, 2h30m, 90, etc.
|
|
3
|
+
* Supports formats like: 5m, 30s, 1h, 2h30m, 1d, 90, etc.
|
|
4
4
|
* If no unit is specified, assumes seconds.
|
|
5
5
|
*/
|
|
6
6
|
export declare function parseDuration(input: string): number;
|
package/dist/utils/duration.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Parse duration string to milliseconds.
|
|
3
|
-
* Supports formats like: 5m, 30s, 1h, 2h30m, 90, etc.
|
|
3
|
+
* Supports formats like: 5m, 30s, 1h, 2h30m, 1d, 90, etc.
|
|
4
4
|
* If no unit is specified, assumes seconds.
|
|
5
5
|
*/
|
|
6
6
|
export function parseDuration(input) {
|
|
@@ -11,7 +11,7 @@ export function parseDuration(input) {
|
|
|
11
11
|
}
|
|
12
12
|
// Parse duration with units
|
|
13
13
|
let totalMs = 0;
|
|
14
|
-
const regex = /(\d+)([
|
|
14
|
+
const regex = /(\d+)([smhd])/g;
|
|
15
15
|
let match;
|
|
16
16
|
let hasMatch = false;
|
|
17
17
|
while ((match = regex.exec(trimmed)) !== null) {
|
|
@@ -28,10 +28,13 @@ export function parseDuration(input) {
|
|
|
28
28
|
case "h":
|
|
29
29
|
totalMs += value * 60 * 60 * 1000;
|
|
30
30
|
break;
|
|
31
|
+
case "d":
|
|
32
|
+
totalMs += value * 24 * 60 * 60 * 1000;
|
|
33
|
+
break;
|
|
31
34
|
}
|
|
32
35
|
}
|
|
33
36
|
if (!hasMatch) {
|
|
34
|
-
throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m`);
|
|
37
|
+
throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m, 1d`);
|
|
35
38
|
}
|
|
36
39
|
return totalMs;
|
|
37
40
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperdrive.bot/paseo-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.43",
|
|
4
4
|
"description": "Paseo CLI - control your AI coding agents from the command line",
|
|
5
5
|
"bin": {
|
|
6
6
|
"paseo": "bin/paseo"
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@clack/prompts": "^1.0.0",
|
|
30
|
-
"@hyperdrive.bot/paseo-client": "0.3.
|
|
31
|
-
"@hyperdrive.bot/paseo-protocol": "0.3.
|
|
32
|
-
"@hyperdrive.bot/paseo-server": "0.3.
|
|
30
|
+
"@hyperdrive.bot/paseo-client": "0.3.43",
|
|
31
|
+
"@hyperdrive.bot/paseo-protocol": "0.3.43",
|
|
32
|
+
"@hyperdrive.bot/paseo-server": "0.3.43",
|
|
33
33
|
"chalk": "^5.3.0",
|
|
34
34
|
"commander": "^12.0.0",
|
|
35
35
|
"mime-types": "^2.1.35",
|