@bridge_gpt/mcp-server 0.2.0 → 0.2.2
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 +56 -54
- package/build/agent-launchers/claude.js +25 -17
- package/build/agent-launchers/cursor.js +65 -0
- package/build/agent-launchers/index.js +23 -8
- package/build/agent-registry.js +68 -0
- package/build/command-catalog.js +376 -0
- package/build/commands.generated.js +8 -5
- package/build/index.js +406 -120
- package/build/mcp-provisioning.js +94 -1
- package/build/pipeline-utils.js +0 -33
- package/build/pipelines.generated.js +2 -31
- package/build/readme.generated.js +3 -0
- package/build/schedule-run.js +436 -88
- package/build/schedule-store.js +41 -1
- package/build/scheduled-prompt.js +109 -0
- package/build/scheduler-backends/at-fallback.js +5 -10
- package/build/scheduler-backends/escaping.js +40 -10
- package/build/scheduler-backends/launchd.js +23 -14
- package/build/scheduler-backends/systemd-user.js +32 -19
- package/build/scheduler-backends/task-scheduler.js +8 -13
- package/build/start-tickets.js +459 -30
- package/build/version.generated.js +1 -1
- package/package.json +4 -3
- package/pipelines/implement-ticket.json +2 -28
- package/smoke-test/SMOKE-TEST.md +61 -18
package/build/schedule-store.js
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { promises as fs } from "node:fs";
|
|
13
13
|
import { pathApiForPlatform } from "./scheduler-backends/types.js";
|
|
14
|
+
/** Upper bound on retained run-history events per schedule (oldest dropped). */
|
|
15
|
+
export const MAX_RUN_HISTORY_EVENTS = 50;
|
|
16
|
+
/** Monotonic suffix source for atomic temp files (avoids same-tick collisions). */
|
|
17
|
+
let atomicWriteCounter = 0;
|
|
14
18
|
/**
|
|
15
19
|
* Build the schedule root from the injected home directory and platform path
|
|
16
20
|
* API: `~/.bridge-gpt/schedules` on POSIX, `%USERPROFILE%\.bridge-gpt\schedules`
|
|
@@ -47,10 +51,46 @@ export async function ensureScheduleDirectories(homeDir, platform) {
|
|
|
47
51
|
/**
|
|
48
52
|
* Persist one pretty-printed JSON file per schedule id with a trailing newline.
|
|
49
53
|
* Called only AFTER the scheduler backend successfully creates the unit/job.
|
|
54
|
+
*
|
|
55
|
+
* The write is atomic (BAPI-351): the content is written to a temp file in the
|
|
56
|
+
* same directory and renamed into place, so a concurrent `_execute` run-history
|
|
57
|
+
* append never observes a partially-written metadata file, and a crash mid-write
|
|
58
|
+
* leaves the prior valid file intact rather than a truncated one.
|
|
50
59
|
*/
|
|
51
60
|
export async function writeScheduleMetadata(metadata, homeDir, platform) {
|
|
52
61
|
const { metadataPath } = getSchedulePaths(metadata.id, homeDir, platform);
|
|
53
|
-
|
|
62
|
+
const content = `${JSON.stringify(metadata, null, 2)}\n`;
|
|
63
|
+
const tmpPath = `${metadataPath}.tmp-${process.pid}-${atomicWriteCounter++}`;
|
|
64
|
+
try {
|
|
65
|
+
await fs.writeFile(tmpPath, content, "utf-8");
|
|
66
|
+
await fs.rename(tmpPath, metadataPath);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
// Best-effort cleanup of the temp file; never leave it orphaned, and never
|
|
70
|
+
// partially overwrite the real metadata file.
|
|
71
|
+
await fs.unlink(tmpPath).catch(() => undefined);
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Append a bounded run-history event to a schedule's metadata and rewrite it
|
|
77
|
+
* atomically (BAPI-351). Tolerant of legacy records that have no `run_history`
|
|
78
|
+
* (initializes it). Returns the updated metadata, or `null` if the schedule
|
|
79
|
+
* metadata no longer exists.
|
|
80
|
+
*/
|
|
81
|
+
export async function appendScheduleRunEvent(id, event, homeDir, platform) {
|
|
82
|
+
const metadata = await readScheduleMetadata(id, homeDir, platform);
|
|
83
|
+
if (!metadata)
|
|
84
|
+
return null;
|
|
85
|
+
const history = Array.isArray(metadata.run_history) ? [...metadata.run_history] : [];
|
|
86
|
+
history.push(event);
|
|
87
|
+
// Keep the newest events when bounding (drop from the front).
|
|
88
|
+
metadata.run_history =
|
|
89
|
+
history.length > MAX_RUN_HISTORY_EVENTS
|
|
90
|
+
? history.slice(history.length - MAX_RUN_HISTORY_EVENTS)
|
|
91
|
+
: history;
|
|
92
|
+
await writeScheduleMetadata(metadata, homeDir, platform);
|
|
93
|
+
return metadata;
|
|
54
94
|
}
|
|
55
95
|
/** Read and parse a single schedule metadata JSON file; `null` when missing. */
|
|
56
96
|
export async function readScheduleMetadata(id, homeDir, platform) {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { schemaSupportsAutoFlag } from "./command-catalog.js";
|
|
2
|
+
/** The fixed late-fire threshold, in seconds, shared by every scheduled run. */
|
|
3
|
+
export const LATE_FIRE_THRESHOLD_SECONDS = 60;
|
|
4
|
+
/**
|
|
5
|
+
* Quote a single argument token for safe inclusion in the rendered prompt while
|
|
6
|
+
* preserving its boundary. Simple tokens (alphanumerics plus a small set of
|
|
7
|
+
* path/flag-safe punctuation) are left bare; anything containing whitespace or
|
|
8
|
+
* prompt/markup-sensitive characters is wrapped in double quotes with embedded
|
|
9
|
+
* double quotes escaped. This is prompt-token quoting, NOT shell quoting — no
|
|
10
|
+
* shell ever sees these strings.
|
|
11
|
+
*/
|
|
12
|
+
export function quotePromptToken(token) {
|
|
13
|
+
if (token === "")
|
|
14
|
+
return '""';
|
|
15
|
+
// Safe: letters, digits, and characters that never need quoting in a prompt
|
|
16
|
+
// (path separators, ISO-timestamp punctuation, flag dashes, etc.).
|
|
17
|
+
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(token))
|
|
18
|
+
return token;
|
|
19
|
+
return `"${token.replace(/"/g, '\\"')}"`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Compute the augmented argv tokens the scheduled run delegates to the command:
|
|
23
|
+
* the normalized `input.args`, plus (only where the command can parse them)
|
|
24
|
+
* `--scheduled-at <ISO>` and `--auto`.
|
|
25
|
+
*
|
|
26
|
+
* `--scheduled-at` is appended ONLY for the legacy `full-automation` command,
|
|
27
|
+
* whose Stage 0 parses it and whose drift stage consumes it. Every other command
|
|
28
|
+
* rejects unrecognized flags and halts (e.g. `start-tickets` stops on any
|
|
29
|
+
* unsupported flag), and the shared late-fire gate already embeds the scheduled
|
|
30
|
+
* time — so injecting `--scheduled-at` into their argv would break an otherwise
|
|
31
|
+
* `schedulable: true` command for no benefit.
|
|
32
|
+
*
|
|
33
|
+
* `--auto` is appended when auto-approve is set AND the command supports it (its
|
|
34
|
+
* schema declares a boolean `--auto` flag, or it is `full-automation`). It is
|
|
35
|
+
* never duplicated if already present in `input.args`.
|
|
36
|
+
*
|
|
37
|
+
* This is the SINGLE source of the delegated argv: both the rendered target
|
|
38
|
+
* command line and the `$ARGUMENTS` body substitution derive from it, so the body
|
|
39
|
+
* parse and the command line never disagree (e.g. `review-ticket`, which reads
|
|
40
|
+
* `--auto` out of `$ARGUMENTS`, sees the same `--auto` the command line shows).
|
|
41
|
+
*/
|
|
42
|
+
export function buildAugmentedArgs(input) {
|
|
43
|
+
const args = [...input.args];
|
|
44
|
+
if (input.commandName === "full-automation") {
|
|
45
|
+
args.push("--scheduled-at", input.scheduledAt);
|
|
46
|
+
}
|
|
47
|
+
const supportsAuto = schemaSupportsAutoFlag(input.schema) || input.commandName === "full-automation";
|
|
48
|
+
const alreadyHasAuto = input.args.includes("--auto");
|
|
49
|
+
if (input.autoApprove && supportsAuto && !alreadyHasAuto) {
|
|
50
|
+
args.push("--auto");
|
|
51
|
+
}
|
|
52
|
+
return args;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Render the delegated target command line as
|
|
56
|
+
* `/<commandName> <augmented args...>` (see {@link buildAugmentedArgs}).
|
|
57
|
+
*/
|
|
58
|
+
export function buildTargetCommandLine(input) {
|
|
59
|
+
const parts = [`/${input.commandName}`];
|
|
60
|
+
for (const arg of buildAugmentedArgs(input))
|
|
61
|
+
parts.push(quotePromptToken(arg));
|
|
62
|
+
return parts.join(" ");
|
|
63
|
+
}
|
|
64
|
+
/** Render only the fixed late-fire gate text (exposed for focused testing). */
|
|
65
|
+
export function renderLateFireGate(scheduleId, scheduledAt, autoApprove) {
|
|
66
|
+
const lateAction = autoApprove
|
|
67
|
+
? "explain that the run is firing late and then PROCEED with the command below (this schedule was created with auto-approve)."
|
|
68
|
+
: "explain that the run is firing late and then HALT WITHOUT EXECUTING the command below (this schedule was created without auto-approve, and a headless run must not proceed unconfirmed).";
|
|
69
|
+
return [
|
|
70
|
+
"## Scheduled-run drift gate",
|
|
71
|
+
"",
|
|
72
|
+
`This is an automated, headless scheduled run (schedule id: ${scheduleId}).`,
|
|
73
|
+
`It was scheduled to fire at ${scheduledAt}.`,
|
|
74
|
+
"",
|
|
75
|
+
`Before doing anything else, check how late this run is firing. If it is more than ${LATE_FIRE_THRESHOLD_SECONDS} seconds later than the scheduled time above, ${lateAction}`,
|
|
76
|
+
`If it is within ${LATE_FIRE_THRESHOLD_SECONDS} seconds of the scheduled time, proceed silently.`,
|
|
77
|
+
"",
|
|
78
|
+
"Then run the following command exactly as written:",
|
|
79
|
+
].join("\n");
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Replace `$ARGUMENTS` in a command body with the safely-quoted, normalized
|
|
83
|
+
* argument string. Every occurrence is replaced (commands often repeat the
|
|
84
|
+
* placeholder in a title and a body line).
|
|
85
|
+
*/
|
|
86
|
+
function replaceArgumentsPlaceholder(body, argString) {
|
|
87
|
+
return body.split("$ARGUMENTS").join(argString);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Render the full scheduled-run prompt: gate + delegated command line + command
|
|
91
|
+
* body (with `$ARGUMENTS` substituted). Deterministic for identical inputs.
|
|
92
|
+
*/
|
|
93
|
+
export function renderScheduledPrompt(input) {
|
|
94
|
+
const gate = renderLateFireGate(input.scheduleId, input.scheduledAt, input.autoApprove);
|
|
95
|
+
const targetInput = {
|
|
96
|
+
commandName: input.commandName,
|
|
97
|
+
args: input.args,
|
|
98
|
+
scheduledAt: input.scheduledAt,
|
|
99
|
+
autoApprove: input.autoApprove,
|
|
100
|
+
schema: input.schema,
|
|
101
|
+
};
|
|
102
|
+
const targetCommandLine = buildTargetCommandLine(targetInput);
|
|
103
|
+
// `$ARGUMENTS` must match what the command line shows — including the appended
|
|
104
|
+
// `--scheduled-at` / `--auto` — so a body that re-parses `$ARGUMENTS` (e.g.
|
|
105
|
+
// review-ticket reading `--auto`) agrees with the rendered command line.
|
|
106
|
+
const argString = buildAugmentedArgs(targetInput).map(quotePromptToken).join(" ");
|
|
107
|
+
const body = replaceArgumentsPlaceholder(input.commandBody, argString);
|
|
108
|
+
return [gate, "", targetCommandLine, "", body].join("\n");
|
|
109
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatEnvExportsForGeneratedUnit, posixShellQuote } from "./escaping.js";
|
|
1
|
+
import { bakedEnvFromCreateInput, formatEnvExportsForGeneratedUnit, posixShellQuote, } from "./escaping.js";
|
|
2
2
|
/** Virtual artifact path for the script piped to `at` (never written to disk). */
|
|
3
3
|
export const AT_STDIN_ARTIFACT_PATH = "<at-stdin>";
|
|
4
4
|
/** Convert an ISO timestamp to the `YYYYMMDDHHMM` form required by `at -t`. */
|
|
@@ -10,15 +10,10 @@ export function formatAtTimestamp(runAtIso) {
|
|
|
10
10
|
}
|
|
11
11
|
/** Render the script piped into `at`. */
|
|
12
12
|
export function renderAtScript(input) {
|
|
13
|
-
const exports = formatEnvExportsForGeneratedUnit(
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
claudePath: input.claudePath,
|
|
18
|
-
ideaFile: input.ideaFile,
|
|
19
|
-
repoPath: input.repoPath,
|
|
20
|
-
});
|
|
21
|
-
const command = [input.invocation.exe, ...input.invocation.args]
|
|
13
|
+
const exports = formatEnvExportsForGeneratedUnit(bakedEnvFromCreateInput(input));
|
|
14
|
+
// The script runs the Node execution shim (trigger invocation); the shim spawns
|
|
15
|
+
// the stored agent invocation and records run-history events.
|
|
16
|
+
const command = [input.triggerInvocation.exe, ...input.triggerInvocation.args]
|
|
22
17
|
.map((part) => posixShellQuote(part))
|
|
23
18
|
.join(" ");
|
|
24
19
|
return [
|
|
@@ -8,14 +8,23 @@
|
|
|
8
8
|
* truncate a generated file mid-write and silently drop a security-relevant
|
|
9
9
|
* suffix — via {@link assertNoNul}.
|
|
10
10
|
*/
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* All baked PATH-trap / schedule-context environment variable names, in a stable
|
|
13
|
+
* order. Generalized in BAPI-351: the full-automation-specific `BRIDGE_GPT_CLAUDE`
|
|
14
|
+
* became the agent-neutral `BRIDGE_GPT_AGENT_PATH`, and schedule-context vars
|
|
15
|
+
* (`BRIDGE_GPT_SCHEDULE_ID`, `BRIDGE_GPT_COMMAND`, `BRIDGE_GPT_COMMAND_ARGS_JSON`)
|
|
16
|
+
* were added. `BRIDGE_GPT_IDEA_FILE` is now legacy-only (baked only when present).
|
|
17
|
+
*/
|
|
12
18
|
export const BAKED_ENV_VAR_NAMES = [
|
|
13
19
|
"PATH",
|
|
14
20
|
"BRIDGE_GPT_NODE",
|
|
15
21
|
"BRIDGE_GPT_NPX",
|
|
16
|
-
"
|
|
17
|
-
"
|
|
22
|
+
"BRIDGE_GPT_AGENT",
|
|
23
|
+
"BRIDGE_GPT_AGENT_PATH",
|
|
18
24
|
"BRIDGE_GPT_REPO_PATH",
|
|
25
|
+
"BRIDGE_GPT_SCHEDULE_ID",
|
|
26
|
+
"BRIDGE_GPT_COMMAND",
|
|
27
|
+
"BRIDGE_GPT_COMMAND_ARGS_JSON",
|
|
19
28
|
];
|
|
20
29
|
/**
|
|
21
30
|
* Reject any generated value containing a NUL byte. NUL can prematurely
|
|
@@ -81,26 +90,47 @@ export function systemdQuote(value) {
|
|
|
81
90
|
return `"${escaped}"`;
|
|
82
91
|
}
|
|
83
92
|
/**
|
|
84
|
-
* Build the ordered baked PATH-trap env entries from explicit,
|
|
85
|
-
* values — never `process.env`. Each value is NUL-checked. This is
|
|
86
|
-
* source every backend uses so
|
|
87
|
-
*
|
|
88
|
-
*
|
|
93
|
+
* Build the ordered baked PATH-trap + schedule-context env entries from explicit,
|
|
94
|
+
* schedule-time values — never `process.env`. Each value is NUL-checked. This is
|
|
95
|
+
* the single source every backend uses so the baked environment is consistent
|
|
96
|
+
* across launchd / Task Scheduler / systemd / at. `BRIDGE_GPT_IDEA_FILE` is
|
|
97
|
+
* appended only for the legacy full-automation path (when `ideaFile` is set).
|
|
89
98
|
*/
|
|
90
99
|
export function bakedEnvEntries(env) {
|
|
91
100
|
const entries = [
|
|
92
101
|
["PATH", env.envPath],
|
|
93
102
|
["BRIDGE_GPT_NODE", env.nodePath],
|
|
94
103
|
["BRIDGE_GPT_NPX", env.npxPath],
|
|
95
|
-
["
|
|
96
|
-
["
|
|
104
|
+
["BRIDGE_GPT_AGENT", env.agent],
|
|
105
|
+
["BRIDGE_GPT_AGENT_PATH", env.agentPath],
|
|
97
106
|
["BRIDGE_GPT_REPO_PATH", env.repoPath],
|
|
107
|
+
["BRIDGE_GPT_SCHEDULE_ID", env.scheduleId],
|
|
108
|
+
["BRIDGE_GPT_COMMAND", env.command],
|
|
109
|
+
["BRIDGE_GPT_COMMAND_ARGS_JSON", env.commandArgsJson],
|
|
98
110
|
];
|
|
111
|
+
if (env.ideaFile !== undefined) {
|
|
112
|
+
entries.push(["BRIDGE_GPT_IDEA_FILE", env.ideaFile]);
|
|
113
|
+
}
|
|
99
114
|
for (const [key, value] of entries) {
|
|
100
115
|
assertNoNul(value, key);
|
|
101
116
|
}
|
|
102
117
|
return entries;
|
|
103
118
|
}
|
|
119
|
+
/** Build a {@link BakedEnv} from a generalized scheduler create input. */
|
|
120
|
+
export function bakedEnvFromCreateInput(input) {
|
|
121
|
+
return {
|
|
122
|
+
envPath: input.envPath,
|
|
123
|
+
nodePath: input.nodePath,
|
|
124
|
+
npxPath: input.npxPath,
|
|
125
|
+
agent: input.agent,
|
|
126
|
+
agentPath: input.agentPath,
|
|
127
|
+
repoPath: input.repoPath,
|
|
128
|
+
scheduleId: input.id,
|
|
129
|
+
command: input.command,
|
|
130
|
+
commandArgsJson: JSON.stringify(input.args),
|
|
131
|
+
ideaFile: input.legacyIdeaFile,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
104
134
|
/**
|
|
105
135
|
* Render the baked env as POSIX `export KEY='value'` lines (used by the `at`
|
|
106
136
|
* heredoc script). Values come only from {@link bakedEnvEntries}; no value is
|
|
@@ -16,10 +16,24 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { promises as fs } from "node:fs";
|
|
18
18
|
import { pathApiForPlatform } from "./types.js";
|
|
19
|
-
import { bakedEnvEntries, xmlEscape } from "./escaping.js";
|
|
20
|
-
/** launchd label for a schedule id. */
|
|
19
|
+
import { bakedEnvFromCreateInput, bakedEnvEntries, xmlEscape } from "./escaping.js";
|
|
20
|
+
/** launchd label for a NEW schedule id (neutral, generic schedule-run naming). */
|
|
21
21
|
export function launchdLabelForId(id) {
|
|
22
|
-
return `com.bridge-gpt.
|
|
22
|
+
return `com.bridge-gpt.schedule-run.${id}`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the launchd label for an existing schedule, preferring the label baked
|
|
26
|
+
* into the recorded plist path so legacy `com.bridge-gpt.full-automation.<id>`
|
|
27
|
+
* schedules remain list/cancel compatible after the neutral-naming change.
|
|
28
|
+
*/
|
|
29
|
+
export function launchdLabelForMetadata(metadata) {
|
|
30
|
+
const unitPath = metadata.unit_path;
|
|
31
|
+
if (unitPath && unitPath.endsWith(".plist")) {
|
|
32
|
+
const base = unitPath.split(/[\\/]/).pop();
|
|
33
|
+
if (base)
|
|
34
|
+
return base.slice(0, -".plist".length);
|
|
35
|
+
}
|
|
36
|
+
return launchdLabelForId(metadata.id);
|
|
23
37
|
}
|
|
24
38
|
/** `~/Library/LaunchAgents/<label>.plist` for a schedule id. */
|
|
25
39
|
export function launchdPlistPathForId(id, homeDir) {
|
|
@@ -49,15 +63,10 @@ function resolveUid(deps) {
|
|
|
49
63
|
export function renderLaunchdPlist(input) {
|
|
50
64
|
const label = launchdLabelForId(input.id);
|
|
51
65
|
const cal = startCalendarIntervalFromIso(input.runAtIso);
|
|
52
|
-
const env = bakedEnvEntries(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
claudePath: input.claudePath,
|
|
57
|
-
ideaFile: input.ideaFile,
|
|
58
|
-
repoPath: input.repoPath,
|
|
59
|
-
});
|
|
60
|
-
const programArgs = [input.invocation.exe, ...input.invocation.args]
|
|
66
|
+
const env = bakedEnvEntries(bakedEnvFromCreateInput(input));
|
|
67
|
+
// The OS unit runs the Node execution shim (trigger invocation), which then
|
|
68
|
+
// spawns the stored agent invocation and records run-history events.
|
|
69
|
+
const programArgs = [input.triggerInvocation.exe, ...input.triggerInvocation.args]
|
|
61
70
|
.map((arg) => ` <string>${xmlEscape(arg)}</string>`)
|
|
62
71
|
.join("\n");
|
|
63
72
|
const envEntries = env
|
|
@@ -168,7 +177,7 @@ export function createLaunchdBackend() {
|
|
|
168
177
|
entries.push({ metadata, status: "stale", detail: "plist missing" });
|
|
169
178
|
continue;
|
|
170
179
|
}
|
|
171
|
-
const label =
|
|
180
|
+
const label = launchdLabelForMetadata(metadata);
|
|
172
181
|
const printed = await input.deps.runCommand("launchctl", ["print", `gui/${uid}/${label}`]);
|
|
173
182
|
entries.push({
|
|
174
183
|
metadata,
|
|
@@ -180,7 +189,7 @@ export function createLaunchdBackend() {
|
|
|
180
189
|
},
|
|
181
190
|
async cancel(input) {
|
|
182
191
|
const uid = resolveUid(input.deps);
|
|
183
|
-
const label =
|
|
192
|
+
const label = launchdLabelForMetadata(input.metadata);
|
|
184
193
|
const plistPath = input.metadata.unit_path ?? launchdPlistPathForId(input.metadata.id, input.deps.homeDir);
|
|
185
194
|
let plistExisted = true;
|
|
186
195
|
try {
|
|
@@ -14,12 +14,12 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { promises as fs } from "node:fs";
|
|
16
16
|
import { pathApiForPlatform } from "./types.js";
|
|
17
|
-
import { bakedEnvEntries, systemdQuote } from "./escaping.js";
|
|
18
|
-
/** Return the unit names for a schedule id. */
|
|
17
|
+
import { bakedEnvFromCreateInput, bakedEnvEntries, systemdQuote } from "./escaping.js";
|
|
18
|
+
/** Return the unit names for a NEW schedule id (neutral schedule-run naming). */
|
|
19
19
|
export function systemdUnitNamesForId(id) {
|
|
20
20
|
return {
|
|
21
|
-
service: `bridge-gpt-
|
|
22
|
-
timer: `bridge-gpt-
|
|
21
|
+
service: `bridge-gpt-schedule-run-${id}.service`,
|
|
22
|
+
timer: `bridge-gpt-schedule-run-${id}.timer`,
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
25
|
/** Return the unit file paths for a schedule id. */
|
|
@@ -32,25 +32,40 @@ export function systemdUnitPathsForId(id, homeDir) {
|
|
|
32
32
|
timer: pathApi.join(dir, names.timer),
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolve unit names + paths for an EXISTING schedule, preferring the names baked
|
|
37
|
+
* into the recorded unit paths so legacy `bridge-gpt-full-automation-<id>` units
|
|
38
|
+
* remain list/cancel compatible after the neutral-naming change.
|
|
39
|
+
*/
|
|
40
|
+
export function systemdUnitsForMetadata(metadata, homeDir) {
|
|
41
|
+
const timerPath = metadata.unit_path && metadata.unit_path.endsWith(".timer") ? metadata.unit_path : undefined;
|
|
42
|
+
const servicePath = (metadata.unit_paths ?? []).find((p) => p.endsWith(".service"));
|
|
43
|
+
if (timerPath && servicePath) {
|
|
44
|
+
const basename = (p) => p.split(/[\\/]/).pop();
|
|
45
|
+
return {
|
|
46
|
+
names: { service: basename(servicePath), timer: basename(timerPath) },
|
|
47
|
+
paths: { service: servicePath, timer: timerPath },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
names: systemdUnitNamesForId(metadata.id),
|
|
52
|
+
paths: systemdUnitPathsForId(metadata.id, homeDir),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
35
55
|
/** Render the systemd service unit. */
|
|
36
56
|
export function renderSystemdService(input) {
|
|
37
|
-
const env = bakedEnvEntries(
|
|
38
|
-
envPath: input.envPath,
|
|
39
|
-
nodePath: input.nodePath,
|
|
40
|
-
npxPath: input.npxPath,
|
|
41
|
-
claudePath: input.claudePath,
|
|
42
|
-
ideaFile: input.ideaFile,
|
|
43
|
-
repoPath: input.repoPath,
|
|
44
|
-
});
|
|
57
|
+
const env = bakedEnvEntries(bakedEnvFromCreateInput(input));
|
|
45
58
|
const envLines = env
|
|
46
59
|
.map(([key, value]) => `Environment=${key}=${systemdQuote(value)}`)
|
|
47
60
|
.join("\n");
|
|
48
|
-
|
|
61
|
+
// ExecStart runs the Node execution shim (trigger invocation); the shim spawns
|
|
62
|
+
// the stored agent invocation and records run-history events.
|
|
63
|
+
const execStart = [input.triggerInvocation.exe, ...input.triggerInvocation.args]
|
|
49
64
|
.map((part) => systemdQuote(part))
|
|
50
65
|
.join(" ");
|
|
51
66
|
return [
|
|
52
67
|
"[Unit]",
|
|
53
|
-
`Description=Bridge GPT
|
|
68
|
+
`Description=Bridge GPT schedule-run one-shot (${input.id}: ${input.command})`,
|
|
54
69
|
"",
|
|
55
70
|
"[Service]",
|
|
56
71
|
"Type=oneshot",
|
|
@@ -82,7 +97,7 @@ export function renderSystemdTimer(input) {
|
|
|
82
97
|
const names = systemdUnitNamesForId(input.id);
|
|
83
98
|
return [
|
|
84
99
|
"[Unit]",
|
|
85
|
-
`Description=Bridge GPT
|
|
100
|
+
`Description=Bridge GPT schedule-run one-shot timer (${input.id}: ${input.command})`,
|
|
86
101
|
"",
|
|
87
102
|
"[Timer]",
|
|
88
103
|
`OnCalendar=${formatSystemdOnCalendar(input.runAtIso)}`,
|
|
@@ -181,8 +196,7 @@ export function createSystemdUserBackend() {
|
|
|
181
196
|
async list(input) {
|
|
182
197
|
const entries = [];
|
|
183
198
|
for (const metadata of input.recorded) {
|
|
184
|
-
const paths =
|
|
185
|
-
const names = systemdUnitNamesForId(metadata.id);
|
|
199
|
+
const { paths, names } = systemdUnitsForMetadata(metadata, input.deps.homeDir);
|
|
186
200
|
let timerExists = true;
|
|
187
201
|
try {
|
|
188
202
|
await fs.access(paths.timer);
|
|
@@ -209,8 +223,7 @@ export function createSystemdUserBackend() {
|
|
|
209
223
|
return entries;
|
|
210
224
|
},
|
|
211
225
|
async cancel(input) {
|
|
212
|
-
const paths =
|
|
213
|
-
const names = systemdUnitNamesForId(input.metadata.id);
|
|
226
|
+
const { paths, names } = systemdUnitsForMetadata(input.metadata, input.deps.homeDir);
|
|
214
227
|
const disable = await input.deps.runCommand("systemctl", [
|
|
215
228
|
"--user",
|
|
216
229
|
"disable",
|
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { promises as fs } from "node:fs";
|
|
20
20
|
import { pathApiForPlatform } from "./types.js";
|
|
21
|
-
import { bakedEnvEntries, escapeWindowsCmdSetValue, windowsCmdQuote, xmlEscape, } from "./escaping.js";
|
|
22
|
-
/** Task Scheduler task name for a schedule id. */
|
|
21
|
+
import { bakedEnvFromCreateInput, bakedEnvEntries, escapeWindowsCmdSetValue, windowsCmdQuote, xmlEscape, } from "./escaping.js";
|
|
22
|
+
/** Task Scheduler task name for a NEW schedule id (neutral schedule-run naming). */
|
|
23
23
|
export function taskNameForId(id) {
|
|
24
|
-
return `BridgeGPT-
|
|
24
|
+
return `BridgeGPT-ScheduleRun-${id}`;
|
|
25
25
|
}
|
|
26
26
|
/** `%USERPROFILE%\.bridge-gpt\schedules\<id>.cmd` wrapper path for a schedule id. */
|
|
27
27
|
export function cmdWrapperPathForId(id, homeDir) {
|
|
@@ -46,14 +46,7 @@ export function formatTaskSchedulerBoundary(runAtIso) {
|
|
|
46
46
|
}
|
|
47
47
|
/** Render the `.cmd` wrapper that the Task Scheduler task invokes. */
|
|
48
48
|
export function renderWindowsCmdWrapper(input) {
|
|
49
|
-
const env = bakedEnvEntries(
|
|
50
|
-
envPath: input.envPath,
|
|
51
|
-
nodePath: input.nodePath,
|
|
52
|
-
npxPath: input.npxPath,
|
|
53
|
-
claudePath: input.claudePath,
|
|
54
|
-
ideaFile: input.ideaFile,
|
|
55
|
-
repoPath: input.repoPath,
|
|
56
|
-
});
|
|
49
|
+
const env = bakedEnvEntries(bakedEnvFromCreateInput(input));
|
|
57
50
|
const lines = ["@echo off"];
|
|
58
51
|
for (const [key, value] of env) {
|
|
59
52
|
if (key === "PATH") {
|
|
@@ -65,7 +58,9 @@ export function renderWindowsCmdWrapper(input) {
|
|
|
65
58
|
}
|
|
66
59
|
}
|
|
67
60
|
lines.push(`cd /D ${windowsCmdQuote(input.repoPath)}`);
|
|
68
|
-
|
|
61
|
+
// The wrapper runs the Node execution shim (trigger invocation), not the agent
|
|
62
|
+
// binary directly; the shim spawns the stored agent invocation.
|
|
63
|
+
const command = [input.triggerInvocation.exe, ...input.triggerInvocation.args]
|
|
69
64
|
.map((part) => windowsCmdQuote(part))
|
|
70
65
|
.join(" ");
|
|
71
66
|
// Append stdout/stderr to the local schedule logs.
|
|
@@ -86,7 +81,7 @@ export function renderTaskSchedulerXml(input) {
|
|
|
86
81
|
'<?xml version="1.0" encoding="UTF-16"?>',
|
|
87
82
|
'<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
|
|
88
83
|
" <RegistrationInfo>",
|
|
89
|
-
` <Description>${xmlEscape(`Bridge GPT
|
|
84
|
+
` <Description>${xmlEscape(`Bridge GPT schedule-run one-shot (${input.id}: ${input.command})`)}</Description>`,
|
|
90
85
|
" </RegistrationInfo>",
|
|
91
86
|
" <Triggers>",
|
|
92
87
|
" <TimeTrigger>",
|