@bridge_gpt/mcp-server 0.1.16 → 0.2.0
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/README.md +333 -162
- package/build/agent-capabilities/cli.js +152 -0
- package/build/agent-capabilities/default-deps.js +45 -0
- package/build/agent-capabilities/probe-context.js +111 -0
- package/build/agent-capabilities/probes.js +278 -0
- package/build/agent-capabilities/reporter.js +50 -0
- package/build/agent-capabilities/runner.js +56 -0
- package/build/agent-capabilities/types.js +10 -0
- package/build/agent-launchers/claude.js +85 -0
- package/build/agent-launchers/index.js +17 -0
- package/build/agent-launchers/types.js +1 -0
- package/build/agents.generated.js +1 -1
- package/build/brainstorm-files.js +89 -0
- package/build/bridge-config.js +404 -0
- package/build/chain-orchestrator.js +1364 -0
- package/build/chain-utils.js +68 -0
- package/build/commands.generated.js +5 -3
- package/build/credential-materialization.js +128 -0
- package/build/credential-store.js +232 -0
- package/build/decision-page-schema.js +39 -6
- package/build/decision-page-template.js +54 -18
- package/build/doctor.js +18 -2
- package/build/fetch-stub.js +139 -0
- package/build/git-ignore-utils.js +63 -0
- package/build/index.js +1623 -546
- package/build/mcp-invoke.js +417 -0
- package/build/mcp-provisioning.js +249 -0
- package/build/mcp-registration-doctor.js +96 -0
- package/build/pipeline-orchestrator.js +66 -1
- package/build/pipeline-utils.js +33 -0
- package/build/pipelines.generated.js +165 -5
- package/build/schedule-run.js +951 -0
- package/build/schedule-store.js +132 -0
- package/build/scheduler-backends/at-fallback.js +144 -0
- package/build/scheduler-backends/escaping.js +113 -0
- package/build/scheduler-backends/index.js +72 -0
- package/build/scheduler-backends/launchd.js +216 -0
- package/build/scheduler-backends/systemd-user.js +237 -0
- package/build/scheduler-backends/task-scheduler.js +219 -0
- package/build/scheduler-backends/types.js +23 -0
- package/build/start-tickets-prereqs.js +90 -1
- package/build/start-tickets.js +222 -70
- package/build/third-party-mcp-targets.js +75 -0
- package/build/version.generated.js +1 -1
- package/package.json +8 -8
- package/pipelines/full-automation.json +49 -0
- package/pipelines/idea-to-ticket.json +71 -0
- package/pipelines/implement-ticket.json +28 -2
- package/smoke-test/SMOKE-TEST.md +511 -0
- package/smoke-test/smoke-test-mcp.md +23 -0
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `schedule-run` — local-only cross-platform scheduler subcommand for the
|
|
3
|
+
* packaged `@bridge_gpt/mcp-server` CLI (BAPI-327, Phase B of the Full Automation
|
|
4
|
+
* v1 epic BAPI-325).
|
|
5
|
+
*
|
|
6
|
+
* npx -y @bridge_gpt/mcp-server schedule-run <create|list|cancel|doctor> [flags]
|
|
7
|
+
*
|
|
8
|
+
* At a chosen time T it fires, on the user's machine:
|
|
9
|
+
* <claude> -p '/full-automation --scheduled-at <T> --idea-file <abs> [--auto]'
|
|
10
|
+
* via an OS-native one-shot unit (launchd / Task Scheduler / systemd-user / at).
|
|
11
|
+
*
|
|
12
|
+
* Strictly local: NO Bridge API HTTP calls, NO database/server-side state. All
|
|
13
|
+
* schedule state lives under `~/.bridge-gpt/schedules/`. The structure mirrors
|
|
14
|
+
* `start-tickets.ts`: a hand-rolled parser, an injectable command/runtime deps
|
|
15
|
+
* boundary, and discriminated parse/result types — no new runtime npm deps.
|
|
16
|
+
*/
|
|
17
|
+
import { execFile } from "node:child_process";
|
|
18
|
+
import { promises as fs } from "node:fs";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { pathApiForPlatform, selectSchedulerBackend, getSchedulerBackendByName, getSchedulerBackendsForPlatform, unsupportedSchedulerPlatformMessage, } from "./scheduler-backends/index.js";
|
|
21
|
+
import { ensureScheduleDirectories, getSchedulePaths, writeScheduleMetadata, readScheduleMetadata, listScheduleMetadata, deleteScheduleMetadata, } from "./schedule-store.js";
|
|
22
|
+
import { getAgentLauncher, formatValidAgentLauncherNames } from "./agent-launchers/index.js";
|
|
23
|
+
import { resolveCommandOnPath } from "./agent-launchers/claude.js";
|
|
24
|
+
/**
|
|
25
|
+
* Default deps backed by real subprocesses / process state. Subprocess execution
|
|
26
|
+
* uses `execFile` (list-based, never `shell: true`) and supports
|
|
27
|
+
* `RunCommandOptions.input` by writing to the child's stdin — required by the
|
|
28
|
+
* `at` backend, which pipes its heredoc script.
|
|
29
|
+
*/
|
|
30
|
+
export function createDefaultScheduleRunDeps() {
|
|
31
|
+
const runCommand = (file, args, options) => new Promise((resolve) => {
|
|
32
|
+
const child = execFile(file, args, {
|
|
33
|
+
cwd: options?.cwd,
|
|
34
|
+
env: options?.env ?? process.env,
|
|
35
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
36
|
+
encoding: "utf-8",
|
|
37
|
+
}, (error, stdout, stderr) => {
|
|
38
|
+
const exitCode = error && typeof error.code === "number"
|
|
39
|
+
? error.code
|
|
40
|
+
: error
|
|
41
|
+
? 1
|
|
42
|
+
: 0;
|
|
43
|
+
resolve({ stdout: stdout ?? "", stderr: stderr ?? "", exitCode });
|
|
44
|
+
});
|
|
45
|
+
if (options?.input !== undefined && child.stdin) {
|
|
46
|
+
child.stdin.write(options.input);
|
|
47
|
+
child.stdin.end();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
runCommand,
|
|
52
|
+
platform: process.platform,
|
|
53
|
+
env: process.env,
|
|
54
|
+
cwd: process.cwd(),
|
|
55
|
+
execPath: process.execPath,
|
|
56
|
+
homeDir: process.env.HOME ?? process.env.USERPROFILE ?? "",
|
|
57
|
+
now: () => Date.now(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** Return true only for a zero exit code. */
|
|
61
|
+
export function commandSucceeded(result) {
|
|
62
|
+
return result.exitCode === 0;
|
|
63
|
+
}
|
|
64
|
+
const VALID_BACKEND_NAMES = [
|
|
65
|
+
"launchd",
|
|
66
|
+
"task-scheduler",
|
|
67
|
+
"systemd-user",
|
|
68
|
+
"at-fallback",
|
|
69
|
+
];
|
|
70
|
+
/** Safe schedule id / unit-name pattern. */
|
|
71
|
+
const SCHEDULE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Usage
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
/** User-facing usage text for `schedule-run`. */
|
|
76
|
+
export function getScheduleRunUsage() {
|
|
77
|
+
return [
|
|
78
|
+
"Usage:",
|
|
79
|
+
" npx -y @bridge_gpt/mcp-server schedule-run <create|list|cancel|doctor> [flags]",
|
|
80
|
+
"",
|
|
81
|
+
"Schedules a local, one-shot Claude full-automation run at a chosen time using",
|
|
82
|
+
"an OS-native scheduler (launchd / Task Scheduler / systemd-user / at). Schedules",
|
|
83
|
+
"are stored locally under ~/.bridge-gpt/schedules/ and are never sent to a server.",
|
|
84
|
+
"",
|
|
85
|
+
"create:",
|
|
86
|
+
' schedule-run create --at "<datetime>" --idea-file <path> [flags]',
|
|
87
|
+
" schedule-run create --in <duration> --idea-file <path> [flags]",
|
|
88
|
+
"",
|
|
89
|
+
"create flags:",
|
|
90
|
+
' --at "<datetime>" ISO-8601 time to run (mutually exclusive with --in)',
|
|
91
|
+
" --in <duration> Run after a duration, e.g. 30m, 4h, 1d (mutually exclusive with --at)",
|
|
92
|
+
" --idea-file <path> REQUIRED absolute or relative path to the idea file",
|
|
93
|
+
" --agent claude Agent to launch (only 'claude' is supported in v1)",
|
|
94
|
+
" --repo-path <path> Working directory for the run (default: current directory)",
|
|
95
|
+
" --auto Forward --auto to /full-automation (default)",
|
|
96
|
+
" --no-auto Do not forward --auto",
|
|
97
|
+
" --dry-run Print the generated unit + invocation without creating anything",
|
|
98
|
+
" --id <id> Override the auto-generated schedule id (mainly for tests)",
|
|
99
|
+
"",
|
|
100
|
+
"list flags:",
|
|
101
|
+
" --id <id> Filter to a single schedule id",
|
|
102
|
+
" --agent <name> Filter by agent",
|
|
103
|
+
" --backend <name> Filter by backend (launchd|task-scheduler|systemd-user|at-fallback)",
|
|
104
|
+
" --json Emit JSON instead of a table",
|
|
105
|
+
"",
|
|
106
|
+
"cancel:",
|
|
107
|
+
" cancel <id> Cancel by positional id",
|
|
108
|
+
" cancel --id <id> Cancel by --id flag",
|
|
109
|
+
" --agent <name> Only cancel when the recorded agent matches",
|
|
110
|
+
" --backend <name> Only cancel when the recorded backend matches",
|
|
111
|
+
"",
|
|
112
|
+
"doctor:",
|
|
113
|
+
" Read-only diagnostics: platform support, backend candidate order, scheduler",
|
|
114
|
+
" availability, and whether claude/npx resolve on PATH. Creates nothing.",
|
|
115
|
+
"",
|
|
116
|
+
" -h, --help Show this help",
|
|
117
|
+
].join("\n");
|
|
118
|
+
}
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Argument parsing
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
/** Internal: a flag that expects a following value. */
|
|
123
|
+
function takeValue(argv, index, arg, flag) {
|
|
124
|
+
if (arg.startsWith(`${flag}=`)) {
|
|
125
|
+
return { value: arg.slice(`${flag}=`.length), nextIndex: index };
|
|
126
|
+
}
|
|
127
|
+
if (index + 1 >= argv.length) {
|
|
128
|
+
return { error: `${flag} requires a value.` };
|
|
129
|
+
}
|
|
130
|
+
return { value: argv[index + 1], nextIndex: index + 1 };
|
|
131
|
+
}
|
|
132
|
+
function matchesFlag(arg, flag) {
|
|
133
|
+
return arg === flag || arg.startsWith(`${flag}=`);
|
|
134
|
+
}
|
|
135
|
+
/** Parse `create` args. */
|
|
136
|
+
export function parseScheduleCreateArgs(argv) {
|
|
137
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
138
|
+
return { status: "help", usage: getScheduleRunUsage() };
|
|
139
|
+
}
|
|
140
|
+
let at;
|
|
141
|
+
let inDuration;
|
|
142
|
+
let ideaFile;
|
|
143
|
+
let agent = "claude";
|
|
144
|
+
let repoPath;
|
|
145
|
+
let id;
|
|
146
|
+
let sawAutoApprove = false;
|
|
147
|
+
let sawNoAutoApprove = false;
|
|
148
|
+
let dryRun = false;
|
|
149
|
+
for (let i = 0; i < argv.length; i++) {
|
|
150
|
+
const arg = argv[i];
|
|
151
|
+
if (matchesFlag(arg, "--at")) {
|
|
152
|
+
const r = takeValue(argv, i, arg, "--at");
|
|
153
|
+
if ("error" in r)
|
|
154
|
+
return { status: "error", message: r.error };
|
|
155
|
+
at = r.value;
|
|
156
|
+
i = r.nextIndex;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (matchesFlag(arg, "--in")) {
|
|
160
|
+
const r = takeValue(argv, i, arg, "--in");
|
|
161
|
+
if ("error" in r)
|
|
162
|
+
return { status: "error", message: r.error };
|
|
163
|
+
inDuration = r.value;
|
|
164
|
+
i = r.nextIndex;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (matchesFlag(arg, "--idea-file")) {
|
|
168
|
+
const r = takeValue(argv, i, arg, "--idea-file");
|
|
169
|
+
if ("error" in r)
|
|
170
|
+
return { status: "error", message: r.error };
|
|
171
|
+
ideaFile = r.value;
|
|
172
|
+
i = r.nextIndex;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (matchesFlag(arg, "--agent")) {
|
|
176
|
+
const r = takeValue(argv, i, arg, "--agent");
|
|
177
|
+
if ("error" in r)
|
|
178
|
+
return { status: "error", message: r.error };
|
|
179
|
+
if (!getAgentLauncher(r.value)) {
|
|
180
|
+
return {
|
|
181
|
+
status: "error",
|
|
182
|
+
message: `Invalid --agent value: '${r.value}'. Only ${formatValidAgentLauncherNames()} is supported in v1.`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
agent = r.value;
|
|
186
|
+
i = r.nextIndex;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (matchesFlag(arg, "--repo-path")) {
|
|
190
|
+
const r = takeValue(argv, i, arg, "--repo-path");
|
|
191
|
+
if ("error" in r)
|
|
192
|
+
return { status: "error", message: r.error };
|
|
193
|
+
repoPath = r.value;
|
|
194
|
+
i = r.nextIndex;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (matchesFlag(arg, "--id")) {
|
|
198
|
+
const r = takeValue(argv, i, arg, "--id");
|
|
199
|
+
if ("error" in r)
|
|
200
|
+
return { status: "error", message: r.error };
|
|
201
|
+
id = r.value;
|
|
202
|
+
i = r.nextIndex;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (arg === "--auto") {
|
|
206
|
+
sawAutoApprove = true;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (arg === "--no-auto") {
|
|
210
|
+
sawNoAutoApprove = true;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (arg === "--dry-run") {
|
|
214
|
+
dryRun = true;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (arg.startsWith("-")) {
|
|
218
|
+
return { status: "error", message: `Unknown flag: ${arg}` };
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
status: "error",
|
|
222
|
+
message: `Unexpected positional argument: '${arg}'. create takes only flags.`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
if (Boolean(at) === Boolean(inDuration)) {
|
|
226
|
+
return {
|
|
227
|
+
status: "error",
|
|
228
|
+
message: "create requires exactly one of --at or --in.",
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
if (!ideaFile) {
|
|
232
|
+
return { status: "error", message: "create requires --idea-file <path>." };
|
|
233
|
+
}
|
|
234
|
+
if (sawAutoApprove && sawNoAutoApprove) {
|
|
235
|
+
return {
|
|
236
|
+
status: "error",
|
|
237
|
+
message: "--auto and --no-auto are mutually exclusive.",
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (id !== undefined && !SCHEDULE_ID_PATTERN.test(id)) {
|
|
241
|
+
return {
|
|
242
|
+
status: "error",
|
|
243
|
+
message: `Invalid --id value: '${id}'. Ids must match ${SCHEDULE_ID_PATTERN.source} ` +
|
|
244
|
+
"(start alphanumeric; letters, digits, '_' and '-'; max 64 chars).",
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
status: "ok",
|
|
249
|
+
subcommand: "create",
|
|
250
|
+
options: {
|
|
251
|
+
at,
|
|
252
|
+
in: inDuration,
|
|
253
|
+
ideaFile,
|
|
254
|
+
agent,
|
|
255
|
+
repoPath,
|
|
256
|
+
autoApprove: !sawNoAutoApprove,
|
|
257
|
+
dryRun,
|
|
258
|
+
id,
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/** Parse `list` args. */
|
|
263
|
+
export function parseScheduleListArgs(argv) {
|
|
264
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
265
|
+
return { status: "help", usage: getScheduleRunUsage() };
|
|
266
|
+
}
|
|
267
|
+
let id;
|
|
268
|
+
let agent;
|
|
269
|
+
let backend;
|
|
270
|
+
let json = false;
|
|
271
|
+
for (let i = 0; i < argv.length; i++) {
|
|
272
|
+
const arg = argv[i];
|
|
273
|
+
if (matchesFlag(arg, "--id")) {
|
|
274
|
+
const r = takeValue(argv, i, arg, "--id");
|
|
275
|
+
if ("error" in r)
|
|
276
|
+
return { status: "error", message: r.error };
|
|
277
|
+
id = r.value;
|
|
278
|
+
i = r.nextIndex;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (matchesFlag(arg, "--agent")) {
|
|
282
|
+
const r = takeValue(argv, i, arg, "--agent");
|
|
283
|
+
if ("error" in r)
|
|
284
|
+
return { status: "error", message: r.error };
|
|
285
|
+
if (!getAgentLauncher(r.value)) {
|
|
286
|
+
return {
|
|
287
|
+
status: "error",
|
|
288
|
+
message: `Invalid --agent filter: '${r.value}'. Valid: ${formatValidAgentLauncherNames()}.`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
agent = r.value;
|
|
292
|
+
i = r.nextIndex;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (matchesFlag(arg, "--backend")) {
|
|
296
|
+
const r = takeValue(argv, i, arg, "--backend");
|
|
297
|
+
if ("error" in r)
|
|
298
|
+
return { status: "error", message: r.error };
|
|
299
|
+
if (!VALID_BACKEND_NAMES.includes(r.value)) {
|
|
300
|
+
return {
|
|
301
|
+
status: "error",
|
|
302
|
+
message: `Invalid --backend filter: '${r.value}'. Valid: ${VALID_BACKEND_NAMES.join(", ")}.`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
backend = r.value;
|
|
306
|
+
i = r.nextIndex;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (arg === "--json") {
|
|
310
|
+
json = true;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (arg.startsWith("-")) {
|
|
314
|
+
return { status: "error", message: `Unknown flag: ${arg}` };
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
status: "error",
|
|
318
|
+
message: `Unexpected positional argument: '${arg}'. list takes only flags.`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
return { status: "ok", subcommand: "list", options: { id, agent, backend, json } };
|
|
322
|
+
}
|
|
323
|
+
/** Parse `cancel` args (one id via positional or --id, never both/multiple). */
|
|
324
|
+
export function parseScheduleCancelArgs(argv) {
|
|
325
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
326
|
+
return { status: "help", usage: getScheduleRunUsage() };
|
|
327
|
+
}
|
|
328
|
+
let positionalId;
|
|
329
|
+
let flagId;
|
|
330
|
+
let agent;
|
|
331
|
+
let backend;
|
|
332
|
+
for (let i = 0; i < argv.length; i++) {
|
|
333
|
+
const arg = argv[i];
|
|
334
|
+
if (matchesFlag(arg, "--id")) {
|
|
335
|
+
const r = takeValue(argv, i, arg, "--id");
|
|
336
|
+
if ("error" in r)
|
|
337
|
+
return { status: "error", message: r.error };
|
|
338
|
+
if (flagId !== undefined) {
|
|
339
|
+
return { status: "error", message: "cancel accepts only one --id." };
|
|
340
|
+
}
|
|
341
|
+
flagId = r.value;
|
|
342
|
+
i = r.nextIndex;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (matchesFlag(arg, "--agent")) {
|
|
346
|
+
const r = takeValue(argv, i, arg, "--agent");
|
|
347
|
+
if ("error" in r)
|
|
348
|
+
return { status: "error", message: r.error };
|
|
349
|
+
agent = r.value;
|
|
350
|
+
i = r.nextIndex;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (matchesFlag(arg, "--backend")) {
|
|
354
|
+
const r = takeValue(argv, i, arg, "--backend");
|
|
355
|
+
if ("error" in r)
|
|
356
|
+
return { status: "error", message: r.error };
|
|
357
|
+
if (!VALID_BACKEND_NAMES.includes(r.value)) {
|
|
358
|
+
return {
|
|
359
|
+
status: "error",
|
|
360
|
+
message: `Invalid --backend filter: '${r.value}'. Valid: ${VALID_BACKEND_NAMES.join(", ")}.`,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
backend = r.value;
|
|
364
|
+
i = r.nextIndex;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (arg.startsWith("-")) {
|
|
368
|
+
return { status: "error", message: `Unknown flag: ${arg}` };
|
|
369
|
+
}
|
|
370
|
+
if (positionalId !== undefined) {
|
|
371
|
+
return { status: "error", message: "cancel accepts only one schedule id." };
|
|
372
|
+
}
|
|
373
|
+
positionalId = arg;
|
|
374
|
+
}
|
|
375
|
+
if (positionalId !== undefined && flagId !== undefined) {
|
|
376
|
+
return {
|
|
377
|
+
status: "error",
|
|
378
|
+
message: "cancel accepts a positional id OR --id, not both.",
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
const id = positionalId ?? flagId;
|
|
382
|
+
if (!id) {
|
|
383
|
+
return { status: "error", message: "cancel requires a schedule id (positional or --id)." };
|
|
384
|
+
}
|
|
385
|
+
return { status: "ok", subcommand: "cancel", options: { id, agent, backend } };
|
|
386
|
+
}
|
|
387
|
+
/** Parse `doctor` args (read-only; only --json / help). */
|
|
388
|
+
export function parseScheduleDoctorArgs(argv) {
|
|
389
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
390
|
+
return { status: "help", usage: getScheduleRunUsage() };
|
|
391
|
+
}
|
|
392
|
+
let json = false;
|
|
393
|
+
for (const arg of argv) {
|
|
394
|
+
if (arg === "--json") {
|
|
395
|
+
json = true;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (arg.startsWith("-")) {
|
|
399
|
+
return { status: "error", message: `Unknown flag: ${arg}` };
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
status: "error",
|
|
403
|
+
message: `Unexpected positional argument: '${arg}'. doctor takes no positional arguments.`,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
return { status: "ok", subcommand: "doctor", options: { json } };
|
|
407
|
+
}
|
|
408
|
+
/** Top-level parser: honor help first, require a valid subcommand, dispatch. */
|
|
409
|
+
export function parseScheduleRunArgs(argv) {
|
|
410
|
+
// Help before any other validation, even with later unknown flags.
|
|
411
|
+
if (argv.length === 0) {
|
|
412
|
+
return {
|
|
413
|
+
status: "error",
|
|
414
|
+
message: "Missing subcommand. Expected one of: create, list, cancel, doctor.",
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
const [subcommand, ...rest] = argv;
|
|
418
|
+
if (subcommand === "-h" || subcommand === "--help") {
|
|
419
|
+
return { status: "help", usage: getScheduleRunUsage() };
|
|
420
|
+
}
|
|
421
|
+
switch (subcommand) {
|
|
422
|
+
case "create":
|
|
423
|
+
return parseScheduleCreateArgs(rest);
|
|
424
|
+
case "list":
|
|
425
|
+
return parseScheduleListArgs(rest);
|
|
426
|
+
case "cancel":
|
|
427
|
+
return parseScheduleCancelArgs(rest);
|
|
428
|
+
case "doctor":
|
|
429
|
+
return parseScheduleDoctorArgs(rest);
|
|
430
|
+
default:
|
|
431
|
+
return {
|
|
432
|
+
status: "error",
|
|
433
|
+
message: `Unknown subcommand '${subcommand}'. Expected one of: create, list, cancel, doctor.`,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/** Parse `--at` as ISO-8601 and normalize to `Date.toISOString()`. */
|
|
438
|
+
export function parseRunAtIso(value) {
|
|
439
|
+
const date = new Date(value);
|
|
440
|
+
if (Number.isNaN(date.getTime())) {
|
|
441
|
+
return { ok: false, error: `Invalid --at datetime: '${value}'. Expected an ISO-8601 timestamp.` };
|
|
442
|
+
}
|
|
443
|
+
return { ok: true, iso: date.toISOString() };
|
|
444
|
+
}
|
|
445
|
+
/** Parse a positive `<n><m|h|d>` duration relative to the injected clock. */
|
|
446
|
+
export function parseDurationFromNow(value, nowMs) {
|
|
447
|
+
const match = value.match(/^(\d+)([mhd])$/);
|
|
448
|
+
if (!match) {
|
|
449
|
+
return {
|
|
450
|
+
ok: false,
|
|
451
|
+
error: `Invalid --in duration: '${value}'. Use a positive number followed by m, h, or d (e.g. 30m, 4h, 1d).`,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
const amount = Number(match[1]);
|
|
455
|
+
if (amount <= 0) {
|
|
456
|
+
return { ok: false, error: `Invalid --in duration: '${value}'. Duration must be positive.` };
|
|
457
|
+
}
|
|
458
|
+
const unitMs = match[2] === "m" ? 60_000 : match[2] === "h" ? 3_600_000 : 86_400_000;
|
|
459
|
+
return { ok: true, iso: new Date(nowMs + amount * unitMs).toISOString() };
|
|
460
|
+
}
|
|
461
|
+
/** Resolve an absolute path against the platform path API and deps.cwd. */
|
|
462
|
+
function resolveAbsolute(p, deps) {
|
|
463
|
+
const pathApi = pathApiForPlatform(deps.platform);
|
|
464
|
+
return pathApi.isAbsolute(p) ? pathApi.normalize(p) : pathApi.resolve(deps.cwd, p);
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Resolve and validate create inputs, baking the PATH-trap values. Validates
|
|
468
|
+
* that the idea file exists and is a file and the repo path exists and is a
|
|
469
|
+
* directory BEFORE attempting binary resolution. Captures `env_path` exactly
|
|
470
|
+
* from the injected env (`PATH` then `Path`); resolves node/npx/claude against
|
|
471
|
+
* that baked PATH; builds the locked invocation (never with `--cwd`).
|
|
472
|
+
*/
|
|
473
|
+
export async function resolveScheduleCreateInput(options, runAtIso, deps) {
|
|
474
|
+
const ideaFile = resolveAbsolute(options.ideaFile, deps);
|
|
475
|
+
const repoPath = resolveAbsolute(options.repoPath ?? deps.cwd, deps);
|
|
476
|
+
try {
|
|
477
|
+
const ideaStat = await fs.stat(ideaFile);
|
|
478
|
+
if (!ideaStat.isFile()) {
|
|
479
|
+
return { ok: false, error: `--idea-file is not a file: ${ideaFile}` };
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
return { ok: false, error: `--idea-file does not exist: ${ideaFile}` };
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
const repoStat = await fs.stat(repoPath);
|
|
487
|
+
if (!repoStat.isDirectory()) {
|
|
488
|
+
return { ok: false, error: `--repo-path is not a directory: ${repoPath}` };
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return { ok: false, error: `--repo-path does not exist: ${repoPath}` };
|
|
493
|
+
}
|
|
494
|
+
const envPath = deps.env.PATH ?? deps.env.Path ?? "";
|
|
495
|
+
const nodePath = deps.execPath;
|
|
496
|
+
const launcher = getAgentLauncher(options.agent);
|
|
497
|
+
if (!launcher) {
|
|
498
|
+
return {
|
|
499
|
+
ok: false,
|
|
500
|
+
error: `Unsupported agent: '${options.agent}'. Valid: ${formatValidAgentLauncherNames()}.`,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
const claudePath = await launcher.resolveBinary(envPath, deps);
|
|
504
|
+
if (!claudePath) {
|
|
505
|
+
return {
|
|
506
|
+
ok: false,
|
|
507
|
+
error: `Could not resolve the '${launcher.capability.command}' binary on the baked PATH.`,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
// npx is resolved best-effort (v1 invokes claude directly); empty when absent.
|
|
511
|
+
const npxPath = (await resolveCommandOnPath("npx", envPath, deps)) ?? "";
|
|
512
|
+
const invocation = launcher.buildInvocation(claudePath, {
|
|
513
|
+
runAtIso,
|
|
514
|
+
ideaFile,
|
|
515
|
+
autoApprove: options.autoApprove,
|
|
516
|
+
});
|
|
517
|
+
const id = options.id ?? randomUUID();
|
|
518
|
+
const paths = getSchedulePaths(id, deps.homeDir, deps.platform);
|
|
519
|
+
return {
|
|
520
|
+
ok: true,
|
|
521
|
+
resolved: {
|
|
522
|
+
id,
|
|
523
|
+
runAtIso,
|
|
524
|
+
agent: options.agent,
|
|
525
|
+
ideaFile,
|
|
526
|
+
repoPath,
|
|
527
|
+
envPath,
|
|
528
|
+
nodePath,
|
|
529
|
+
npxPath,
|
|
530
|
+
claudePath,
|
|
531
|
+
invocation,
|
|
532
|
+
paths: {
|
|
533
|
+
schedulesDir: paths.schedulesDir,
|
|
534
|
+
logsDir: paths.logsDir,
|
|
535
|
+
stdoutPath: paths.stdoutPath,
|
|
536
|
+
stderrPath: paths.stderrPath,
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
/** Build the local-only schedule metadata from resolved inputs + backend result. */
|
|
542
|
+
export function buildScheduleMetadata(resolved, createResult, createdAtIso) {
|
|
543
|
+
return {
|
|
544
|
+
id: resolved.id,
|
|
545
|
+
run_at_iso: resolved.runAtIso,
|
|
546
|
+
backend: createResult.backend,
|
|
547
|
+
unit_path: createResult.unitPath,
|
|
548
|
+
unit_paths: createResult.unitPaths,
|
|
549
|
+
backend_job_id: createResult.backendJobId,
|
|
550
|
+
agent: resolved.agent,
|
|
551
|
+
idea_file: resolved.ideaFile,
|
|
552
|
+
repo_path: resolved.repoPath,
|
|
553
|
+
created_at: createdAtIso,
|
|
554
|
+
env_path: resolved.envPath,
|
|
555
|
+
node_path: resolved.nodePath,
|
|
556
|
+
npx_path: resolved.npxPath,
|
|
557
|
+
claude_path: resolved.claudePath,
|
|
558
|
+
invocation: resolved.invocation,
|
|
559
|
+
logs: { stdout: resolved.paths.stdoutPath, stderr: resolved.paths.stderrPath },
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
/** Compute the run time (exactly one of --at / --in already validated). */
|
|
563
|
+
function resolveRunAtIso(options, deps) {
|
|
564
|
+
if (options.at !== undefined) {
|
|
565
|
+
return parseRunAtIso(options.at);
|
|
566
|
+
}
|
|
567
|
+
const nowMs = deps.now ? deps.now() : Date.now();
|
|
568
|
+
return parseDurationFromNow(options.in, nowMs);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Orchestrate a create. Dry-run selects the primary backend and returns
|
|
572
|
+
* artifacts WITHOUT creating directories or writing metadata. Real create:
|
|
573
|
+
* resolve → ensure directories → backend.create → write metadata only after
|
|
574
|
+
* backend success; if metadata persistence fails after a native unit/job was
|
|
575
|
+
* created, attempt a best-effort backend cancel before returning a failure.
|
|
576
|
+
*/
|
|
577
|
+
export async function orchestrateScheduleCreate(options, deps) {
|
|
578
|
+
const runAt = resolveRunAtIso(options, deps);
|
|
579
|
+
if (!runAt.ok)
|
|
580
|
+
return { ok: false, error: runAt.error };
|
|
581
|
+
const resolvedResult = await resolveScheduleCreateInput(options, runAt.iso, deps);
|
|
582
|
+
if (!resolvedResult.ok)
|
|
583
|
+
return { ok: false, error: resolvedResult.error };
|
|
584
|
+
const resolved = resolvedResult.resolved;
|
|
585
|
+
const createdAtIso = new Date(deps.now ? deps.now() : Date.now()).toISOString();
|
|
586
|
+
const metadataPath = getSchedulePaths(resolved.id, deps.homeDir, deps.platform).metadataPath;
|
|
587
|
+
// For a real create, reject a colliding/unreadable existing schedule BEFORE
|
|
588
|
+
// probing scheduler availability or creating anything (fail fast, no side
|
|
589
|
+
// effects). Without this, `schtasks /Create /F` would replace the prior task
|
|
590
|
+
// and writeScheduleMetadata would overwrite the prior <id>.json, losing it.
|
|
591
|
+
// A missing file reads as null (proceed); a present-but-unreadable file
|
|
592
|
+
// (malformed JSON, EACCES, …) is surfaced as a controlled error rather than
|
|
593
|
+
// being swallowed and silently overwritten — mirroring how
|
|
594
|
+
// orchestrateScheduleList surfaces malformed metadata instead of ignoring it.
|
|
595
|
+
if (!options.dryRun) {
|
|
596
|
+
let existing;
|
|
597
|
+
try {
|
|
598
|
+
existing = await readScheduleMetadata(resolved.id, deps.homeDir, deps.platform);
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
602
|
+
return {
|
|
603
|
+
ok: false,
|
|
604
|
+
error: `Could not read existing schedule metadata for id '${resolved.id}' at ${metadataPath}: ${msg}. ` +
|
|
605
|
+
"Resolve or remove it before creating.",
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
if (existing) {
|
|
609
|
+
return {
|
|
610
|
+
ok: false,
|
|
611
|
+
error: `A schedule with id '${resolved.id}' already exists (${metadataPath}). ` +
|
|
612
|
+
"Cancel it first (schedule-run cancel <id>) or choose a different --id.",
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const selection = await selectSchedulerBackend(deps, options.dryRun);
|
|
617
|
+
if (!selection.ok)
|
|
618
|
+
return { ok: false, error: selection.error };
|
|
619
|
+
const backend = selection.backend;
|
|
620
|
+
const createInput = {
|
|
621
|
+
deps,
|
|
622
|
+
id: resolved.id,
|
|
623
|
+
runAtIso: resolved.runAtIso,
|
|
624
|
+
invocation: resolved.invocation,
|
|
625
|
+
repoPath: resolved.repoPath,
|
|
626
|
+
ideaFile: resolved.ideaFile,
|
|
627
|
+
envPath: resolved.envPath,
|
|
628
|
+
nodePath: resolved.nodePath,
|
|
629
|
+
npxPath: resolved.npxPath,
|
|
630
|
+
claudePath: resolved.claudePath,
|
|
631
|
+
paths: resolved.paths,
|
|
632
|
+
dryRun: options.dryRun,
|
|
633
|
+
};
|
|
634
|
+
if (options.dryRun) {
|
|
635
|
+
const createResult = await backend.create(createInput);
|
|
636
|
+
if (!createResult.ok) {
|
|
637
|
+
return { ok: false, error: createResult.error ?? "Backend dry-run failed." };
|
|
638
|
+
}
|
|
639
|
+
const metadata = buildScheduleMetadata(resolved, createResult, createdAtIso);
|
|
640
|
+
return { ok: true, dryRun: true, metadata, metadataPath, createResult };
|
|
641
|
+
}
|
|
642
|
+
await ensureScheduleDirectories(deps.homeDir, deps.platform);
|
|
643
|
+
const createResult = await backend.create(createInput);
|
|
644
|
+
if (!createResult.ok) {
|
|
645
|
+
return { ok: false, error: createResult.error ?? "Scheduler backend create failed." };
|
|
646
|
+
}
|
|
647
|
+
const metadata = buildScheduleMetadata(resolved, createResult, createdAtIso);
|
|
648
|
+
try {
|
|
649
|
+
await writeScheduleMetadata(metadata, deps.homeDir, deps.platform);
|
|
650
|
+
}
|
|
651
|
+
catch (error) {
|
|
652
|
+
// Native unit/job exists but we can't persist metadata — roll back the unit.
|
|
653
|
+
await backend.cancel({ deps, metadata }).catch(() => undefined);
|
|
654
|
+
return {
|
|
655
|
+
ok: false,
|
|
656
|
+
error: `Created the native ${createResult.backend} unit but failed to persist schedule metadata ` +
|
|
657
|
+
`(${error instanceof Error ? error.message : String(error)}); attempted to cancel the native unit.`,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
return { ok: true, dryRun: false, metadata, metadataPath, createResult };
|
|
661
|
+
}
|
|
662
|
+
/** Format a create result for display (dry-run prints artifacts verbatim). */
|
|
663
|
+
export function formatScheduleCreateResult(result) {
|
|
664
|
+
if (!result.ok)
|
|
665
|
+
return `Error: ${result.error}`;
|
|
666
|
+
const m = result.metadata;
|
|
667
|
+
const lines = [];
|
|
668
|
+
lines.push(result.dryRun ? "[dry-run] schedule-run create preview" : "Schedule created.");
|
|
669
|
+
lines.push(` id: ${m.id}`);
|
|
670
|
+
lines.push(` backend: ${m.backend}`);
|
|
671
|
+
lines.push(` run at: ${m.run_at_iso}`);
|
|
672
|
+
lines.push(` agent: ${m.agent}`);
|
|
673
|
+
lines.push(` metadata: ${result.metadataPath}${result.dryRun ? " (not written in dry-run)" : ""}`);
|
|
674
|
+
lines.push(` repo path: ${m.repo_path}`);
|
|
675
|
+
lines.push(` idea file: ${m.idea_file}`);
|
|
676
|
+
lines.push(` stdout log: ${m.logs.stdout}`);
|
|
677
|
+
lines.push(` stderr log: ${m.logs.stderr}`);
|
|
678
|
+
lines.push(` baked PATH: ${m.env_path}`);
|
|
679
|
+
lines.push(` node: ${m.node_path}`);
|
|
680
|
+
lines.push(` npx: ${m.npx_path}`);
|
|
681
|
+
lines.push(` claude: ${m.claude_path}`);
|
|
682
|
+
lines.push(` invocation: ${[m.invocation.exe, ...m.invocation.args].join(" ")}`);
|
|
683
|
+
if (m.unit_paths.length > 0) {
|
|
684
|
+
lines.push(` unit paths: ${m.unit_paths.join(", ")}`);
|
|
685
|
+
}
|
|
686
|
+
if (result.dryRun) {
|
|
687
|
+
for (const artifact of result.createResult.artifacts) {
|
|
688
|
+
lines.push("");
|
|
689
|
+
lines.push(`----- ${artifact.kind}: ${artifact.path} -----`);
|
|
690
|
+
lines.push(artifact.content);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return lines.join("\n");
|
|
694
|
+
}
|
|
695
|
+
/** Apply id/agent/backend filters consistently. */
|
|
696
|
+
export function filterScheduleMetadata(rows, filters) {
|
|
697
|
+
return rows.filter((row) => {
|
|
698
|
+
if (filters.id !== undefined && row.id !== filters.id)
|
|
699
|
+
return false;
|
|
700
|
+
if (filters.agent !== undefined && row.agent !== filters.agent)
|
|
701
|
+
return false;
|
|
702
|
+
if (filters.backend !== undefined && row.backend !== filters.backend)
|
|
703
|
+
return false;
|
|
704
|
+
return true;
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
/** Read local metadata, reconcile each backend group with native state. */
|
|
708
|
+
export async function orchestrateScheduleList(options, deps) {
|
|
709
|
+
const rows = await listScheduleMetadata(deps.homeDir, deps.platform);
|
|
710
|
+
const warnings = [];
|
|
711
|
+
const valid = [];
|
|
712
|
+
for (const row of rows) {
|
|
713
|
+
if (row.ok) {
|
|
714
|
+
valid.push(row.metadata);
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
warnings.push(`Skipping malformed schedule metadata ${row.path}: ${row.error}`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const filtered = filterScheduleMetadata(valid, {
|
|
721
|
+
id: options.id,
|
|
722
|
+
agent: options.agent,
|
|
723
|
+
backend: options.backend,
|
|
724
|
+
});
|
|
725
|
+
// Group by backend so each backend's list() is called once with its rows.
|
|
726
|
+
const byBackend = new Map();
|
|
727
|
+
for (const row of filtered) {
|
|
728
|
+
const group = byBackend.get(row.backend) ?? [];
|
|
729
|
+
group.push(row);
|
|
730
|
+
byBackend.set(row.backend, group);
|
|
731
|
+
}
|
|
732
|
+
const entries = [];
|
|
733
|
+
for (const [backendName, recorded] of byBackend) {
|
|
734
|
+
const backend = getSchedulerBackendByName(backendName);
|
|
735
|
+
if (!backend) {
|
|
736
|
+
for (const metadata of recorded) {
|
|
737
|
+
entries.push({ metadata, status: "backend-unavailable", detail: "unknown backend" });
|
|
738
|
+
}
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
const groupEntries = await backend.list({ deps, recorded });
|
|
742
|
+
entries.push(...groupEntries);
|
|
743
|
+
}
|
|
744
|
+
// Stable order by id for deterministic output.
|
|
745
|
+
entries.sort((a, b) => a.metadata.id.localeCompare(b.metadata.id));
|
|
746
|
+
return { entries, warnings };
|
|
747
|
+
}
|
|
748
|
+
/** Format a list report as a table (or JSON when requested). */
|
|
749
|
+
export function formatScheduleListResult(report, json) {
|
|
750
|
+
if (json) {
|
|
751
|
+
// Wrap as { entries, warnings } so malformed-metadata warnings are not lost
|
|
752
|
+
// for consumers piping `schedule-run list --json` into tooling.
|
|
753
|
+
return JSON.stringify({
|
|
754
|
+
entries: report.entries.map((e) => ({
|
|
755
|
+
id: e.metadata.id,
|
|
756
|
+
run_at: e.metadata.run_at_iso,
|
|
757
|
+
backend: e.metadata.backend,
|
|
758
|
+
agent: e.metadata.agent,
|
|
759
|
+
status: e.status,
|
|
760
|
+
unit_path: e.metadata.unit_path,
|
|
761
|
+
})),
|
|
762
|
+
warnings: report.warnings,
|
|
763
|
+
}, null, 2);
|
|
764
|
+
}
|
|
765
|
+
const lines = [];
|
|
766
|
+
for (const w of report.warnings)
|
|
767
|
+
lines.push(`Warning: ${w}`);
|
|
768
|
+
if (report.entries.length === 0) {
|
|
769
|
+
lines.push("No schedules found.");
|
|
770
|
+
return lines.join("\n");
|
|
771
|
+
}
|
|
772
|
+
lines.push(["ID", "RUN_AT", "BACKEND", "AGENT", "STATUS", "UNIT_PATH"].join(" "));
|
|
773
|
+
for (const e of report.entries) {
|
|
774
|
+
lines.push([
|
|
775
|
+
e.metadata.id,
|
|
776
|
+
e.metadata.run_at_iso,
|
|
777
|
+
e.metadata.backend,
|
|
778
|
+
e.metadata.agent,
|
|
779
|
+
e.status,
|
|
780
|
+
e.metadata.unit_path ?? "-",
|
|
781
|
+
].join(" "));
|
|
782
|
+
}
|
|
783
|
+
return lines.join("\n");
|
|
784
|
+
}
|
|
785
|
+
/** Cancel a schedule by id (with optional agent/backend filters). */
|
|
786
|
+
export async function orchestrateScheduleCancel(options, deps) {
|
|
787
|
+
const metadata = await readScheduleMetadata(options.id, deps.homeDir, deps.platform);
|
|
788
|
+
if (!metadata) {
|
|
789
|
+
return { ok: false, notFound: true, error: `No schedule found with id '${options.id}'.` };
|
|
790
|
+
}
|
|
791
|
+
if (options.agent !== undefined && metadata.agent !== options.agent) {
|
|
792
|
+
return {
|
|
793
|
+
ok: false,
|
|
794
|
+
notFound: true,
|
|
795
|
+
error: `Schedule '${options.id}' does not match agent filter '${options.agent}'.`,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
if (options.backend !== undefined && metadata.backend !== options.backend) {
|
|
799
|
+
return {
|
|
800
|
+
ok: false,
|
|
801
|
+
notFound: true,
|
|
802
|
+
error: `Schedule '${options.id}' does not match backend filter '${options.backend}'.`,
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
const backend = getSchedulerBackendByName(metadata.backend);
|
|
806
|
+
if (!backend) {
|
|
807
|
+
return { ok: false, error: `Unknown backend '${metadata.backend}' recorded for '${options.id}'.` };
|
|
808
|
+
}
|
|
809
|
+
const cancelResult = await backend.cancel({ deps, metadata });
|
|
810
|
+
if (!cancelResult.ok) {
|
|
811
|
+
return { ok: false, error: cancelResult.error ?? "Backend cancel failed." };
|
|
812
|
+
}
|
|
813
|
+
// Stale or removed both delete local metadata; logs are preserved by the store.
|
|
814
|
+
await deleteScheduleMetadata(options.id, deps.homeDir, deps.platform);
|
|
815
|
+
return {
|
|
816
|
+
ok: true,
|
|
817
|
+
id: options.id,
|
|
818
|
+
backend: metadata.backend,
|
|
819
|
+
nativeRemoved: cancelResult.nativeRemoved,
|
|
820
|
+
stale: cancelResult.stale,
|
|
821
|
+
metadataRemoved: true,
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
/** Format a cancel result. */
|
|
825
|
+
export function formatScheduleCancelResult(result) {
|
|
826
|
+
if (!result.ok)
|
|
827
|
+
return `Error: ${result.error}`;
|
|
828
|
+
return [
|
|
829
|
+
`Schedule '${result.id}' canceled.`,
|
|
830
|
+
` backend: ${result.backend}`,
|
|
831
|
+
` native removed: ${result.nativeRemoved ? "yes" : `no${result.stale ? " (stale)" : ""}`}`,
|
|
832
|
+
` metadata removed: ${result.metadataRemoved ? "yes" : "no"}`,
|
|
833
|
+
" logs: preserved",
|
|
834
|
+
].join("\n");
|
|
835
|
+
}
|
|
836
|
+
/** Read-only diagnostics: platform support, backend order/availability, binaries. */
|
|
837
|
+
export async function orchestrateScheduleDoctor(deps) {
|
|
838
|
+
const platformResult = getSchedulerBackendsForPlatform(deps.platform);
|
|
839
|
+
const envPath = deps.env.PATH ?? deps.env.Path ?? "";
|
|
840
|
+
const claudeResolved = Boolean(await resolveCommandOnPath("claude", envPath, deps));
|
|
841
|
+
const npxResolved = Boolean(await resolveCommandOnPath("npx", envPath, deps));
|
|
842
|
+
if (!platformResult.ok) {
|
|
843
|
+
return {
|
|
844
|
+
platform: deps.platform,
|
|
845
|
+
platformSupported: false,
|
|
846
|
+
candidateBackends: [],
|
|
847
|
+
backendAvailability: [],
|
|
848
|
+
claudeResolved,
|
|
849
|
+
npxResolved,
|
|
850
|
+
unsupportedMessage: platformResult.error,
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
const candidateBackends = platformResult.backends.map((b) => b.name);
|
|
854
|
+
const backendAvailability = [];
|
|
855
|
+
for (const backend of platformResult.backends) {
|
|
856
|
+
backendAvailability.push({ backend: backend.name, available: await backend.isAvailable(deps) });
|
|
857
|
+
}
|
|
858
|
+
return {
|
|
859
|
+
platform: deps.platform,
|
|
860
|
+
platformSupported: true,
|
|
861
|
+
candidateBackends,
|
|
862
|
+
backendAvailability,
|
|
863
|
+
claudeResolved,
|
|
864
|
+
npxResolved,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
/** Format the doctor report (table or JSON). */
|
|
868
|
+
export function formatScheduleDoctorReport(report, json) {
|
|
869
|
+
if (json)
|
|
870
|
+
return JSON.stringify(report, null, 2);
|
|
871
|
+
const lines = [
|
|
872
|
+
"schedule-run doctor (read-only diagnostics)",
|
|
873
|
+
`Platform: ${report.platform}`,
|
|
874
|
+
];
|
|
875
|
+
if (!report.platformSupported) {
|
|
876
|
+
lines.push(report.unsupportedMessage ?? unsupportedSchedulerPlatformMessage(report.platform));
|
|
877
|
+
}
|
|
878
|
+
else {
|
|
879
|
+
lines.push(`Candidate backends (in order): ${report.candidateBackends.join(", ")}`);
|
|
880
|
+
for (const a of report.backendAvailability) {
|
|
881
|
+
lines.push(` ${a.available ? "AVAILABLE " : "UNAVAILABLE"} ${a.backend}`);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
lines.push(`claude on PATH: ${report.claudeResolved ? "yes" : "no"}`);
|
|
885
|
+
lines.push(`npx on PATH: ${report.npxResolved ? "yes" : "no"}`);
|
|
886
|
+
return lines.join("\n");
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* CLI entry for `schedule-run`. Returns a numeric process exit code. Help → 0;
|
|
890
|
+
* parser/validation errors → 1 (printed with usage to stderr); create/list/
|
|
891
|
+
* cancel/doctor dispatch otherwise. Unexpected errors are caught at this
|
|
892
|
+
* boundary, logged internally to stderr, and surfaced to the user sanitized.
|
|
893
|
+
*/
|
|
894
|
+
export async function runScheduleRunCli(argv, overrides = {}) {
|
|
895
|
+
const log = overrides.log ?? ((m) => console.log(m));
|
|
896
|
+
const errorLog = overrides.errorLog ?? ((m) => console.error(m));
|
|
897
|
+
const parsed = parseScheduleRunArgs(argv);
|
|
898
|
+
if (parsed.status === "help") {
|
|
899
|
+
log(parsed.usage);
|
|
900
|
+
return 0;
|
|
901
|
+
}
|
|
902
|
+
if (parsed.status === "error") {
|
|
903
|
+
errorLog(`Error: ${parsed.message}`);
|
|
904
|
+
errorLog("");
|
|
905
|
+
errorLog(getScheduleRunUsage());
|
|
906
|
+
return 1;
|
|
907
|
+
}
|
|
908
|
+
const deps = overrides.deps ?? createDefaultScheduleRunDeps();
|
|
909
|
+
try {
|
|
910
|
+
switch (parsed.subcommand) {
|
|
911
|
+
case "create": {
|
|
912
|
+
const result = await orchestrateScheduleCreate(parsed.options, deps);
|
|
913
|
+
if (!result.ok) {
|
|
914
|
+
errorLog(formatScheduleCreateResult(result));
|
|
915
|
+
return 1;
|
|
916
|
+
}
|
|
917
|
+
log(formatScheduleCreateResult(result));
|
|
918
|
+
return 0;
|
|
919
|
+
}
|
|
920
|
+
case "list": {
|
|
921
|
+
const report = await orchestrateScheduleList(parsed.options, deps);
|
|
922
|
+
log(formatScheduleListResult(report, parsed.options.json));
|
|
923
|
+
return 0;
|
|
924
|
+
}
|
|
925
|
+
case "cancel": {
|
|
926
|
+
const result = await orchestrateScheduleCancel(parsed.options, deps);
|
|
927
|
+
if (!result.ok) {
|
|
928
|
+
errorLog(formatScheduleCancelResult(result));
|
|
929
|
+
return 1;
|
|
930
|
+
}
|
|
931
|
+
log(formatScheduleCancelResult(result));
|
|
932
|
+
return 0;
|
|
933
|
+
}
|
|
934
|
+
case "doctor": {
|
|
935
|
+
const report = await orchestrateScheduleDoctor(deps);
|
|
936
|
+
log(formatScheduleDoctorReport(report, parsed.options.json));
|
|
937
|
+
return report.platformSupported ? 0 : 1;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
catch (error) {
|
|
942
|
+
// Log the internal detail to stderr for local diagnostics, but surface a
|
|
943
|
+
// sanitized message as the primary user-facing error.
|
|
944
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
945
|
+
errorLog(`Internal error: ${detail}`);
|
|
946
|
+
errorLog("Error: schedule-run failed unexpectedly. See the message above for local diagnostics.");
|
|
947
|
+
return 1;
|
|
948
|
+
}
|
|
949
|
+
// Unreachable: the switch is exhaustive over the parsed subcommands.
|
|
950
|
+
return 1;
|
|
951
|
+
}
|