@bridge_gpt/mcp-server 0.2.49 → 0.2.50
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 +24 -7
- package/build/base-ref.js +28 -3
- package/build/claude-review-workflow-drift-probe.js +130 -0
- package/build/claude-review-workflow-drift.js +173 -0
- package/build/claude-review-workflow.js +81 -16
- package/build/commands.generated.js +5 -5
- package/build/conductor/done-gate.js +25 -3
- package/build/conductor/install-doctor.js +65 -5
- package/build/conductor/latest-check-selector.js +170 -0
- package/build/conductor/local-merge.js +8 -6
- package/build/conductor-bin.js +1 -1
- package/build/{brainstorm-files.js → council-files.js} +15 -15
- package/build/decision-page-schema.js +1 -1
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +162 -4
- package/build/executor/worktree.js +46 -1
- package/build/index.js +92 -51
- package/build/init.js +9 -2
- package/build/install-bridge.js +60 -2
- package/build/install-reexec.js +47 -9
- package/build/pipelines.generated.js +1 -1
- package/build/plane/cli.js +12 -2
- package/build/plane/manifest.js +25 -1
- package/build/plane/member-roster.js +61 -7
- package/build/plane/preflight.js +24 -9
- package/build/plane/supervisor.js +77 -5
- package/build/plane/types.js +23 -3
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +2 -1
- package/build/stale-worktree-doctor.js +120 -0
- package/build/start-tickets-prereqs.js +70 -0
- package/build/start-tickets.js +91 -3
- package/build/version.generated.js +3 -2
- package/package.json +4 -2
- package/build/chain-orchestrator.js +0 -1457
- package/build/chain-utils.js +0 -68
- package/build/command-catalog.js +0 -376
- package/build/schedule-run.js +0 -1300
- package/build/schedule-store.js +0 -172
- package/build/scheduled-prompt.js +0 -115
- package/build/scheduler-backends/at-fallback.js +0 -139
- package/build/scheduler-backends/escaping.js +0 -143
- package/build/scheduler-backends/index.js +0 -72
- package/build/scheduler-backends/launchd.js +0 -225
- package/build/scheduler-backends/systemd-user.js +0 -250
- package/build/scheduler-backends/task-scheduler.js +0 -214
- package/build/scheduler-backends/types.js +0 -23
package/build/schedule-run.js
DELETED
|
@@ -1,1300 +0,0 @@
|
|
|
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, createHash } from "node:crypto";
|
|
20
|
-
import { pathApiForPlatform, selectSchedulerBackend, getSchedulerBackendByName, getSchedulerBackendsForPlatform, unsupportedSchedulerPlatformMessage, } from "./scheduler-backends/index.js";
|
|
21
|
-
import { ensureScheduleDirectories, getSchedulePaths, writeScheduleMetadata, readScheduleMetadata, listScheduleMetadata, deleteScheduleMetadata, appendScheduleRunEvent, } from "./schedule-store.js";
|
|
22
|
-
import { getAgentLauncher, formatValidAgentLauncherNames } from "./agent-launchers/index.js";
|
|
23
|
-
import { resolveCommandOnPath } from "./agent-launchers/claude.js";
|
|
24
|
-
import { discoverCommandCatalog, resolveSchedulableCommand, validateCommandArgv, } from "./command-catalog.js";
|
|
25
|
-
import { buildTargetCommandLine } from "./scheduled-prompt.js";
|
|
26
|
-
import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
|
|
27
|
-
/**
|
|
28
|
-
* Default deps backed by real subprocesses / process state. Subprocess execution
|
|
29
|
-
* uses `execFile` (list-based, never `shell: true`) and supports
|
|
30
|
-
* `RunCommandOptions.input` by writing to the child's stdin — required by the
|
|
31
|
-
* `at` backend, which pipes its heredoc script.
|
|
32
|
-
*/
|
|
33
|
-
export function createDefaultScheduleRunDeps() {
|
|
34
|
-
const runCommand = (file, args, options) => new Promise((resolve) => {
|
|
35
|
-
const child = execFile(file, args, {
|
|
36
|
-
cwd: options?.cwd,
|
|
37
|
-
env: options?.env ?? process.env,
|
|
38
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
39
|
-
encoding: "utf-8",
|
|
40
|
-
}, (error, stdout, stderr) => {
|
|
41
|
-
const exitCode = error && typeof error.code === "number"
|
|
42
|
-
? error.code
|
|
43
|
-
: error
|
|
44
|
-
? 1
|
|
45
|
-
: 0;
|
|
46
|
-
resolve({ stdout: stdout ?? "", stderr: stderr ?? "", exitCode });
|
|
47
|
-
});
|
|
48
|
-
if (options?.input !== undefined && child.stdin) {
|
|
49
|
-
child.stdin.write(options.input);
|
|
50
|
-
child.stdin.end();
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
return {
|
|
54
|
-
runCommand,
|
|
55
|
-
platform: process.platform,
|
|
56
|
-
env: process.env,
|
|
57
|
-
cwd: process.cwd(),
|
|
58
|
-
execPath: process.execPath,
|
|
59
|
-
homeDir: process.env.HOME ?? process.env.USERPROFILE ?? "",
|
|
60
|
-
now: () => Date.now(),
|
|
61
|
-
// The packaged CLI entry is argv[1]; the trigger invocation re-enters it as
|
|
62
|
-
// `node <cliEntryPath> schedule-run _execute <id>`.
|
|
63
|
-
cliEntryPath: process.argv[1],
|
|
64
|
-
// Best-effort: a Bridge credential is considered resolvable when BAPI_API_KEY
|
|
65
|
-
// is present in the environment. Never reads or returns the value itself.
|
|
66
|
-
bridgeCredentialResolved: () => Boolean(process.env.BAPI_API_KEY),
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
/** Return true only for a zero exit code. */
|
|
70
|
-
export function commandSucceeded(result) {
|
|
71
|
-
return result.exitCode === 0;
|
|
72
|
-
}
|
|
73
|
-
/**
|
|
74
|
-
* Detect a CLI entry path that lives inside a transient npx cache directory
|
|
75
|
-
* (`~/.npm/_npx/<hash>/…` on POSIX, `…\npm-cache\_npx\<hash>\…` on Windows).
|
|
76
|
-
* Such an entry can be garbage-collected before a future schedule fires, which
|
|
77
|
-
* would make the trigger shim `node <gone-path>` fail with ENOENT. Matched on the
|
|
78
|
-
* `_npx` path segment so it is independent of the surrounding cache root.
|
|
79
|
-
*/
|
|
80
|
-
export function isTransientNpxEntryPath(entryPath) {
|
|
81
|
-
if (!entryPath)
|
|
82
|
-
return false;
|
|
83
|
-
return /[\\/]_npx[\\/]/.test(entryPath);
|
|
84
|
-
}
|
|
85
|
-
const VALID_BACKEND_NAMES = [
|
|
86
|
-
"launchd",
|
|
87
|
-
"task-scheduler",
|
|
88
|
-
"systemd-user",
|
|
89
|
-
"at-fallback",
|
|
90
|
-
];
|
|
91
|
-
/** Safe schedule id / unit-name pattern. */
|
|
92
|
-
const SCHEDULE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
// Usage
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
/** User-facing usage text for `schedule-run`. */
|
|
97
|
-
export function getScheduleRunUsage() {
|
|
98
|
-
return [
|
|
99
|
-
"Usage:",
|
|
100
|
-
` npx -y ${MCP_PACKAGE_NAME} schedule-run <create|list|cancel|doctor> [flags]`,
|
|
101
|
-
"",
|
|
102
|
-
"Schedules a local, one-shot run of ANY Bridge slash-command automation (from",
|
|
103
|
-
"the repo's .claude/commands/*.md catalog) at a chosen time using an OS-native",
|
|
104
|
-
"scheduler (launchd / Task Scheduler / systemd-user / at). Schedules are stored",
|
|
105
|
-
"locally under ~/.bridge-gpt/schedules/ and are never sent to a server.",
|
|
106
|
-
"",
|
|
107
|
-
"create:",
|
|
108
|
-
" schedule-run create --in 2h --command review-ticket -- BAPI-999",
|
|
109
|
-
' schedule-run create --at "<datetime>" --command start-tickets -- BAPI-1 BAPI-2',
|
|
110
|
-
" legacy: schedule-run create --in 2h --idea-file <path>",
|
|
111
|
-
"",
|
|
112
|
-
"All tokens after a bare -- are the structured argv passed to the command.",
|
|
113
|
-
"",
|
|
114
|
-
"create flags:",
|
|
115
|
-
' --at "<datetime>" ISO-8601 time to run (mutually exclusive with --in)',
|
|
116
|
-
" --in <duration> Run after a duration, e.g. 30m, 4h, 1d (mutually exclusive with --at)",
|
|
117
|
-
" --command <name> Discovered .claude/commands command to schedule",
|
|
118
|
-
" --idea-file <path> Legacy: schedule /full-automation with this idea file",
|
|
119
|
-
" --agent <name> Agent to launch (claude [default] or cursor-agent)",
|
|
120
|
-
" --repo-path <path> Working directory + command catalog root (default: cwd)",
|
|
121
|
-
" --auto Run hands-off; gate proceeds even when firing late",
|
|
122
|
-
" --no-auto Late runs halt without executing (headless-safe default)",
|
|
123
|
-
" --dry-run Print the generated unit + prompt + invocation without creating anything",
|
|
124
|
-
" --id <id> Override the auto-generated schedule id (mainly for tests)",
|
|
125
|
-
"",
|
|
126
|
-
"list flags:",
|
|
127
|
-
" --id <id> Filter to a single schedule id",
|
|
128
|
-
" --agent <name> Filter by agent",
|
|
129
|
-
" --backend <name> Filter by backend (launchd|task-scheduler|systemd-user|at-fallback)",
|
|
130
|
-
" --json Emit JSON instead of a table",
|
|
131
|
-
"",
|
|
132
|
-
"cancel:",
|
|
133
|
-
" cancel <id> Cancel by positional id",
|
|
134
|
-
" cancel --id <id> Cancel by --id flag",
|
|
135
|
-
" --agent <name> Only cancel when the recorded agent matches",
|
|
136
|
-
" --backend <name> Only cancel when the recorded backend matches",
|
|
137
|
-
"",
|
|
138
|
-
"doctor:",
|
|
139
|
-
" Read-only diagnostics: platform support, backend candidate order, scheduler",
|
|
140
|
-
" availability, and whether claude/npx resolve on PATH. Creates nothing.",
|
|
141
|
-
"",
|
|
142
|
-
" -h, --help Show this help",
|
|
143
|
-
].join("\n");
|
|
144
|
-
}
|
|
145
|
-
// ---------------------------------------------------------------------------
|
|
146
|
-
// Argument parsing
|
|
147
|
-
// ---------------------------------------------------------------------------
|
|
148
|
-
/** Internal: a flag that expects a following value. */
|
|
149
|
-
function takeValue(argv, index, arg, flag) {
|
|
150
|
-
if (arg.startsWith(`${flag}=`)) {
|
|
151
|
-
return { value: arg.slice(`${flag}=`.length), nextIndex: index };
|
|
152
|
-
}
|
|
153
|
-
if (index + 1 >= argv.length) {
|
|
154
|
-
return { error: `${flag} requires a value.` };
|
|
155
|
-
}
|
|
156
|
-
return { value: argv[index + 1], nextIndex: index + 1 };
|
|
157
|
-
}
|
|
158
|
-
function matchesFlag(arg, flag) {
|
|
159
|
-
return arg === flag || arg.startsWith(`${flag}=`);
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* Parse `create` args. Everything after the FIRST bare `--` is the structured
|
|
163
|
-
* command argv (passed through verbatim, never re-tokenized); scheduler flags are
|
|
164
|
-
* parsed only before that delimiter. `--idea-file` without `--command` implies
|
|
165
|
-
* the legacy `full-automation` command.
|
|
166
|
-
*/
|
|
167
|
-
export function parseScheduleCreateArgs(argv) {
|
|
168
|
-
// Split on the first bare "--". Help is honored only from the scheduler-flag
|
|
169
|
-
// side; tokens after "--" belong to the delegated command verbatim.
|
|
170
|
-
const delimIndex = argv.indexOf("--");
|
|
171
|
-
const schedulerArgs = delimIndex === -1 ? argv : argv.slice(0, delimIndex);
|
|
172
|
-
const commandArgs = delimIndex === -1 ? [] : argv.slice(delimIndex + 1);
|
|
173
|
-
if (schedulerArgs.includes("-h") || schedulerArgs.includes("--help")) {
|
|
174
|
-
return { status: "help", usage: getScheduleRunUsage() };
|
|
175
|
-
}
|
|
176
|
-
let at;
|
|
177
|
-
let inDuration;
|
|
178
|
-
let commandName;
|
|
179
|
-
let ideaFile;
|
|
180
|
-
let agent = "claude";
|
|
181
|
-
let repoPath;
|
|
182
|
-
let id;
|
|
183
|
-
let sawAutoApprove = false;
|
|
184
|
-
let sawNoAutoApprove = false;
|
|
185
|
-
let dryRun = false;
|
|
186
|
-
for (let i = 0; i < schedulerArgs.length; i++) {
|
|
187
|
-
const arg = schedulerArgs[i];
|
|
188
|
-
if (matchesFlag(arg, "--at")) {
|
|
189
|
-
const r = takeValue(schedulerArgs, i, arg, "--at");
|
|
190
|
-
if ("error" in r)
|
|
191
|
-
return { status: "error", message: r.error };
|
|
192
|
-
at = r.value;
|
|
193
|
-
i = r.nextIndex;
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
196
|
-
if (matchesFlag(arg, "--in")) {
|
|
197
|
-
const r = takeValue(schedulerArgs, i, arg, "--in");
|
|
198
|
-
if ("error" in r)
|
|
199
|
-
return { status: "error", message: r.error };
|
|
200
|
-
inDuration = r.value;
|
|
201
|
-
i = r.nextIndex;
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
if (matchesFlag(arg, "--command")) {
|
|
205
|
-
const r = takeValue(schedulerArgs, i, arg, "--command");
|
|
206
|
-
if ("error" in r)
|
|
207
|
-
return { status: "error", message: r.error };
|
|
208
|
-
commandName = r.value;
|
|
209
|
-
i = r.nextIndex;
|
|
210
|
-
continue;
|
|
211
|
-
}
|
|
212
|
-
if (matchesFlag(arg, "--idea-file")) {
|
|
213
|
-
const r = takeValue(schedulerArgs, i, arg, "--idea-file");
|
|
214
|
-
if ("error" in r)
|
|
215
|
-
return { status: "error", message: r.error };
|
|
216
|
-
ideaFile = r.value;
|
|
217
|
-
i = r.nextIndex;
|
|
218
|
-
continue;
|
|
219
|
-
}
|
|
220
|
-
if (matchesFlag(arg, "--agent")) {
|
|
221
|
-
const r = takeValue(schedulerArgs, i, arg, "--agent");
|
|
222
|
-
if ("error" in r)
|
|
223
|
-
return { status: "error", message: r.error };
|
|
224
|
-
if (!getAgentLauncher(r.value)) {
|
|
225
|
-
return {
|
|
226
|
-
status: "error",
|
|
227
|
-
message: `Invalid --agent value: '${r.value}'. Valid: ${formatValidAgentLauncherNames()}.`,
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
agent = r.value;
|
|
231
|
-
i = r.nextIndex;
|
|
232
|
-
continue;
|
|
233
|
-
}
|
|
234
|
-
if (matchesFlag(arg, "--repo-path")) {
|
|
235
|
-
const r = takeValue(schedulerArgs, i, arg, "--repo-path");
|
|
236
|
-
if ("error" in r)
|
|
237
|
-
return { status: "error", message: r.error };
|
|
238
|
-
repoPath = r.value;
|
|
239
|
-
i = r.nextIndex;
|
|
240
|
-
continue;
|
|
241
|
-
}
|
|
242
|
-
if (matchesFlag(arg, "--id")) {
|
|
243
|
-
const r = takeValue(schedulerArgs, i, arg, "--id");
|
|
244
|
-
if ("error" in r)
|
|
245
|
-
return { status: "error", message: r.error };
|
|
246
|
-
id = r.value;
|
|
247
|
-
i = r.nextIndex;
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
if (arg === "--auto") {
|
|
251
|
-
sawAutoApprove = true;
|
|
252
|
-
continue;
|
|
253
|
-
}
|
|
254
|
-
if (arg === "--no-auto") {
|
|
255
|
-
sawNoAutoApprove = true;
|
|
256
|
-
continue;
|
|
257
|
-
}
|
|
258
|
-
if (arg === "--dry-run") {
|
|
259
|
-
dryRun = true;
|
|
260
|
-
continue;
|
|
261
|
-
}
|
|
262
|
-
if (arg.startsWith("-")) {
|
|
263
|
-
return { status: "error", message: `Unknown flag: ${arg}` };
|
|
264
|
-
}
|
|
265
|
-
return {
|
|
266
|
-
status: "error",
|
|
267
|
-
message: `Unexpected positional argument: '${arg}'. create takes flags before '--'; ` +
|
|
268
|
-
"put command arguments after '--'.",
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
if (Boolean(at) === Boolean(inDuration)) {
|
|
272
|
-
return {
|
|
273
|
-
status: "error",
|
|
274
|
-
message: "create requires exactly one of --at or --in.",
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
if (sawAutoApprove && sawNoAutoApprove) {
|
|
278
|
-
return {
|
|
279
|
-
status: "error",
|
|
280
|
-
message: "--auto and --no-auto are mutually exclusive.",
|
|
281
|
-
};
|
|
282
|
-
}
|
|
283
|
-
if (id !== undefined && !SCHEDULE_ID_PATTERN.test(id)) {
|
|
284
|
-
return {
|
|
285
|
-
status: "error",
|
|
286
|
-
message: `Invalid --id value: '${id}'. Ids must match ${SCHEDULE_ID_PATTERN.source} ` +
|
|
287
|
-
"(start alphanumeric; letters, digits, '_' and '-'; max 64 chars).",
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
// Legacy compatibility: --idea-file without --command implies full-automation.
|
|
291
|
-
// --idea-file with any OTHER command is rejected (it only applies to that path).
|
|
292
|
-
if (ideaFile !== undefined) {
|
|
293
|
-
if (commandName === undefined) {
|
|
294
|
-
commandName = "full-automation";
|
|
295
|
-
}
|
|
296
|
-
else if (commandName !== "full-automation") {
|
|
297
|
-
return {
|
|
298
|
-
status: "error",
|
|
299
|
-
message: `--idea-file is only valid for legacy full-automation scheduling, not --command '${commandName}'. ` +
|
|
300
|
-
"Pass command arguments after '--' instead.",
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
if (commandName === undefined) {
|
|
305
|
-
return {
|
|
306
|
-
status: "error",
|
|
307
|
-
message: "create requires --command <name> (or legacy --idea-file <path>).",
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
return {
|
|
311
|
-
status: "ok",
|
|
312
|
-
subcommand: "create",
|
|
313
|
-
options: {
|
|
314
|
-
at,
|
|
315
|
-
in: inDuration,
|
|
316
|
-
commandName,
|
|
317
|
-
commandArgs,
|
|
318
|
-
ideaFile,
|
|
319
|
-
agent,
|
|
320
|
-
repoPath,
|
|
321
|
-
autoApprove: !sawNoAutoApprove,
|
|
322
|
-
autoApproveExplicit: sawAutoApprove || sawNoAutoApprove,
|
|
323
|
-
dryRun,
|
|
324
|
-
id,
|
|
325
|
-
},
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
/** Parse the hidden `_execute <id>` shim args (exactly one valid schedule id). */
|
|
329
|
-
export function parseScheduleExecuteArgs(argv) {
|
|
330
|
-
const positionals = argv.filter((a) => !a.startsWith("-"));
|
|
331
|
-
const flags = argv.filter((a) => a.startsWith("-"));
|
|
332
|
-
if (flags.length > 0) {
|
|
333
|
-
return { status: "error", message: `_execute takes no flags (got ${flags.join(", ")}).` };
|
|
334
|
-
}
|
|
335
|
-
if (positionals.length !== 1) {
|
|
336
|
-
return { status: "error", message: "_execute requires exactly one schedule id." };
|
|
337
|
-
}
|
|
338
|
-
const id = positionals[0];
|
|
339
|
-
if (!SCHEDULE_ID_PATTERN.test(id)) {
|
|
340
|
-
return { status: "error", message: `Invalid schedule id: '${id}'.` };
|
|
341
|
-
}
|
|
342
|
-
return { status: "ok", subcommand: "_execute", options: { id } };
|
|
343
|
-
}
|
|
344
|
-
/** Parse `list` args. */
|
|
345
|
-
export function parseScheduleListArgs(argv) {
|
|
346
|
-
if (argv.includes("-h") || argv.includes("--help")) {
|
|
347
|
-
return { status: "help", usage: getScheduleRunUsage() };
|
|
348
|
-
}
|
|
349
|
-
let id;
|
|
350
|
-
let agent;
|
|
351
|
-
let backend;
|
|
352
|
-
let json = false;
|
|
353
|
-
for (let i = 0; i < argv.length; i++) {
|
|
354
|
-
const arg = argv[i];
|
|
355
|
-
if (matchesFlag(arg, "--id")) {
|
|
356
|
-
const r = takeValue(argv, i, arg, "--id");
|
|
357
|
-
if ("error" in r)
|
|
358
|
-
return { status: "error", message: r.error };
|
|
359
|
-
id = r.value;
|
|
360
|
-
i = r.nextIndex;
|
|
361
|
-
continue;
|
|
362
|
-
}
|
|
363
|
-
if (matchesFlag(arg, "--agent")) {
|
|
364
|
-
const r = takeValue(argv, i, arg, "--agent");
|
|
365
|
-
if ("error" in r)
|
|
366
|
-
return { status: "error", message: r.error };
|
|
367
|
-
if (!getAgentLauncher(r.value)) {
|
|
368
|
-
return {
|
|
369
|
-
status: "error",
|
|
370
|
-
message: `Invalid --agent filter: '${r.value}'. Valid: ${formatValidAgentLauncherNames()}.`,
|
|
371
|
-
};
|
|
372
|
-
}
|
|
373
|
-
agent = r.value;
|
|
374
|
-
i = r.nextIndex;
|
|
375
|
-
continue;
|
|
376
|
-
}
|
|
377
|
-
if (matchesFlag(arg, "--backend")) {
|
|
378
|
-
const r = takeValue(argv, i, arg, "--backend");
|
|
379
|
-
if ("error" in r)
|
|
380
|
-
return { status: "error", message: r.error };
|
|
381
|
-
if (!VALID_BACKEND_NAMES.includes(r.value)) {
|
|
382
|
-
return {
|
|
383
|
-
status: "error",
|
|
384
|
-
message: `Invalid --backend filter: '${r.value}'. Valid: ${VALID_BACKEND_NAMES.join(", ")}.`,
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
backend = r.value;
|
|
388
|
-
i = r.nextIndex;
|
|
389
|
-
continue;
|
|
390
|
-
}
|
|
391
|
-
if (arg === "--json") {
|
|
392
|
-
json = true;
|
|
393
|
-
continue;
|
|
394
|
-
}
|
|
395
|
-
if (arg.startsWith("-")) {
|
|
396
|
-
return { status: "error", message: `Unknown flag: ${arg}` };
|
|
397
|
-
}
|
|
398
|
-
return {
|
|
399
|
-
status: "error",
|
|
400
|
-
message: `Unexpected positional argument: '${arg}'. list takes only flags.`,
|
|
401
|
-
};
|
|
402
|
-
}
|
|
403
|
-
return { status: "ok", subcommand: "list", options: { id, agent, backend, json } };
|
|
404
|
-
}
|
|
405
|
-
/** Parse `cancel` args (one id via positional or --id, never both/multiple). */
|
|
406
|
-
export function parseScheduleCancelArgs(argv) {
|
|
407
|
-
if (argv.includes("-h") || argv.includes("--help")) {
|
|
408
|
-
return { status: "help", usage: getScheduleRunUsage() };
|
|
409
|
-
}
|
|
410
|
-
let positionalId;
|
|
411
|
-
let flagId;
|
|
412
|
-
let agent;
|
|
413
|
-
let backend;
|
|
414
|
-
for (let i = 0; i < argv.length; i++) {
|
|
415
|
-
const arg = argv[i];
|
|
416
|
-
if (matchesFlag(arg, "--id")) {
|
|
417
|
-
const r = takeValue(argv, i, arg, "--id");
|
|
418
|
-
if ("error" in r)
|
|
419
|
-
return { status: "error", message: r.error };
|
|
420
|
-
if (flagId !== undefined) {
|
|
421
|
-
return { status: "error", message: "cancel accepts only one --id." };
|
|
422
|
-
}
|
|
423
|
-
flagId = r.value;
|
|
424
|
-
i = r.nextIndex;
|
|
425
|
-
continue;
|
|
426
|
-
}
|
|
427
|
-
if (matchesFlag(arg, "--agent")) {
|
|
428
|
-
const r = takeValue(argv, i, arg, "--agent");
|
|
429
|
-
if ("error" in r)
|
|
430
|
-
return { status: "error", message: r.error };
|
|
431
|
-
agent = r.value;
|
|
432
|
-
i = r.nextIndex;
|
|
433
|
-
continue;
|
|
434
|
-
}
|
|
435
|
-
if (matchesFlag(arg, "--backend")) {
|
|
436
|
-
const r = takeValue(argv, i, arg, "--backend");
|
|
437
|
-
if ("error" in r)
|
|
438
|
-
return { status: "error", message: r.error };
|
|
439
|
-
if (!VALID_BACKEND_NAMES.includes(r.value)) {
|
|
440
|
-
return {
|
|
441
|
-
status: "error",
|
|
442
|
-
message: `Invalid --backend filter: '${r.value}'. Valid: ${VALID_BACKEND_NAMES.join(", ")}.`,
|
|
443
|
-
};
|
|
444
|
-
}
|
|
445
|
-
backend = r.value;
|
|
446
|
-
i = r.nextIndex;
|
|
447
|
-
continue;
|
|
448
|
-
}
|
|
449
|
-
if (arg.startsWith("-")) {
|
|
450
|
-
return { status: "error", message: `Unknown flag: ${arg}` };
|
|
451
|
-
}
|
|
452
|
-
if (positionalId !== undefined) {
|
|
453
|
-
return { status: "error", message: "cancel accepts only one schedule id." };
|
|
454
|
-
}
|
|
455
|
-
positionalId = arg;
|
|
456
|
-
}
|
|
457
|
-
if (positionalId !== undefined && flagId !== undefined) {
|
|
458
|
-
return {
|
|
459
|
-
status: "error",
|
|
460
|
-
message: "cancel accepts a positional id OR --id, not both.",
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
const id = positionalId ?? flagId;
|
|
464
|
-
if (!id) {
|
|
465
|
-
return { status: "error", message: "cancel requires a schedule id (positional or --id)." };
|
|
466
|
-
}
|
|
467
|
-
return { status: "ok", subcommand: "cancel", options: { id, agent, backend } };
|
|
468
|
-
}
|
|
469
|
-
/** Parse `doctor` args (read-only; only --json / help). */
|
|
470
|
-
export function parseScheduleDoctorArgs(argv) {
|
|
471
|
-
if (argv.includes("-h") || argv.includes("--help")) {
|
|
472
|
-
return { status: "help", usage: getScheduleRunUsage() };
|
|
473
|
-
}
|
|
474
|
-
let json = false;
|
|
475
|
-
for (const arg of argv) {
|
|
476
|
-
if (arg === "--json") {
|
|
477
|
-
json = true;
|
|
478
|
-
continue;
|
|
479
|
-
}
|
|
480
|
-
if (arg.startsWith("-")) {
|
|
481
|
-
return { status: "error", message: `Unknown flag: ${arg}` };
|
|
482
|
-
}
|
|
483
|
-
return {
|
|
484
|
-
status: "error",
|
|
485
|
-
message: `Unexpected positional argument: '${arg}'. doctor takes no positional arguments.`,
|
|
486
|
-
};
|
|
487
|
-
}
|
|
488
|
-
return { status: "ok", subcommand: "doctor", options: { json } };
|
|
489
|
-
}
|
|
490
|
-
/** Top-level parser: honor help first, require a valid subcommand, dispatch. */
|
|
491
|
-
export function parseScheduleRunArgs(argv) {
|
|
492
|
-
// Help before any other validation, even with later unknown flags.
|
|
493
|
-
if (argv.length === 0) {
|
|
494
|
-
return {
|
|
495
|
-
status: "error",
|
|
496
|
-
message: "Missing subcommand. Expected one of: create, list, cancel, doctor.",
|
|
497
|
-
};
|
|
498
|
-
}
|
|
499
|
-
const [subcommand, ...rest] = argv;
|
|
500
|
-
if (subcommand === "-h" || subcommand === "--help") {
|
|
501
|
-
return { status: "help", usage: getScheduleRunUsage() };
|
|
502
|
-
}
|
|
503
|
-
switch (subcommand) {
|
|
504
|
-
case "create":
|
|
505
|
-
return parseScheduleCreateArgs(rest);
|
|
506
|
-
case "list":
|
|
507
|
-
return parseScheduleListArgs(rest);
|
|
508
|
-
case "cancel":
|
|
509
|
-
return parseScheduleCancelArgs(rest);
|
|
510
|
-
case "doctor":
|
|
511
|
-
return parseScheduleDoctorArgs(rest);
|
|
512
|
-
// Hidden internal shim the OS unit invokes; intentionally absent from usage.
|
|
513
|
-
case "_execute":
|
|
514
|
-
return parseScheduleExecuteArgs(rest);
|
|
515
|
-
default:
|
|
516
|
-
return {
|
|
517
|
-
status: "error",
|
|
518
|
-
message: `Unknown subcommand '${subcommand}'. Expected one of: create, list, cancel, doctor.`,
|
|
519
|
-
};
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
/** Parse `--at` as ISO-8601 and normalize to `Date.toISOString()`. */
|
|
523
|
-
export function parseRunAtIso(value) {
|
|
524
|
-
const date = new Date(value);
|
|
525
|
-
if (Number.isNaN(date.getTime())) {
|
|
526
|
-
return { ok: false, error: `Invalid --at datetime: '${value}'. Expected an ISO-8601 timestamp.` };
|
|
527
|
-
}
|
|
528
|
-
return { ok: true, iso: date.toISOString() };
|
|
529
|
-
}
|
|
530
|
-
/** Parse a positive `<n><m|h|d>` duration relative to the injected clock. */
|
|
531
|
-
export function parseDurationFromNow(value, nowMs) {
|
|
532
|
-
const match = value.match(/^(\d+)([mhd])$/);
|
|
533
|
-
if (!match) {
|
|
534
|
-
return {
|
|
535
|
-
ok: false,
|
|
536
|
-
error: `Invalid --in duration: '${value}'. Use a positive number followed by m, h, or d (e.g. 30m, 4h, 1d).`,
|
|
537
|
-
};
|
|
538
|
-
}
|
|
539
|
-
const amount = Number(match[1]);
|
|
540
|
-
if (amount <= 0) {
|
|
541
|
-
return { ok: false, error: `Invalid --in duration: '${value}'. Duration must be positive.` };
|
|
542
|
-
}
|
|
543
|
-
const unitMs = match[2] === "m" ? 60_000 : match[2] === "h" ? 3_600_000 : 86_400_000;
|
|
544
|
-
return { ok: true, iso: new Date(nowMs + amount * unitMs).toISOString() };
|
|
545
|
-
}
|
|
546
|
-
/** Resolve an absolute path against the platform path API and deps.cwd. */
|
|
547
|
-
function resolveAbsolute(p, deps) {
|
|
548
|
-
const pathApi = pathApiForPlatform(deps.platform);
|
|
549
|
-
return pathApi.isAbsolute(p) ? pathApi.normalize(p) : pathApi.resolve(deps.cwd, p);
|
|
550
|
-
}
|
|
551
|
-
/**
|
|
552
|
-
* Collect read-only prerequisite/auth readiness for the selected create input.
|
|
553
|
-
* Returns presence/readiness booleans only — it never reads or returns secret
|
|
554
|
-
* values (CURSOR_API_KEY / Bridge credential material).
|
|
555
|
-
*/
|
|
556
|
-
export function collectScheduleRunPrereqStatus(input) {
|
|
557
|
-
return {
|
|
558
|
-
agentBinaryResolved: input.agentBinaryResolved,
|
|
559
|
-
repoPathExists: input.repoPathExists,
|
|
560
|
-
cursorApiKeyPresent: Boolean(input.deps.env.CURSOR_API_KEY),
|
|
561
|
-
bridgeCredentialResolved: input.deps.bridgeCredentialResolved?.() ?? Boolean(input.deps.env.BAPI_API_KEY),
|
|
562
|
-
};
|
|
563
|
-
}
|
|
564
|
-
/**
|
|
565
|
-
* Resolve and validate generic create inputs. Resolves the repo path first and
|
|
566
|
-
* validates it is a directory; discovers the `.claude/commands` catalog under it;
|
|
567
|
-
* resolves and validates the requested command (rejecting unknown /
|
|
568
|
-
* `schedulable: false` / malformed); validates structured argv against the
|
|
569
|
-
* command's schema; resolves the agent binary on the baked PATH; builds the agent
|
|
570
|
-
* invocation via the launcher and the trigger invocation for the `_execute` shim.
|
|
571
|
-
* The legacy `--idea-file` path is converted to structured `["--idea-file", abs]`
|
|
572
|
-
* args for `full-automation`. Installs nothing — orchestration owns side effects.
|
|
573
|
-
*/
|
|
574
|
-
export async function resolveScheduleCreateInput(options, runAtIso, deps) {
|
|
575
|
-
const launcher = getAgentLauncher(options.agent);
|
|
576
|
-
if (!launcher) {
|
|
577
|
-
return {
|
|
578
|
-
ok: false,
|
|
579
|
-
error: `Unsupported agent: '${options.agent}'. Valid: ${formatValidAgentLauncherNames()}.`,
|
|
580
|
-
};
|
|
581
|
-
}
|
|
582
|
-
const commandName = options.commandName;
|
|
583
|
-
if (!commandName) {
|
|
584
|
-
return { ok: false, error: "create requires a command (--command or legacy --idea-file)." };
|
|
585
|
-
}
|
|
586
|
-
// Resolve + validate the repo path (command-catalog root) BEFORE discovery.
|
|
587
|
-
const repoPath = resolveAbsolute(options.repoPath ?? deps.cwd, deps);
|
|
588
|
-
try {
|
|
589
|
-
const repoStat = await fs.stat(repoPath);
|
|
590
|
-
if (!repoStat.isDirectory()) {
|
|
591
|
-
return { ok: false, error: `--repo-path is not a directory: ${repoPath}` };
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
catch {
|
|
595
|
-
return { ok: false, error: `--repo-path does not exist: ${repoPath}` };
|
|
596
|
-
}
|
|
597
|
-
// Build the structured command argv. Legacy --idea-file is validated and turned
|
|
598
|
-
// into ["--idea-file", <abs>] (full-automation only; enforced by the parser).
|
|
599
|
-
let commandArgs = options.commandArgs;
|
|
600
|
-
let legacyIdeaFile;
|
|
601
|
-
if (options.ideaFile !== undefined) {
|
|
602
|
-
const ideaFile = resolveAbsolute(options.ideaFile, deps);
|
|
603
|
-
try {
|
|
604
|
-
const ideaStat = await fs.stat(ideaFile);
|
|
605
|
-
if (!ideaStat.isFile())
|
|
606
|
-
return { ok: false, error: `--idea-file is not a file: ${ideaFile}` };
|
|
607
|
-
}
|
|
608
|
-
catch {
|
|
609
|
-
return { ok: false, error: `--idea-file does not exist: ${ideaFile}` };
|
|
610
|
-
}
|
|
611
|
-
legacyIdeaFile = ideaFile;
|
|
612
|
-
commandArgs = ["--idea-file", ideaFile];
|
|
613
|
-
}
|
|
614
|
-
// Discover the command catalog under the resolved repo and resolve the command.
|
|
615
|
-
const catalog = await discoverCommandCatalog(repoPath, deps.platform, deps.commandCatalogFsDeps);
|
|
616
|
-
const resolvedCommand = resolveSchedulableCommand(catalog, commandName);
|
|
617
|
-
if (!resolvedCommand.ok)
|
|
618
|
-
return { ok: false, error: resolvedCommand.error };
|
|
619
|
-
const command = resolvedCommand.command;
|
|
620
|
-
// Validate the structured argv against the command's schema (or passthrough).
|
|
621
|
-
const validation = validateCommandArgv(commandArgs, command.argumentSchema);
|
|
622
|
-
if (!validation.ok) {
|
|
623
|
-
return { ok: false, error: `Invalid arguments for '${commandName}': ${validation.error}` };
|
|
624
|
-
}
|
|
625
|
-
const normalizedArgv = validation.normalizedArgv;
|
|
626
|
-
const envPath = deps.env.PATH ?? deps.env.Path ?? "";
|
|
627
|
-
const nodePath = deps.execPath;
|
|
628
|
-
const agentPath = await launcher.resolveBinary(envPath, deps);
|
|
629
|
-
if (!agentPath) {
|
|
630
|
-
return {
|
|
631
|
-
ok: false,
|
|
632
|
-
error: `Could not resolve the '${launcher.capability.command}' binary on the baked PATH.`,
|
|
633
|
-
};
|
|
634
|
-
}
|
|
635
|
-
// npx is resolved best-effort; empty when absent.
|
|
636
|
-
const npxPath = (await resolveCommandOnPath("npx", envPath, deps)) ?? "";
|
|
637
|
-
const id = options.id ?? randomUUID();
|
|
638
|
-
const agentInvocation = launcher.buildInvocation(agentPath, {
|
|
639
|
-
scheduleId: id,
|
|
640
|
-
runAtIso,
|
|
641
|
-
commandName,
|
|
642
|
-
args: normalizedArgv,
|
|
643
|
-
autoApprove: options.autoApprove,
|
|
644
|
-
repoPath,
|
|
645
|
-
commandFilePath: command.filePath,
|
|
646
|
-
commandBody: command.body,
|
|
647
|
-
schema: command.argumentSchema,
|
|
648
|
-
});
|
|
649
|
-
const cliEntryPath = deps.cliEntryPath ?? process.argv[1] ?? "";
|
|
650
|
-
const triggerInvocation = {
|
|
651
|
-
exe: nodePath,
|
|
652
|
-
args: [cliEntryPath, "schedule-run", "_execute", id],
|
|
653
|
-
};
|
|
654
|
-
const commandBodyHash = createHash("sha256").update(command.body, "utf-8").digest("hex");
|
|
655
|
-
const warnings = [];
|
|
656
|
-
if (command.interactive && !options.autoApproveExplicit) {
|
|
657
|
-
warnings.push(`Command '${commandName}' has an interactive step but --auto/--no-auto was not specified; ` +
|
|
658
|
-
"a headless scheduled run may hang waiting for confirmation. Pass --auto to run hands-off.");
|
|
659
|
-
}
|
|
660
|
-
if (isTransientNpxEntryPath(cliEntryPath)) {
|
|
661
|
-
warnings.push(`The scheduled run re-enters this CLI at '${cliEntryPath}', which is inside an npx cache ` +
|
|
662
|
-
"directory. npx caches can be pruned before a future schedule fires, which would make the " +
|
|
663
|
-
`run fail with ENOENT. Install the CLI persistently (e.g. \`npm i -g ${MCP_PACKAGE_NAME}\`) ` +
|
|
664
|
-
"and schedule with the installed binary so the trigger path stays stable.");
|
|
665
|
-
}
|
|
666
|
-
const paths = getSchedulePaths(id, deps.homeDir, deps.platform);
|
|
667
|
-
return {
|
|
668
|
-
ok: true,
|
|
669
|
-
resolved: {
|
|
670
|
-
id,
|
|
671
|
-
runAtIso,
|
|
672
|
-
agent: options.agent,
|
|
673
|
-
command: commandName,
|
|
674
|
-
args: normalizedArgv,
|
|
675
|
-
commandFilePath: command.filePath,
|
|
676
|
-
commandBodyHash,
|
|
677
|
-
autoApprove: options.autoApprove,
|
|
678
|
-
repoPath,
|
|
679
|
-
legacyIdeaFile,
|
|
680
|
-
envPath,
|
|
681
|
-
nodePath,
|
|
682
|
-
npxPath,
|
|
683
|
-
agentPath,
|
|
684
|
-
agentInvocation,
|
|
685
|
-
triggerInvocation,
|
|
686
|
-
renderedPrompt: agentInvocation.prompt,
|
|
687
|
-
schema: command.argumentSchema,
|
|
688
|
-
paths: {
|
|
689
|
-
schedulesDir: paths.schedulesDir,
|
|
690
|
-
logsDir: paths.logsDir,
|
|
691
|
-
stdoutPath: paths.stdoutPath,
|
|
692
|
-
stderrPath: paths.stderrPath,
|
|
693
|
-
},
|
|
694
|
-
warnings,
|
|
695
|
-
prereq: collectScheduleRunPrereqStatus({
|
|
696
|
-
agent: options.agent,
|
|
697
|
-
agentBinaryResolved: Boolean(agentPath),
|
|
698
|
-
repoPathExists: true,
|
|
699
|
-
deps,
|
|
700
|
-
}),
|
|
701
|
-
},
|
|
702
|
-
};
|
|
703
|
-
}
|
|
704
|
-
/**
|
|
705
|
-
* Build the local-only schedule metadata from resolved inputs + backend result.
|
|
706
|
-
* Writes the generalized fields plus legacy compatibility aliases (`invocation`,
|
|
707
|
-
* `claude_path`, `idea_file`) so old readers and full-automation records keep
|
|
708
|
-
* working. Run history is initialized with a `created` event.
|
|
709
|
-
*/
|
|
710
|
-
export function buildScheduleMetadata(resolved, createResult, createdAtIso) {
|
|
711
|
-
return {
|
|
712
|
-
id: resolved.id,
|
|
713
|
-
run_at_iso: resolved.runAtIso,
|
|
714
|
-
scheduled_at: resolved.runAtIso,
|
|
715
|
-
backend: createResult.backend,
|
|
716
|
-
unit_path: createResult.unitPath,
|
|
717
|
-
unit_paths: createResult.unitPaths,
|
|
718
|
-
backend_job_id: createResult.backendJobId,
|
|
719
|
-
agent: resolved.agent,
|
|
720
|
-
agent_path: resolved.agentPath,
|
|
721
|
-
command: resolved.command,
|
|
722
|
-
args: resolved.args,
|
|
723
|
-
command_file: resolved.commandFilePath,
|
|
724
|
-
command_body_hash: resolved.commandBodyHash,
|
|
725
|
-
auto_approve: resolved.autoApprove,
|
|
726
|
-
repo_path: resolved.repoPath,
|
|
727
|
-
created_at: createdAtIso,
|
|
728
|
-
env_path: resolved.envPath,
|
|
729
|
-
node_path: resolved.nodePath,
|
|
730
|
-
npx_path: resolved.npxPath,
|
|
731
|
-
// Legacy alias retained for full-automation/claude records.
|
|
732
|
-
claude_path: resolved.agent === "claude" ? resolved.agentPath : undefined,
|
|
733
|
-
idea_file: resolved.legacyIdeaFile,
|
|
734
|
-
invocation: resolved.agentInvocation,
|
|
735
|
-
agent_invocation: resolved.agentInvocation,
|
|
736
|
-
trigger_invocation: resolved.triggerInvocation,
|
|
737
|
-
run_history: [{ status: "created", at: createdAtIso }],
|
|
738
|
-
logs: { stdout: resolved.paths.stdoutPath, stderr: resolved.paths.stderrPath },
|
|
739
|
-
};
|
|
740
|
-
}
|
|
741
|
-
/** Compute the run time (exactly one of --at / --in already validated). */
|
|
742
|
-
function resolveRunAtIso(options, deps) {
|
|
743
|
-
if (options.at !== undefined) {
|
|
744
|
-
return parseRunAtIso(options.at);
|
|
745
|
-
}
|
|
746
|
-
const nowMs = deps.now ? deps.now() : Date.now();
|
|
747
|
-
return parseDurationFromNow(options.in, nowMs);
|
|
748
|
-
}
|
|
749
|
-
/**
|
|
750
|
-
* Orchestrate a create. Dry-run selects the primary backend and returns
|
|
751
|
-
* artifacts WITHOUT creating directories or writing metadata. Real create:
|
|
752
|
-
* resolve → ensure directories → backend.create → write metadata only after
|
|
753
|
-
* backend success; if metadata persistence fails after a native unit/job was
|
|
754
|
-
* created, attempt a best-effort backend cancel before returning a failure.
|
|
755
|
-
*/
|
|
756
|
-
export async function orchestrateScheduleCreate(options, deps) {
|
|
757
|
-
const runAt = resolveRunAtIso(options, deps);
|
|
758
|
-
if (!runAt.ok)
|
|
759
|
-
return { ok: false, error: runAt.error };
|
|
760
|
-
const resolvedResult = await resolveScheduleCreateInput(options, runAt.iso, deps);
|
|
761
|
-
if (!resolvedResult.ok)
|
|
762
|
-
return { ok: false, error: resolvedResult.error };
|
|
763
|
-
const resolved = resolvedResult.resolved;
|
|
764
|
-
const createdAtIso = new Date(deps.now ? deps.now() : Date.now()).toISOString();
|
|
765
|
-
const metadataPath = getSchedulePaths(resolved.id, deps.homeDir, deps.platform).metadataPath;
|
|
766
|
-
// For a real create, reject a colliding/unreadable existing schedule BEFORE
|
|
767
|
-
// probing scheduler availability or creating anything (fail fast, no side
|
|
768
|
-
// effects). Without this, `schtasks /Create /F` would replace the prior task
|
|
769
|
-
// and writeScheduleMetadata would overwrite the prior <id>.json, losing it.
|
|
770
|
-
// A missing file reads as null (proceed); a present-but-unreadable file
|
|
771
|
-
// (malformed JSON, EACCES, …) is surfaced as a controlled error rather than
|
|
772
|
-
// being swallowed and silently overwritten — mirroring how
|
|
773
|
-
// orchestrateScheduleList surfaces malformed metadata instead of ignoring it.
|
|
774
|
-
if (!options.dryRun) {
|
|
775
|
-
let existing;
|
|
776
|
-
try {
|
|
777
|
-
existing = await readScheduleMetadata(resolved.id, deps.homeDir, deps.platform);
|
|
778
|
-
}
|
|
779
|
-
catch (error) {
|
|
780
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
781
|
-
return {
|
|
782
|
-
ok: false,
|
|
783
|
-
error: `Could not read existing schedule metadata for id '${resolved.id}' at ${metadataPath}: ${msg}. ` +
|
|
784
|
-
"Resolve or remove it before creating.",
|
|
785
|
-
};
|
|
786
|
-
}
|
|
787
|
-
if (existing) {
|
|
788
|
-
return {
|
|
789
|
-
ok: false,
|
|
790
|
-
error: `A schedule with id '${resolved.id}' already exists (${metadataPath}). ` +
|
|
791
|
-
"Cancel it first (schedule-run cancel <id>) or choose a different --id.",
|
|
792
|
-
};
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
const selection = await selectSchedulerBackend(deps, options.dryRun);
|
|
796
|
-
if (!selection.ok)
|
|
797
|
-
return { ok: false, error: selection.error };
|
|
798
|
-
const backend = selection.backend;
|
|
799
|
-
const createInput = {
|
|
800
|
-
deps,
|
|
801
|
-
id: resolved.id,
|
|
802
|
-
runAtIso: resolved.runAtIso,
|
|
803
|
-
triggerInvocation: resolved.triggerInvocation,
|
|
804
|
-
agentInvocation: resolved.agentInvocation,
|
|
805
|
-
command: resolved.command,
|
|
806
|
-
args: resolved.args,
|
|
807
|
-
agent: resolved.agent,
|
|
808
|
-
repoPath: resolved.repoPath,
|
|
809
|
-
envPath: resolved.envPath,
|
|
810
|
-
nodePath: resolved.nodePath,
|
|
811
|
-
npxPath: resolved.npxPath,
|
|
812
|
-
agentPath: resolved.agentPath,
|
|
813
|
-
legacyIdeaFile: resolved.legacyIdeaFile,
|
|
814
|
-
paths: resolved.paths,
|
|
815
|
-
dryRun: options.dryRun,
|
|
816
|
-
};
|
|
817
|
-
if (options.dryRun) {
|
|
818
|
-
const createResult = await backend.create(createInput);
|
|
819
|
-
if (!createResult.ok) {
|
|
820
|
-
return { ok: false, error: createResult.error ?? "Backend dry-run failed." };
|
|
821
|
-
}
|
|
822
|
-
const metadata = buildScheduleMetadata(resolved, createResult, createdAtIso);
|
|
823
|
-
return { ok: true, dryRun: true, metadata, metadataPath, createResult, resolved };
|
|
824
|
-
}
|
|
825
|
-
await ensureScheduleDirectories(deps.homeDir, deps.platform);
|
|
826
|
-
const createResult = await backend.create(createInput);
|
|
827
|
-
if (!createResult.ok) {
|
|
828
|
-
return { ok: false, error: createResult.error ?? "Scheduler backend create failed." };
|
|
829
|
-
}
|
|
830
|
-
const metadata = buildScheduleMetadata(resolved, createResult, createdAtIso);
|
|
831
|
-
// Append the `scheduled` event only after the backend created a real OS job.
|
|
832
|
-
const scheduledAtIso = new Date(deps.now ? deps.now() : Date.now()).toISOString();
|
|
833
|
-
metadata.run_history = [...(metadata.run_history ?? []), { status: "scheduled", at: scheduledAtIso }];
|
|
834
|
-
try {
|
|
835
|
-
await writeScheduleMetadata(metadata, deps.homeDir, deps.platform);
|
|
836
|
-
}
|
|
837
|
-
catch (error) {
|
|
838
|
-
// Native unit/job exists but we can't persist metadata — roll back the unit.
|
|
839
|
-
await backend.cancel({ deps, metadata }).catch(() => undefined);
|
|
840
|
-
return {
|
|
841
|
-
ok: false,
|
|
842
|
-
error: `Created the native ${createResult.backend} unit but failed to persist schedule metadata ` +
|
|
843
|
-
`(${error instanceof Error ? error.message : String(error)}); attempted to cancel the native unit.`,
|
|
844
|
-
};
|
|
845
|
-
}
|
|
846
|
-
return { ok: true, dryRun: false, metadata, metadataPath, createResult, resolved };
|
|
847
|
-
}
|
|
848
|
-
/** Format a prereq status block (presence/readiness only, never secret values). */
|
|
849
|
-
function formatPrereqStatus(agent, prereq) {
|
|
850
|
-
const lines = [
|
|
851
|
-
` agent binary: ${prereq.agentBinaryResolved ? "resolved" : "MISSING"}`,
|
|
852
|
-
` repo path: ${prereq.repoPathExists ? "exists" : "MISSING"}`,
|
|
853
|
-
];
|
|
854
|
-
if (agent === "cursor-agent") {
|
|
855
|
-
lines.push(` CURSOR_API_KEY set: ${prereq.cursorApiKeyPresent ? "yes" : "no"}`);
|
|
856
|
-
}
|
|
857
|
-
else {
|
|
858
|
-
lines.push(` Bridge credential: ${prereq.bridgeCredentialResolved ? "resolved" : "not resolved"}`);
|
|
859
|
-
}
|
|
860
|
-
return lines;
|
|
861
|
-
}
|
|
862
|
-
/**
|
|
863
|
-
* Format a create result for display. Real create and dry-run both show the
|
|
864
|
-
* delegated command, normalized args, schedule time, agent, repo path, metadata
|
|
865
|
-
* path, agent binary, trigger invocation, agent invocation argv, and the rendered
|
|
866
|
-
* target command line. Dry-run additionally prints the full concatenated prompt,
|
|
867
|
-
* the prerequisite/auth status (secret-free), and the backend artifacts.
|
|
868
|
-
*/
|
|
869
|
-
export function formatScheduleCreateResult(result) {
|
|
870
|
-
if (!result.ok)
|
|
871
|
-
return `Error: ${result.error}`;
|
|
872
|
-
const m = result.metadata;
|
|
873
|
-
const r = result.resolved;
|
|
874
|
-
const targetCommandLine = buildTargetCommandLine({
|
|
875
|
-
commandName: r.command,
|
|
876
|
-
args: r.args,
|
|
877
|
-
scheduledAt: r.runAtIso,
|
|
878
|
-
autoApprove: r.autoApprove,
|
|
879
|
-
schema: r.schema,
|
|
880
|
-
});
|
|
881
|
-
const lines = [];
|
|
882
|
-
lines.push(result.dryRun ? "[dry-run] schedule-run create preview" : "Schedule created.");
|
|
883
|
-
lines.push(` id: ${m.id}`);
|
|
884
|
-
lines.push(` backend: ${m.backend}`);
|
|
885
|
-
lines.push(` command: ${r.command}`);
|
|
886
|
-
lines.push(` args: ${r.args.length > 0 ? r.args.join(" ") : "(none)"}`);
|
|
887
|
-
lines.push(` run at: ${m.run_at_iso}`);
|
|
888
|
-
lines.push(` agent: ${m.agent}`);
|
|
889
|
-
lines.push(` auto-approve: ${m.auto_approve ? "yes" : "no"}`);
|
|
890
|
-
lines.push(` metadata: ${result.metadataPath}${result.dryRun ? " (not written in dry-run)" : ""}`);
|
|
891
|
-
lines.push(` repo path: ${m.repo_path}`);
|
|
892
|
-
if (m.idea_file)
|
|
893
|
-
lines.push(` idea file: ${m.idea_file}`);
|
|
894
|
-
lines.push(` stdout log: ${m.logs.stdout}`);
|
|
895
|
-
lines.push(` stderr log: ${m.logs.stderr}`);
|
|
896
|
-
lines.push(` agent binary: ${r.agentPath}`);
|
|
897
|
-
lines.push(` target command: ${targetCommandLine}`);
|
|
898
|
-
lines.push(` trigger invocation: ${[r.triggerInvocation.exe, ...r.triggerInvocation.args].join(" ")}`);
|
|
899
|
-
lines.push(` agent invocation: ${[r.agentInvocation.exe, ...r.agentInvocation.args].join(" ")}`);
|
|
900
|
-
if (m.unit_paths.length > 0) {
|
|
901
|
-
lines.push(` unit paths: ${m.unit_paths.join(", ")}`);
|
|
902
|
-
}
|
|
903
|
-
for (const warning of r.warnings) {
|
|
904
|
-
lines.push(` warning: ${warning}`);
|
|
905
|
-
}
|
|
906
|
-
if (result.dryRun) {
|
|
907
|
-
lines.push("");
|
|
908
|
-
lines.push("prerequisites (secret-free):");
|
|
909
|
-
lines.push(...formatPrereqStatus(r.agent, r.prereq));
|
|
910
|
-
lines.push("");
|
|
911
|
-
lines.push("----- rendered prompt -----");
|
|
912
|
-
lines.push(r.renderedPrompt);
|
|
913
|
-
for (const artifact of result.createResult.artifacts) {
|
|
914
|
-
lines.push("");
|
|
915
|
-
lines.push(`----- ${artifact.kind}: ${artifact.path} -----`);
|
|
916
|
-
lines.push(artifact.content);
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
return lines.join("\n");
|
|
920
|
-
}
|
|
921
|
-
/** Apply id/agent/backend filters consistently. */
|
|
922
|
-
export function filterScheduleMetadata(rows, filters) {
|
|
923
|
-
return rows.filter((row) => {
|
|
924
|
-
if (filters.id !== undefined && row.id !== filters.id)
|
|
925
|
-
return false;
|
|
926
|
-
if (filters.agent !== undefined && row.agent !== filters.agent)
|
|
927
|
-
return false;
|
|
928
|
-
if (filters.backend !== undefined && row.backend !== filters.backend)
|
|
929
|
-
return false;
|
|
930
|
-
return true;
|
|
931
|
-
});
|
|
932
|
-
}
|
|
933
|
-
/** Read local metadata, reconcile each backend group with native state. */
|
|
934
|
-
export async function orchestrateScheduleList(options, deps) {
|
|
935
|
-
const rows = await listScheduleMetadata(deps.homeDir, deps.platform);
|
|
936
|
-
const warnings = [];
|
|
937
|
-
const valid = [];
|
|
938
|
-
for (const row of rows) {
|
|
939
|
-
if (row.ok) {
|
|
940
|
-
valid.push(row.metadata);
|
|
941
|
-
}
|
|
942
|
-
else {
|
|
943
|
-
warnings.push(`Skipping malformed schedule metadata ${row.path}: ${row.error}`);
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
const filtered = filterScheduleMetadata(valid, {
|
|
947
|
-
id: options.id,
|
|
948
|
-
agent: options.agent,
|
|
949
|
-
backend: options.backend,
|
|
950
|
-
});
|
|
951
|
-
// Group by backend so each backend's list() is called once with its rows.
|
|
952
|
-
const byBackend = new Map();
|
|
953
|
-
for (const row of filtered) {
|
|
954
|
-
const group = byBackend.get(row.backend) ?? [];
|
|
955
|
-
group.push(row);
|
|
956
|
-
byBackend.set(row.backend, group);
|
|
957
|
-
}
|
|
958
|
-
const entries = [];
|
|
959
|
-
for (const [backendName, recorded] of byBackend) {
|
|
960
|
-
const backend = getSchedulerBackendByName(backendName);
|
|
961
|
-
if (!backend) {
|
|
962
|
-
for (const metadata of recorded) {
|
|
963
|
-
entries.push({ metadata, status: "backend-unavailable", detail: "unknown backend" });
|
|
964
|
-
}
|
|
965
|
-
continue;
|
|
966
|
-
}
|
|
967
|
-
const groupEntries = await backend.list({ deps, recorded });
|
|
968
|
-
entries.push(...groupEntries);
|
|
969
|
-
}
|
|
970
|
-
// Stable order by id for deterministic output.
|
|
971
|
-
entries.sort((a, b) => a.metadata.id.localeCompare(b.metadata.id));
|
|
972
|
-
return { entries, warnings };
|
|
973
|
-
}
|
|
974
|
-
/** Derive the displayed command (legacy rows show `full-automation`). */
|
|
975
|
-
export function scheduleCommandLabel(m) {
|
|
976
|
-
if (m.command)
|
|
977
|
-
return m.command;
|
|
978
|
-
if (m.idea_file)
|
|
979
|
-
return "full-automation";
|
|
980
|
-
return "(unknown)";
|
|
981
|
-
}
|
|
982
|
-
/** Latest run-history status, or empty string when no history is recorded. */
|
|
983
|
-
export function latestRunStatus(m) {
|
|
984
|
-
const h = m.run_history;
|
|
985
|
-
return h && h.length > 0 ? h[h.length - 1].status : "";
|
|
986
|
-
}
|
|
987
|
-
/** Format a list report as a table (or JSON when requested). */
|
|
988
|
-
export function formatScheduleListResult(report, json) {
|
|
989
|
-
if (json) {
|
|
990
|
-
// Wrap as { entries, warnings } so malformed-metadata warnings are not lost
|
|
991
|
-
// for consumers piping `schedule-run list --json` into tooling.
|
|
992
|
-
return JSON.stringify({
|
|
993
|
-
entries: report.entries.map((e) => ({
|
|
994
|
-
id: e.metadata.id,
|
|
995
|
-
command: scheduleCommandLabel(e.metadata),
|
|
996
|
-
args: e.metadata.args ?? [],
|
|
997
|
-
scheduled_at: e.metadata.scheduled_at ?? e.metadata.run_at_iso,
|
|
998
|
-
run_at: e.metadata.run_at_iso,
|
|
999
|
-
backend: e.metadata.backend,
|
|
1000
|
-
agent: e.metadata.agent,
|
|
1001
|
-
native_status: e.status,
|
|
1002
|
-
latest_run_status: latestRunStatus(e.metadata),
|
|
1003
|
-
run_history: e.metadata.run_history ?? [],
|
|
1004
|
-
status: e.status,
|
|
1005
|
-
unit_path: e.metadata.unit_path,
|
|
1006
|
-
})),
|
|
1007
|
-
warnings: report.warnings,
|
|
1008
|
-
}, null, 2);
|
|
1009
|
-
}
|
|
1010
|
-
const lines = [];
|
|
1011
|
-
for (const w of report.warnings)
|
|
1012
|
-
lines.push(`Warning: ${w}`);
|
|
1013
|
-
if (report.entries.length === 0) {
|
|
1014
|
-
lines.push("No schedules found.");
|
|
1015
|
-
return lines.join("\n");
|
|
1016
|
-
}
|
|
1017
|
-
lines.push(["ID", "COMMAND", "RUN_AT", "BACKEND", "AGENT", "NATIVE", "LATEST", "UNIT_PATH"].join(" "));
|
|
1018
|
-
for (const e of report.entries) {
|
|
1019
|
-
lines.push([
|
|
1020
|
-
e.metadata.id,
|
|
1021
|
-
scheduleCommandLabel(e.metadata),
|
|
1022
|
-
e.metadata.run_at_iso,
|
|
1023
|
-
e.metadata.backend,
|
|
1024
|
-
e.metadata.agent,
|
|
1025
|
-
e.status,
|
|
1026
|
-
latestRunStatus(e.metadata) || "-",
|
|
1027
|
-
e.metadata.unit_path ?? "-",
|
|
1028
|
-
].join(" "));
|
|
1029
|
-
}
|
|
1030
|
-
return lines.join("\n");
|
|
1031
|
-
}
|
|
1032
|
-
/** Cancel a schedule by id (with optional agent/backend filters). */
|
|
1033
|
-
export async function orchestrateScheduleCancel(options, deps) {
|
|
1034
|
-
const metadata = await readScheduleMetadata(options.id, deps.homeDir, deps.platform);
|
|
1035
|
-
if (!metadata) {
|
|
1036
|
-
return { ok: false, notFound: true, error: `No schedule found with id '${options.id}'.` };
|
|
1037
|
-
}
|
|
1038
|
-
if (options.agent !== undefined && metadata.agent !== options.agent) {
|
|
1039
|
-
return {
|
|
1040
|
-
ok: false,
|
|
1041
|
-
notFound: true,
|
|
1042
|
-
error: `Schedule '${options.id}' does not match agent filter '${options.agent}'.`,
|
|
1043
|
-
};
|
|
1044
|
-
}
|
|
1045
|
-
if (options.backend !== undefined && metadata.backend !== options.backend) {
|
|
1046
|
-
return {
|
|
1047
|
-
ok: false,
|
|
1048
|
-
notFound: true,
|
|
1049
|
-
error: `Schedule '${options.id}' does not match backend filter '${options.backend}'.`,
|
|
1050
|
-
};
|
|
1051
|
-
}
|
|
1052
|
-
const backend = getSchedulerBackendByName(metadata.backend);
|
|
1053
|
-
if (!backend) {
|
|
1054
|
-
return { ok: false, error: `Unknown backend '${metadata.backend}' recorded for '${options.id}'.` };
|
|
1055
|
-
}
|
|
1056
|
-
const cancelResult = await backend.cancel({ deps, metadata });
|
|
1057
|
-
if (!cancelResult.ok) {
|
|
1058
|
-
return { ok: false, error: cancelResult.error ?? "Backend cancel failed." };
|
|
1059
|
-
}
|
|
1060
|
-
// Best-effort: record a `canceled` event before deleting metadata. This is
|
|
1061
|
-
// primarily useful if deletion later fails, or if cancellation is refactored
|
|
1062
|
-
// to retain records; a failure here must not mask a successful native cancel.
|
|
1063
|
-
const canceledAtIso = new Date(deps.now ? deps.now() : Date.now()).toISOString();
|
|
1064
|
-
await appendScheduleRunEvent(options.id, { status: "canceled", at: canceledAtIso }, deps.homeDir, deps.platform).catch(() => undefined);
|
|
1065
|
-
// Stale or removed both delete local metadata; logs are preserved by the store.
|
|
1066
|
-
await deleteScheduleMetadata(options.id, deps.homeDir, deps.platform);
|
|
1067
|
-
return {
|
|
1068
|
-
ok: true,
|
|
1069
|
-
id: options.id,
|
|
1070
|
-
backend: metadata.backend,
|
|
1071
|
-
nativeRemoved: cancelResult.nativeRemoved,
|
|
1072
|
-
stale: cancelResult.stale,
|
|
1073
|
-
metadataRemoved: true,
|
|
1074
|
-
};
|
|
1075
|
-
}
|
|
1076
|
-
/** Format a cancel result. */
|
|
1077
|
-
export function formatScheduleCancelResult(result) {
|
|
1078
|
-
if (!result.ok)
|
|
1079
|
-
return `Error: ${result.error}`;
|
|
1080
|
-
return [
|
|
1081
|
-
`Schedule '${result.id}' canceled.`,
|
|
1082
|
-
` backend: ${result.backend}`,
|
|
1083
|
-
` native removed: ${result.nativeRemoved ? "yes" : `no${result.stale ? " (stale)" : ""}`}`,
|
|
1084
|
-
` metadata removed: ${result.metadataRemoved ? "yes" : "no"}`,
|
|
1085
|
-
" logs: preserved",
|
|
1086
|
-
].join("\n");
|
|
1087
|
-
}
|
|
1088
|
-
/**
|
|
1089
|
-
* Read-only diagnostics: platform support, backend order/availability, both agent
|
|
1090
|
-
* binaries, and secret-free auth readiness for each agent. Stays zero-argument
|
|
1091
|
-
* and environment-oriented — command-specific prompt rendering belongs to
|
|
1092
|
-
* `create --dry-run`, never here.
|
|
1093
|
-
*/
|
|
1094
|
-
export async function orchestrateScheduleDoctor(deps) {
|
|
1095
|
-
const platformResult = getSchedulerBackendsForPlatform(deps.platform);
|
|
1096
|
-
const envPath = deps.env.PATH ?? deps.env.Path ?? "";
|
|
1097
|
-
const claudeResolved = Boolean(await resolveCommandOnPath("claude", envPath, deps));
|
|
1098
|
-
const cursorResolved = Boolean(await resolveCommandOnPath("cursor-agent", envPath, deps));
|
|
1099
|
-
const npxResolved = Boolean(await resolveCommandOnPath("npx", envPath, deps));
|
|
1100
|
-
const cursorApiKeyPresent = Boolean(deps.env.CURSOR_API_KEY);
|
|
1101
|
-
const bridgeCredentialResolved = deps.bridgeCredentialResolved?.() ?? Boolean(deps.env.BAPI_API_KEY);
|
|
1102
|
-
if (!platformResult.ok) {
|
|
1103
|
-
return {
|
|
1104
|
-
platform: deps.platform,
|
|
1105
|
-
platformSupported: false,
|
|
1106
|
-
candidateBackends: [],
|
|
1107
|
-
backendAvailability: [],
|
|
1108
|
-
claudeResolved,
|
|
1109
|
-
cursorResolved,
|
|
1110
|
-
npxResolved,
|
|
1111
|
-
cursorApiKeyPresent,
|
|
1112
|
-
bridgeCredentialResolved,
|
|
1113
|
-
unsupportedMessage: platformResult.error,
|
|
1114
|
-
};
|
|
1115
|
-
}
|
|
1116
|
-
const candidateBackends = platformResult.backends.map((b) => b.name);
|
|
1117
|
-
const backendAvailability = [];
|
|
1118
|
-
for (const backend of platformResult.backends) {
|
|
1119
|
-
backendAvailability.push({ backend: backend.name, available: await backend.isAvailable(deps) });
|
|
1120
|
-
}
|
|
1121
|
-
return {
|
|
1122
|
-
platform: deps.platform,
|
|
1123
|
-
platformSupported: true,
|
|
1124
|
-
candidateBackends,
|
|
1125
|
-
backendAvailability,
|
|
1126
|
-
claudeResolved,
|
|
1127
|
-
cursorResolved,
|
|
1128
|
-
npxResolved,
|
|
1129
|
-
cursorApiKeyPresent,
|
|
1130
|
-
bridgeCredentialResolved,
|
|
1131
|
-
};
|
|
1132
|
-
}
|
|
1133
|
-
/** Format the doctor report (table or JSON). Never prints secret values. */
|
|
1134
|
-
export function formatScheduleDoctorReport(report, json) {
|
|
1135
|
-
if (json)
|
|
1136
|
-
return JSON.stringify(report, null, 2);
|
|
1137
|
-
const lines = [
|
|
1138
|
-
"schedule-run doctor (read-only diagnostics)",
|
|
1139
|
-
`Platform: ${report.platform}`,
|
|
1140
|
-
];
|
|
1141
|
-
if (!report.platformSupported) {
|
|
1142
|
-
lines.push(report.unsupportedMessage ?? unsupportedSchedulerPlatformMessage(report.platform));
|
|
1143
|
-
}
|
|
1144
|
-
else {
|
|
1145
|
-
lines.push(`Candidate backends (in order): ${report.candidateBackends.join(", ")}`);
|
|
1146
|
-
for (const a of report.backendAvailability) {
|
|
1147
|
-
lines.push(` ${a.available ? "AVAILABLE " : "UNAVAILABLE"} ${a.backend}`);
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
lines.push(`claude on PATH: ${report.claudeResolved ? "yes" : "no"}`);
|
|
1151
|
-
lines.push(`cursor-agent on PATH: ${report.cursorResolved ? "yes" : "no"}`);
|
|
1152
|
-
lines.push(`npx on PATH: ${report.npxResolved ? "yes" : "no"}`);
|
|
1153
|
-
lines.push(`CURSOR_API_KEY set: ${report.cursorApiKeyPresent ? "yes" : "no"}`);
|
|
1154
|
-
lines.push(`Bridge credential: ${report.bridgeCredentialResolved ? "resolved" : "not resolved"}`);
|
|
1155
|
-
return lines.join("\n");
|
|
1156
|
-
}
|
|
1157
|
-
/** Current time as ISO from the injectable clock. */
|
|
1158
|
-
function nowIso(deps) {
|
|
1159
|
-
return new Date(deps.now ? deps.now() : Date.now()).toISOString();
|
|
1160
|
-
}
|
|
1161
|
-
/**
|
|
1162
|
-
* The hidden `_execute <id>` shim the OS unit runs at fire time (BAPI-351). It
|
|
1163
|
-
* reads the local schedule metadata, validates the stored agent invocation,
|
|
1164
|
-
* records a `started` event, spawns the agent via the list-based `runCommand`
|
|
1165
|
-
* boundary (NEVER a shell string) with the schedule's cwd + baked env, forwards
|
|
1166
|
-
* the agent's stdout/stderr to its own streams (so existing OS log redirection
|
|
1167
|
-
* captures them), then records `completed`/`failed` and returns the agent's exit
|
|
1168
|
-
* code. Run-history writes are best-effort and never mask the agent's exit code;
|
|
1169
|
-
* env values (including any secrets) are never printed.
|
|
1170
|
-
*/
|
|
1171
|
-
export async function orchestrateScheduleExecute(options, deps, io) {
|
|
1172
|
-
const metadata = await readScheduleMetadata(options.id, deps.homeDir, deps.platform);
|
|
1173
|
-
if (!metadata) {
|
|
1174
|
-
return { ok: false, exitCode: 1, error: `No schedule found with id '${options.id}'.` };
|
|
1175
|
-
}
|
|
1176
|
-
const agentInvocation = metadata.agent_invocation ?? metadata.invocation;
|
|
1177
|
-
if (!agentInvocation || !agentInvocation.exe) {
|
|
1178
|
-
await appendScheduleRunEvent(options.id, { status: "failed", at: nowIso(deps), message: "missing agent_invocation" }, deps.homeDir, deps.platform).catch(() => undefined);
|
|
1179
|
-
return { ok: false, exitCode: 1, error: `Schedule '${options.id}' has no agent invocation to run.` };
|
|
1180
|
-
}
|
|
1181
|
-
await appendScheduleRunEvent(options.id, { status: "started", at: nowIso(deps) }, deps.homeDir, deps.platform).catch(() => undefined);
|
|
1182
|
-
// Child env: base env + baked PATH + schedule-context variables. No secrets are
|
|
1183
|
-
// synthesized here; only values already present in metadata/env are forwarded.
|
|
1184
|
-
const env = { ...deps.env };
|
|
1185
|
-
if (metadata.env_path) {
|
|
1186
|
-
env.PATH = metadata.env_path;
|
|
1187
|
-
if (deps.platform === "win32")
|
|
1188
|
-
env.Path = metadata.env_path;
|
|
1189
|
-
}
|
|
1190
|
-
env.BRIDGE_GPT_SCHEDULE_ID = metadata.id;
|
|
1191
|
-
if (metadata.command)
|
|
1192
|
-
env.BRIDGE_GPT_COMMAND = metadata.command;
|
|
1193
|
-
if (metadata.args)
|
|
1194
|
-
env.BRIDGE_GPT_COMMAND_ARGS_JSON = JSON.stringify(metadata.args);
|
|
1195
|
-
if (metadata.repo_path)
|
|
1196
|
-
env.BRIDGE_GPT_REPO_PATH = metadata.repo_path;
|
|
1197
|
-
if (metadata.agent)
|
|
1198
|
-
env.BRIDGE_GPT_AGENT = metadata.agent;
|
|
1199
|
-
if (metadata.agent_path)
|
|
1200
|
-
env.BRIDGE_GPT_AGENT_PATH = metadata.agent_path;
|
|
1201
|
-
if (metadata.idea_file)
|
|
1202
|
-
env.BRIDGE_GPT_IDEA_FILE = metadata.idea_file;
|
|
1203
|
-
let result;
|
|
1204
|
-
try {
|
|
1205
|
-
result = await deps.runCommand(agentInvocation.exe, agentInvocation.args, {
|
|
1206
|
-
cwd: metadata.repo_path,
|
|
1207
|
-
env,
|
|
1208
|
-
});
|
|
1209
|
-
}
|
|
1210
|
-
catch (error) {
|
|
1211
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
1212
|
-
await appendScheduleRunEvent(options.id, { status: "failed", at: nowIso(deps), message: `agent launch failed: ${msg}` }, deps.homeDir, deps.platform).catch(() => undefined);
|
|
1213
|
-
return { ok: false, exitCode: 1, error: `Failed to launch agent: ${msg}` };
|
|
1214
|
-
}
|
|
1215
|
-
if (result.stdout)
|
|
1216
|
-
io.writeStdout(result.stdout);
|
|
1217
|
-
if (result.stderr)
|
|
1218
|
-
io.writeStderr(result.stderr);
|
|
1219
|
-
if (result.exitCode === 0) {
|
|
1220
|
-
await appendScheduleRunEvent(options.id, { status: "completed", at: nowIso(deps), exit_code: 0 }, deps.homeDir, deps.platform).catch(() => undefined);
|
|
1221
|
-
return { ok: true, exitCode: 0 };
|
|
1222
|
-
}
|
|
1223
|
-
await appendScheduleRunEvent(options.id, { status: "failed", at: nowIso(deps), exit_code: result.exitCode }, deps.homeDir, deps.platform).catch(() => undefined);
|
|
1224
|
-
return { ok: false, exitCode: result.exitCode };
|
|
1225
|
-
}
|
|
1226
|
-
/**
|
|
1227
|
-
* CLI entry for `schedule-run`. Returns a numeric process exit code. Help → 0;
|
|
1228
|
-
* parser/validation errors → 1 (printed with usage to stderr); create/list/
|
|
1229
|
-
* cancel/doctor dispatch otherwise. Unexpected errors are caught at this
|
|
1230
|
-
* boundary, logged internally to stderr, and surfaced to the user sanitized.
|
|
1231
|
-
*/
|
|
1232
|
-
export async function runScheduleRunCli(argv, overrides = {}) {
|
|
1233
|
-
const log = overrides.log ?? ((m) => console.log(m));
|
|
1234
|
-
const errorLog = overrides.errorLog ?? ((m) => console.error(m));
|
|
1235
|
-
const parsed = parseScheduleRunArgs(argv);
|
|
1236
|
-
if (parsed.status === "help") {
|
|
1237
|
-
log(parsed.usage);
|
|
1238
|
-
return 0;
|
|
1239
|
-
}
|
|
1240
|
-
if (parsed.status === "error") {
|
|
1241
|
-
errorLog(`Error: ${parsed.message}`);
|
|
1242
|
-
errorLog("");
|
|
1243
|
-
errorLog(getScheduleRunUsage());
|
|
1244
|
-
return 1;
|
|
1245
|
-
}
|
|
1246
|
-
const deps = overrides.deps ?? createDefaultScheduleRunDeps();
|
|
1247
|
-
try {
|
|
1248
|
-
switch (parsed.subcommand) {
|
|
1249
|
-
case "create": {
|
|
1250
|
-
const result = await orchestrateScheduleCreate(parsed.options, deps);
|
|
1251
|
-
if (!result.ok) {
|
|
1252
|
-
errorLog(formatScheduleCreateResult(result));
|
|
1253
|
-
return 1;
|
|
1254
|
-
}
|
|
1255
|
-
log(formatScheduleCreateResult(result));
|
|
1256
|
-
return 0;
|
|
1257
|
-
}
|
|
1258
|
-
case "list": {
|
|
1259
|
-
const report = await orchestrateScheduleList(parsed.options, deps);
|
|
1260
|
-
log(formatScheduleListResult(report, parsed.options.json));
|
|
1261
|
-
return 0;
|
|
1262
|
-
}
|
|
1263
|
-
case "cancel": {
|
|
1264
|
-
const result = await orchestrateScheduleCancel(parsed.options, deps);
|
|
1265
|
-
if (!result.ok) {
|
|
1266
|
-
errorLog(formatScheduleCancelResult(result));
|
|
1267
|
-
return 1;
|
|
1268
|
-
}
|
|
1269
|
-
log(formatScheduleCancelResult(result));
|
|
1270
|
-
return 0;
|
|
1271
|
-
}
|
|
1272
|
-
case "doctor": {
|
|
1273
|
-
const report = await orchestrateScheduleDoctor(deps);
|
|
1274
|
-
log(formatScheduleDoctorReport(report, parsed.options.json));
|
|
1275
|
-
return report.platformSupported ? 0 : 1;
|
|
1276
|
-
}
|
|
1277
|
-
case "_execute": {
|
|
1278
|
-
const io = {
|
|
1279
|
-
writeStdout: overrides.writeStdout ?? ((chunk) => process.stdout.write(chunk)),
|
|
1280
|
-
writeStderr: overrides.writeStderr ?? ((chunk) => process.stderr.write(chunk)),
|
|
1281
|
-
};
|
|
1282
|
-
const result = await orchestrateScheduleExecute(parsed.options, deps, io);
|
|
1283
|
-
if (!result.ok && result.error) {
|
|
1284
|
-
errorLog(`Error: ${result.error}`);
|
|
1285
|
-
}
|
|
1286
|
-
return result.exitCode;
|
|
1287
|
-
}
|
|
1288
|
-
}
|
|
1289
|
-
}
|
|
1290
|
-
catch (error) {
|
|
1291
|
-
// Log the internal detail to stderr for local diagnostics, but surface a
|
|
1292
|
-
// sanitized message as the primary user-facing error.
|
|
1293
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
1294
|
-
errorLog(`Internal error: ${detail}`);
|
|
1295
|
-
errorLog("Error: schedule-run failed unexpectedly. See the message above for local diagnostics.");
|
|
1296
|
-
return 1;
|
|
1297
|
-
}
|
|
1298
|
-
// Unreachable: the switch is exhaustive over the parsed subcommands.
|
|
1299
|
-
return 1;
|
|
1300
|
-
}
|