@bridge_gpt/mcp-server 0.2.50 → 0.2.52
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 -8
- package/build/agent-capabilities/probe-context.js +15 -7
- package/build/agent-capabilities/probes.js +42 -6
- package/build/agent-launchers/claude-executor-adapter.js +98 -14
- package/build/commands.generated.js +1 -1
- package/build/conduct-epic/bridge-client.js +115 -1
- package/build/conduct-epic/cli.js +351 -33
- package/build/conduct-epic/cut-protocol.js +65 -0
- package/build/conductor/bridge-api-client.js +171 -5
- package/build/conductor/deny-enforcement-preflight.js +107 -10
- package/build/conductor/local-merge.js +170 -11
- package/build/conductor-bin.js +2 -2
- package/build/connect-bitbucket-api.js +370 -0
- package/build/connect-bitbucket.js +437 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +230 -1
- package/build/drive-epic.js +423 -11
- package/build/env-file-link.js +164 -0
- package/build/epic-integration-pr.js +290 -0
- package/build/executor/cli.js +41 -6
- package/build/executor/deps.js +5 -1
- package/build/executor/env-file-guard.js +113 -0
- package/build/executor/env.js +78 -1
- package/build/executor/heartbeat.js +9 -0
- package/build/executor/http-client.js +90 -22
- package/build/executor/job-errors.js +43 -2
- package/build/executor/job-runner.js +137 -29
- package/build/executor/merge-job.js +102 -6
- package/build/executor/permissions.js +106 -0
- package/build/executor/preflight.js +38 -13
- package/build/executor/resume-pre-spawn.js +2 -1
- package/build/executor/runner.js +175 -4
- package/build/executor/service-unit.js +15 -0
- package/build/executor/terminal-mutation.js +22 -1
- package/build/executor/types.js +86 -0
- package/build/executor/worker-command.js +21 -5
- package/build/executor/worker-guard-hook.js +939 -0
- package/build/executor/worker-log.js +56 -0
- package/build/executor/worktree.js +11 -0
- package/build/git-reachability.js +147 -0
- package/build/index.js +535 -95
- package/build/install-bridge.js +95 -0
- package/build/pipelines.generated.js +10 -2
- package/build/plan-epic-conductor-eligibility.js +213 -0
- package/build/plane/cli.js +78 -15
- package/build/plane/defaults.js +165 -0
- package/build/plane/manifest.js +63 -8
- package/build/plane/member-logs.js +6 -0
- package/build/plane/member-roster.js +195 -11
- package/build/plane/preflight.js +43 -0
- package/build/plane/shutdown.js +25 -3
- package/build/plane/status.js +11 -0
- package/build/plane/supervisor.js +343 -14
- package/build/plane/test-fakes.js +43 -0
- package/build/plane/types.js +82 -11
- package/build/pr-base-contract.js +20 -0
- package/build/readme.generated.js +1 -1
- package/build/review-synthesis-config.js +60 -0
- package/build/scripts/executor-protocol-contract-driver.js +311 -0
- package/build/setup-epic.js +592 -139
- package/build/sfcc/log-query.js +2 -1
- package/build/sfcc/reads-custom-object-def.js +10 -13
- package/build/sfcc/reads-site-preference.js +5 -5
- package/build/sfcc/reads-system-object.js +4 -4
- package/build/sfcc/writes-custom-object-def.js +7 -7
- package/build/sfcc/writes-site-preference.js +4 -3
- package/build/sfcc/writes-system-object.js +7 -6
- package/build/start-tickets-conductor.js +11 -2
- package/build/start-tickets.js +69 -2
- package/build/version.generated.js +3 -3
- package/build/worker-containment-diagnostic.js +97 -0
- package/build/worker-guard-hook-bin.js +6 -0
- package/docs/CONDUCTOR.md +27 -0
- package/docs/install/mcp-tool-integrations.md +3 -2
- package/package.json +5 -3
- package/pipelines/plan-epic.json +5 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive worktree environment-file LINKING (BAPI-1019, Slice A / A.2).
|
|
3
|
+
*
|
|
4
|
+
* Architecture Miss 28 was a conductor worker that truncated the operator's real
|
|
5
|
+
* operational database. It could reach that database because Worktrunk's
|
|
6
|
+
* `copy-ignored` step had placed a COPY of the operator's `.env` inside the
|
|
7
|
+
* worker's worktree. BAPI-1019 removes `.env*` from that copy step at source
|
|
8
|
+
* (`.config/wt.toml`), which fixes the executor case outright — an executor
|
|
9
|
+
* worktree must hold neither a copy nor a link — but it also takes `.env` away
|
|
10
|
+
* from the INTERACTIVE worktrees a human actually works in, which legitimately
|
|
11
|
+
* need it.
|
|
12
|
+
*
|
|
13
|
+
* This module gives that file back by REFERENCE rather than by value. For each
|
|
14
|
+
* supported name present in the main checkout it creates
|
|
15
|
+
* `<worktree>/<name>` as a SYMLINK to the main checkout's file, so an operator's
|
|
16
|
+
* secrets exist exactly once at rest no matter how many worktrees are open, and
|
|
17
|
+
* editing the operator's `.env` is immediately visible everywhere instead of
|
|
18
|
+
* drifting per worktree.
|
|
19
|
+
*
|
|
20
|
+
* CONTAINMENT RULES, all load-bearing:
|
|
21
|
+
*
|
|
22
|
+
* - This module is for the INTERACTIVE path only. `createWorktrees()` in
|
|
23
|
+
* `start-tickets.ts` is its sole production caller; `ensureExecutorWorktree()`
|
|
24
|
+
* must never call it. An executor worktree that held a link would resolve to
|
|
25
|
+
* the operator's real `.env` exactly as a copy did.
|
|
26
|
+
* - It NEVER reads or logs file CONTENTS. Its whole dependency surface is
|
|
27
|
+
* metadata (`stat`/`lstat`), link creation, unlinking, and — only on a
|
|
28
|
+
* platform that cannot make links — a copy. There is deliberately no
|
|
29
|
+
* `readFile` seam to misuse.
|
|
30
|
+
* - Its returned warnings are FIXED text plus a closed category. No absolute
|
|
31
|
+
* path, username, resolved link target, file content, or exception text
|
|
32
|
+
* reaches the caller, because these strings surface in `start-tickets`'
|
|
33
|
+
* operator-facing summary.
|
|
34
|
+
* - A destination that already exists as a REGULAR FILE (a copy left behind by
|
|
35
|
+
* an earlier run, before this ticket) is replaced with a link. That is the
|
|
36
|
+
* migration path off the old behavior: leaving the copy in place would leave
|
|
37
|
+
* the second at-rest copy this ticket exists to remove.
|
|
38
|
+
*
|
|
39
|
+
* ACCEPTED WINDOWS GAP. Creating a symlink on Windows requires Developer Mode or
|
|
40
|
+
* an elevated process, so `symlink` there commonly fails with `EPERM`. On that
|
|
41
|
+
* recognized failure this module falls back to a COPY and returns a warning
|
|
42
|
+
* rather than throwing: an interactive Windows worktree with a copied `.env` is
|
|
43
|
+
* the behavior operators already had, whereas a hard failure would break worktree
|
|
44
|
+
* creation outright. The residual second-copy-at-rest gap on Windows is recorded
|
|
45
|
+
* as accepted in the ticket's exploration ("Criteria Coverage"), not as a defect
|
|
46
|
+
* to solve here. It does NOT weaken the executor guarantee, which is enforced by
|
|
47
|
+
* `executor/env-file-guard.ts` on every platform.
|
|
48
|
+
*/
|
|
49
|
+
import { pathApiForPlatform } from "./worktree-core.js";
|
|
50
|
+
/**
|
|
51
|
+
* The operator environment files an interactive worktree may receive by link.
|
|
52
|
+
*
|
|
53
|
+
* A CLOSED list, not a glob. `.env` and `.env.test` are the two files this
|
|
54
|
+
* repository's own tooling reads; matching `.env.*` here would start linking
|
|
55
|
+
* whatever an operator happens to have lying around (`.env.production`,
|
|
56
|
+
* `.env.backup`) into every worktree, which is a wider blast radius than the
|
|
57
|
+
* problem needs. The executor's strip, by contrast, is deliberately a WIDE
|
|
58
|
+
* pattern — removing too much from a worker is safe, linking too much is not.
|
|
59
|
+
*/
|
|
60
|
+
export const LINKED_OPERATOR_ENV_FILES = [".env", ".env.test"];
|
|
61
|
+
/**
|
|
62
|
+
* The FIXED warning texts. Basenames are the only variable part, and a basename
|
|
63
|
+
* is a constant from {@link LINKED_OPERATOR_ENV_FILES} rather than anything
|
|
64
|
+
* discovered on disk — so no path, username, or secret can enter these strings.
|
|
65
|
+
*/
|
|
66
|
+
function symlinkUnsupportedWarning(name) {
|
|
67
|
+
return {
|
|
68
|
+
category: "symlink_unsupported",
|
|
69
|
+
message: `worktree ${name}: this platform could not create a symbolic link, so the file was ` +
|
|
70
|
+
"copied instead. The worktree now holds a second copy of that environment file at " +
|
|
71
|
+
"rest. On Windows, enable Developer Mode (or run elevated) to get links instead.",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function linkFailedWarning(name) {
|
|
75
|
+
return {
|
|
76
|
+
category: "link_failed",
|
|
77
|
+
message: `worktree ${name}: could not be provided from the main checkout, so this worktree has ` +
|
|
78
|
+
"no copy of it. Commands that need it will fail until it is linked by hand.",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Error codes that mean "this platform/filesystem cannot make a symlink here",
|
|
83
|
+
* as opposed to an ordinary failure. `EPERM` is the Windows non-Developer-Mode
|
|
84
|
+
* signature; the rest cover filesystems and runtimes without link support.
|
|
85
|
+
*/
|
|
86
|
+
const SYMLINK_UNSUPPORTED_CODES = new Set([
|
|
87
|
+
"EPERM",
|
|
88
|
+
"ENOSYS",
|
|
89
|
+
"EOPNOTSUPP",
|
|
90
|
+
"ENOTSUP",
|
|
91
|
+
"UNKNOWN",
|
|
92
|
+
]);
|
|
93
|
+
function errorCode(err) {
|
|
94
|
+
if (!err || typeof err !== "object")
|
|
95
|
+
return undefined;
|
|
96
|
+
const code = err.code;
|
|
97
|
+
return typeof code === "string" ? code : undefined;
|
|
98
|
+
}
|
|
99
|
+
/** True when two metadata records describe the same underlying file. */
|
|
100
|
+
function sameFile(a, b) {
|
|
101
|
+
if (!a || !b)
|
|
102
|
+
return false;
|
|
103
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Provide the main checkout's operator environment files to an interactive
|
|
107
|
+
* worktree as symlinks.
|
|
108
|
+
*
|
|
109
|
+
* Never throws: every per-name failure becomes a fixed warning, because a
|
|
110
|
+
* worktree that was created successfully must not be reported as failed just
|
|
111
|
+
* because a convenience link could not be made. A name whose SOURCE is absent in
|
|
112
|
+
* the main checkout is an ordinary no-op with no warning at all — most checkouts
|
|
113
|
+
* have `.env` and no `.env.test`, and warning about that would train operators to
|
|
114
|
+
* ignore this channel.
|
|
115
|
+
*/
|
|
116
|
+
export async function linkOperatorEnvFiles(mainCheckout, worktreePath, deps) {
|
|
117
|
+
const pathApi = pathApiForPlatform(deps.platform ?? process.platform);
|
|
118
|
+
const result = { linked: [], copied: [], unchanged: [], warnings: [] };
|
|
119
|
+
for (const name of LINKED_OPERATOR_ENV_FILES) {
|
|
120
|
+
const source = pathApi.join(mainCheckout, name);
|
|
121
|
+
const destination = pathApi.join(worktreePath, name);
|
|
122
|
+
try {
|
|
123
|
+
// The main checkout is the authority on whether this name exists at all.
|
|
124
|
+
// Checked FIRST so a missing source touches the destination in no way.
|
|
125
|
+
const sourceStats = await deps.stat(source);
|
|
126
|
+
if (!sourceStats || !sourceStats.isFile())
|
|
127
|
+
continue;
|
|
128
|
+
const existing = await deps.lstat(destination);
|
|
129
|
+
if (existing) {
|
|
130
|
+
if (existing.isSymbolicLink() && sameFile(await deps.stat(destination), sourceStats)) {
|
|
131
|
+
// Already pointing at this exact file. Re-creating it would be churn,
|
|
132
|
+
// and every unlink of a correct entry is a window in which the worktree
|
|
133
|
+
// has no `.env` at all.
|
|
134
|
+
result.unchanged.push(name);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// A stale link, or a regular-file copy from before this ticket. Remove
|
|
138
|
+
// the ENTRY — `unlink` never follows, so the main checkout's real file is
|
|
139
|
+
// untouched even when the entry is a link pointing straight at it.
|
|
140
|
+
await deps.unlink(destination);
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
await deps.symlink(source, destination);
|
|
144
|
+
result.linked.push(name);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
if (!SYMLINK_UNSUPPORTED_CODES.has(errorCode(err) ?? ""))
|
|
148
|
+
throw err;
|
|
149
|
+
// The accepted platform gap. Fall back to the old behavior rather than
|
|
150
|
+
// leaving the worktree without a file it needs.
|
|
151
|
+
await deps.copyFile(source, destination);
|
|
152
|
+
result.copied.push(name);
|
|
153
|
+
result.warnings.push(symlinkUnsupportedWarning(name));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Bounded on purpose: the caught error is never inspected for text, only
|
|
158
|
+
// discarded. A filesystem error message can carry an absolute path (which
|
|
159
|
+
// contains a username) and this string is printed to an operator summary.
|
|
160
|
+
result.warnings.push(linkFailedWarning(name));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, non-throwing helper that ensures a draft epic-integration PR exists
|
|
3
|
+
* (BAPI-951).
|
|
4
|
+
*
|
|
5
|
+
* Local `gh` is used deliberately here: the Bridge/server pull-request seam has
|
|
6
|
+
* no `draft` parameter and cannot distinguish GitHub's "no commits between
|
|
7
|
+
* branches" 422 from a credential or provider failure — both of which this
|
|
8
|
+
* helper must classify without throwing.
|
|
9
|
+
*
|
|
10
|
+
* A draft PR still receives the `conductor-ci / gate` required check (BAPI-949's
|
|
11
|
+
* trigger has no draft exclusion), while `claude-review.yml` excludes drafts
|
|
12
|
+
* until the PR is marked ready. So opening the integration PR as a draft earns
|
|
13
|
+
* the epic branch its required CI immediately, without paying for a paid review
|
|
14
|
+
* nobody can act on until a human decides the epic is ready.
|
|
15
|
+
*/
|
|
16
|
+
import { execFile as nodeExecFile } from "node:child_process";
|
|
17
|
+
/** Bounded read timeout — matches the existing 5s GitHub probe limit (pr-discovery.ts). */
|
|
18
|
+
export const EPIC_INTEGRATION_PR_READ_TIMEOUT_MS = 5_000;
|
|
19
|
+
/** Separate, larger bounded timeout for the two write operations (`pr create`, `pr ready`). */
|
|
20
|
+
export const EPIC_INTEGRATION_PR_WRITE_TIMEOUT_MS = 20_000;
|
|
21
|
+
/** Bounded result count for the existence probe. */
|
|
22
|
+
const LIST_LIMIT = 20;
|
|
23
|
+
const PR_LIST_JSON_FIELDS = "number,headRefName,baseRefName,isDraft";
|
|
24
|
+
/**
|
|
25
|
+
* `epic/<KEY>` prefix a child PR's base branch carries under BAPI-949/BAPI-950.
|
|
26
|
+
* Exported (BAPI-1010) so every call site that derives an epic key from a base
|
|
27
|
+
* branch shares one definition instead of re-declaring a private literal.
|
|
28
|
+
*/
|
|
29
|
+
export const EPIC_BRANCH_PREFIX = "epic/";
|
|
30
|
+
/** The production call sites this helper is invoked from. */
|
|
31
|
+
export const EPIC_INTEGRATION_PR_COMMANDS = [
|
|
32
|
+
"setup-epic",
|
|
33
|
+
"conduct-epic init",
|
|
34
|
+
"conduct-epic catch-up",
|
|
35
|
+
"conduct-epic finish",
|
|
36
|
+
"executor merge",
|
|
37
|
+
// BAPI-1010: the executor's one-shot wind-down observer, distinct from
|
|
38
|
+
// "executor merge" so the generated PR body and diagnostics never mislabel
|
|
39
|
+
// a done-state readiness request as a ticket merge enrichment.
|
|
40
|
+
"executor wind-down",
|
|
41
|
+
];
|
|
42
|
+
/** Only the sanctioned safe fields. Never raw command output. */
|
|
43
|
+
export function formatEpicIntegrationPullRequestOutcome(outcome) {
|
|
44
|
+
if (outcome.kind === "already_open" || outcome.kind === "created") {
|
|
45
|
+
return {
|
|
46
|
+
kind: outcome.kind,
|
|
47
|
+
reason: outcome.reason,
|
|
48
|
+
pr_number: outcome.prNumber,
|
|
49
|
+
readiness: outcome.readiness,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return { kind: outcome.kind, reason: outcome.reason };
|
|
53
|
+
}
|
|
54
|
+
/** A concise, epic-specific PR title. Does not depend on `gh`. */
|
|
55
|
+
export function buildEpicIntegrationPullRequestTitle(epicKey) {
|
|
56
|
+
return `${epicKey}: epic integration branch`;
|
|
57
|
+
}
|
|
58
|
+
/** A non-empty, epic-specific PR body. Does not depend on `gh`. */
|
|
59
|
+
export function buildEpicIntegrationPullRequestBody(epicKey, command) {
|
|
60
|
+
return [
|
|
61
|
+
`\`${command}\` opened this pull request automatically.`,
|
|
62
|
+
"",
|
|
63
|
+
`It exists so the \`conductor-ci / gate\` required check runs for the ${epicKey} ` +
|
|
64
|
+
"epic integration branch (BAPI-949). Draft status postpones the paid Claude " +
|
|
65
|
+
"review until a human marks this pull request ready for review.",
|
|
66
|
+
"",
|
|
67
|
+
"Only a human merges an epic integration branch — the conductor never merges it.",
|
|
68
|
+
].join("\n");
|
|
69
|
+
}
|
|
70
|
+
function isRecord(value) {
|
|
71
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
72
|
+
}
|
|
73
|
+
/** Bounded diagnostics to distinguish "no local gh", "not authenticated", and "inconclusive". */
|
|
74
|
+
async function classifyGhUnavailable(gh, cwd) {
|
|
75
|
+
let version;
|
|
76
|
+
try {
|
|
77
|
+
version = await gh(["--version"], { cwd, timeoutMs: EPIC_INTEGRATION_PR_READ_TIMEOUT_MS });
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return "gh_unavailable";
|
|
81
|
+
}
|
|
82
|
+
if (version.exitCode !== 0)
|
|
83
|
+
return "gh_unavailable";
|
|
84
|
+
let auth;
|
|
85
|
+
try {
|
|
86
|
+
auth = await gh(["auth", "status"], { cwd, timeoutMs: EPIC_INTEGRATION_PR_READ_TIMEOUT_MS });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return "probe_inconclusive";
|
|
90
|
+
}
|
|
91
|
+
if (auth.exitCode !== 0)
|
|
92
|
+
return "gh_unauthenticated";
|
|
93
|
+
return "probe_inconclusive";
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Existence probe: `gh pr list` constrained by BOTH head and base. Only a
|
|
97
|
+
* successful, valid, empty JSON array is confirmed absence — anything else
|
|
98
|
+
* (non-zero exit, unparseable output, a non-array shape, an item with an
|
|
99
|
+
* invalid number, or a record whose head/base do not match) is `unavailable`,
|
|
100
|
+
* never treated as absence.
|
|
101
|
+
*/
|
|
102
|
+
async function probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd) {
|
|
103
|
+
const args = [
|
|
104
|
+
"pr",
|
|
105
|
+
"list",
|
|
106
|
+
"--state",
|
|
107
|
+
"open",
|
|
108
|
+
"--head",
|
|
109
|
+
epicBranch,
|
|
110
|
+
"--base",
|
|
111
|
+
baseBranch,
|
|
112
|
+
"--json",
|
|
113
|
+
PR_LIST_JSON_FIELDS,
|
|
114
|
+
"--limit",
|
|
115
|
+
String(LIST_LIMIT),
|
|
116
|
+
];
|
|
117
|
+
let result;
|
|
118
|
+
try {
|
|
119
|
+
result = await gh(args, { cwd, timeoutMs: EPIC_INTEGRATION_PR_READ_TIMEOUT_MS });
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return { kind: "unavailable", reason: await classifyGhUnavailable(gh, cwd) };
|
|
123
|
+
}
|
|
124
|
+
if (result.exitCode !== 0) {
|
|
125
|
+
return { kind: "unavailable", reason: await classifyGhUnavailable(gh, cwd) };
|
|
126
|
+
}
|
|
127
|
+
let parsed;
|
|
128
|
+
try {
|
|
129
|
+
parsed = JSON.parse(result.stdout);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return { kind: "unavailable", reason: "probe_malformed" };
|
|
133
|
+
}
|
|
134
|
+
if (!Array.isArray(parsed)) {
|
|
135
|
+
return { kind: "unavailable", reason: "probe_malformed" };
|
|
136
|
+
}
|
|
137
|
+
if (parsed.length === 0) {
|
|
138
|
+
return { kind: "absent" };
|
|
139
|
+
}
|
|
140
|
+
for (const item of parsed) {
|
|
141
|
+
if (!isRecord(item))
|
|
142
|
+
continue;
|
|
143
|
+
const number = item.number;
|
|
144
|
+
const head = item.headRefName;
|
|
145
|
+
const base = item.baseRefName;
|
|
146
|
+
const isDraft = item.isDraft;
|
|
147
|
+
if (typeof number === "number" &&
|
|
148
|
+
Number.isInteger(number) &&
|
|
149
|
+
number > 0 &&
|
|
150
|
+
head === epicBranch &&
|
|
151
|
+
base === baseBranch &&
|
|
152
|
+
typeof isDraft === "boolean") {
|
|
153
|
+
return { kind: "found", number, isDraft };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Records exist but none matches both head and base exactly, or the shape is
|
|
157
|
+
// unexpected — never treated as absence.
|
|
158
|
+
return { kind: "unavailable", reason: "probe_malformed" };
|
|
159
|
+
}
|
|
160
|
+
const NO_COMMITS_BETWEEN_PATTERN = /no commits between/i;
|
|
161
|
+
/** Strict positive numeric suffix of a normal `https://…/pull/<n>` URL, else `null`. */
|
|
162
|
+
function extractPrNumberFromCreateOutput(stdout) {
|
|
163
|
+
const match = stdout.trim().match(/\/pull\/(\d+)\s*$/);
|
|
164
|
+
if (!match)
|
|
165
|
+
return null;
|
|
166
|
+
const n = Number(match[1]);
|
|
167
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
168
|
+
}
|
|
169
|
+
async function createDraftPr(gh, epicKey, epicBranch, baseBranch, command, cwd) {
|
|
170
|
+
const title = buildEpicIntegrationPullRequestTitle(epicKey);
|
|
171
|
+
const body = buildEpicIntegrationPullRequestBody(epicKey, command);
|
|
172
|
+
const args = [
|
|
173
|
+
"pr",
|
|
174
|
+
"create",
|
|
175
|
+
"--draft",
|
|
176
|
+
"--base",
|
|
177
|
+
baseBranch,
|
|
178
|
+
"--head",
|
|
179
|
+
epicBranch,
|
|
180
|
+
"--title",
|
|
181
|
+
title,
|
|
182
|
+
"--body",
|
|
183
|
+
body,
|
|
184
|
+
];
|
|
185
|
+
let result;
|
|
186
|
+
try {
|
|
187
|
+
result = await gh(args, { cwd, timeoutMs: EPIC_INTEGRATION_PR_WRITE_TIMEOUT_MS });
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return { kind: "create_failed" };
|
|
191
|
+
}
|
|
192
|
+
if (result.exitCode === 0) {
|
|
193
|
+
return { kind: "created", numberHint: extractPrNumberFromCreateOutput(result.stdout) };
|
|
194
|
+
}
|
|
195
|
+
const combined = `${result.stdout}\n${result.stderr}`;
|
|
196
|
+
if (NO_COMMITS_BETWEEN_PATTERN.test(combined)) {
|
|
197
|
+
return { kind: "no_commits" };
|
|
198
|
+
}
|
|
199
|
+
return { kind: "create_failed" };
|
|
200
|
+
}
|
|
201
|
+
async function maybeMakeReady(gh, prNumber, isDraft, requestReady, cwd) {
|
|
202
|
+
if (!requestReady)
|
|
203
|
+
return "not_requested";
|
|
204
|
+
if (prNumber === null)
|
|
205
|
+
return "ready_failed";
|
|
206
|
+
if (!isDraft)
|
|
207
|
+
return "already_ready";
|
|
208
|
+
try {
|
|
209
|
+
const result = await gh(["pr", "ready", String(prNumber)], {
|
|
210
|
+
cwd,
|
|
211
|
+
timeoutMs: EPIC_INTEGRATION_PR_WRITE_TIMEOUT_MS,
|
|
212
|
+
});
|
|
213
|
+
return result.exitCode === 0 ? "made_ready" : "ready_failed";
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return "ready_failed";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function ensureEpicIntegrationPullRequestInner(options) {
|
|
220
|
+
const { epicKey, epicBranch, baseBranch, command, gh, cwd, requestReady } = options;
|
|
221
|
+
const probe = await probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd);
|
|
222
|
+
if (probe.kind === "unavailable") {
|
|
223
|
+
return { kind: "unavailable", reason: probe.reason };
|
|
224
|
+
}
|
|
225
|
+
if (probe.kind === "found") {
|
|
226
|
+
const readiness = await maybeMakeReady(gh, probe.number, probe.isDraft, requestReady, cwd);
|
|
227
|
+
return { kind: "already_open", reason: "already_open", prNumber: probe.number, readiness };
|
|
228
|
+
}
|
|
229
|
+
// Confirmed absent: create.
|
|
230
|
+
const created = await createDraftPr(gh, epicKey, epicBranch, baseBranch, command, cwd);
|
|
231
|
+
if (created.kind === "no_commits") {
|
|
232
|
+
return { kind: "deferred", reason: "no_commits_between_branches" };
|
|
233
|
+
}
|
|
234
|
+
if (created.kind === "created") {
|
|
235
|
+
// Resolve the number through a fresh matching probe first; fall back to the
|
|
236
|
+
// strict URL-derived hint only when the probe cannot confirm it.
|
|
237
|
+
const reprobe = await probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd);
|
|
238
|
+
const prNumber = reprobe.kind === "found" ? reprobe.number : created.numberHint;
|
|
239
|
+
const isDraft = reprobe.kind === "found" ? reprobe.isDraft : true;
|
|
240
|
+
const readiness = await maybeMakeReady(gh, prNumber, isDraft, requestReady, cwd);
|
|
241
|
+
return { kind: "created", reason: "created", prNumber, readiness };
|
|
242
|
+
}
|
|
243
|
+
// Generic create failure: resolve a possible concurrent creator once, never more.
|
|
244
|
+
const race = await probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd);
|
|
245
|
+
if (race.kind === "found") {
|
|
246
|
+
const readiness = await maybeMakeReady(gh, race.number, race.isDraft, requestReady, cwd);
|
|
247
|
+
return { kind: "already_open", reason: "already_open", prNumber: race.number, readiness };
|
|
248
|
+
}
|
|
249
|
+
return { kind: "unavailable", reason: "create_unavailable" };
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Ensure a matching draft epic-integration pull request exists. Idempotent and
|
|
253
|
+
* never throws or rejects — every internal failure, including an injected
|
|
254
|
+
* runner throwing, resolves to `{ kind: "unavailable", ... }`.
|
|
255
|
+
*/
|
|
256
|
+
export async function ensureEpicIntegrationPullRequest(options) {
|
|
257
|
+
try {
|
|
258
|
+
return await ensureEpicIntegrationPullRequestInner(options);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return { kind: "unavailable", reason: "probe_inconclusive" };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Build the production `gh` runner. Uses `execFile` (never a shell, never
|
|
266
|
+
* interpolated argv), ignores stdin, captures stdout/stderr, and bounds both
|
|
267
|
+
* the timeout and the output buffer. Every process-level failure (missing
|
|
268
|
+
* binary, timeout, spawn error) resolves a non-zero/timed-out result rather
|
|
269
|
+
* than rejecting.
|
|
270
|
+
*/
|
|
271
|
+
export function createProductionEpicIntegrationGhRunner(execFileImpl = nodeExecFile) {
|
|
272
|
+
return (args, options) => new Promise((resolve) => {
|
|
273
|
+
execFileImpl("gh", args, {
|
|
274
|
+
cwd: options.cwd,
|
|
275
|
+
timeout: options.timeoutMs,
|
|
276
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
277
|
+
encoding: "utf-8",
|
|
278
|
+
shell: false,
|
|
279
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
280
|
+
}, (error, stdout, stderr) => {
|
|
281
|
+
if (error) {
|
|
282
|
+
const timedOut = error.killed === true;
|
|
283
|
+
const code = typeof error.code === "number" ? error.code : null;
|
|
284
|
+
resolve({ exitCode: code, stdout: stdout ?? "", stderr: stderr ?? "", timedOut });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
resolve({ exitCode: 0, stdout: stdout ?? "", stderr: stderr ?? "", timedOut: false });
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
}
|
package/build/executor/cli.js
CHANGED
|
@@ -40,7 +40,11 @@ export function getExecutorUsage() {
|
|
|
40
40
|
" --repo <name> Repo to serve (repeatable).",
|
|
41
41
|
" --repos=<a,b> Comma-separated repos.",
|
|
42
42
|
" --epic-run-id <id> Dedicate this executor to an epic run (repeatable).",
|
|
43
|
-
"
|
|
43
|
+
" --repo-wide Explicit compatibility opt-out: claim from every",
|
|
44
|
+
" authorized repo, oldest-first, unscoped by run.",
|
|
45
|
+
" Exactly one of --epic-run-id or --repo-wide is",
|
|
46
|
+
" REQUIRED; omitting both is a startup error, not",
|
|
47
|
+
" repository-wide claiming (BAPI-1026 / R54).",
|
|
44
48
|
" --base-url <url> Bridge API endpoint (overrides BAPI_BASE_URL).",
|
|
45
49
|
" --executor-id <id> Stable executor id (default: <hostname>-<pid>).",
|
|
46
50
|
" --max-concurrent <n> Max concurrent jobs (>= 1, default 1).",
|
|
@@ -83,6 +87,7 @@ function isValidEpicRunId(value) {
|
|
|
83
87
|
export function parseExecutorArgs(argv, context) {
|
|
84
88
|
const repos = [];
|
|
85
89
|
const epicRunIds = [];
|
|
90
|
+
let repoWide = false;
|
|
86
91
|
let executorId;
|
|
87
92
|
let maxConcurrent = DEFAULT_MAX_CONCURRENT;
|
|
88
93
|
let once = false;
|
|
@@ -122,6 +127,9 @@ export function parseExecutorArgs(argv, context) {
|
|
|
122
127
|
if (!epicRunIds.includes(trimmed))
|
|
123
128
|
epicRunIds.push(trimmed);
|
|
124
129
|
}
|
|
130
|
+
else if (arg === "--repo-wide") {
|
|
131
|
+
repoWide = true;
|
|
132
|
+
}
|
|
125
133
|
else if (arg === "--executor-id") {
|
|
126
134
|
executorId = argv[++i];
|
|
127
135
|
if (!executorId)
|
|
@@ -182,6 +190,29 @@ export function parseExecutorArgs(argv, context) {
|
|
|
182
190
|
const executorIdFinal = executorId && executorId.trim().length > 0
|
|
183
191
|
? executorId.trim()
|
|
184
192
|
: `${context.hostname}-${context.pid}`;
|
|
193
|
+
// BAPI-1026 (R54) — the claim scope is now MANDATORY and explicit.
|
|
194
|
+
//
|
|
195
|
+
// Before this ticket, omitting `--epic-run-id` silently meant "claim the
|
|
196
|
+
// oldest queued row of ANY run in every authorized repo", which is how an
|
|
197
|
+
// executor could take a job belonging to an abandoned run (BAPI-993 B2). The
|
|
198
|
+
// server no longer treats an omitted scope as repository-wide once its
|
|
199
|
+
// deprecation window closes, so an executor started with no scope would poll
|
|
200
|
+
// forever and claim nothing. Refusing to START is the loud failure that
|
|
201
|
+
// replaces that silent idle: a misconfiguration is a startup error, visible
|
|
202
|
+
// immediately, rather than an executor that looks healthy and does no work.
|
|
203
|
+
if (repoWide && epicRunIds.length > 0) {
|
|
204
|
+
return {
|
|
205
|
+
kind: "error",
|
|
206
|
+
message: "--repo-wide cannot be combined with --epic-run-id: pass one claim scope, not both",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (!repoWide && epicRunIds.length === 0) {
|
|
210
|
+
return {
|
|
211
|
+
kind: "error",
|
|
212
|
+
message: "a claim scope is required: pass --epic-run-id <id> (repeatable) to serve " +
|
|
213
|
+
"specific epic runs, or --repo-wide to deliberately claim repository-wide",
|
|
214
|
+
};
|
|
215
|
+
}
|
|
185
216
|
const options = {
|
|
186
217
|
executorId: executorIdFinal,
|
|
187
218
|
repos,
|
|
@@ -208,6 +239,9 @@ export function parseExecutorArgs(argv, context) {
|
|
|
208
239
|
// was supplied, so downstream `epicRunIds !== undefined` checks (the claim
|
|
209
240
|
// manifest builder, startup diagnostics) see an unambiguous "unscoped".
|
|
210
241
|
...(epicRunIds.length > 0 ? { epicRunIds } : {}),
|
|
242
|
+
// BAPI-1026 (R54) — same "truly absent, never a falsy placeholder"
|
|
243
|
+
// discipline: `repoWide` is either `true` or the key is missing.
|
|
244
|
+
...(repoWide ? { repoWide: true } : {}),
|
|
211
245
|
};
|
|
212
246
|
return { kind: "ok", options };
|
|
213
247
|
}
|
|
@@ -267,14 +301,15 @@ export async function runExecutorCli(argv, overrides = {}) {
|
|
|
267
301
|
return 1;
|
|
268
302
|
}
|
|
269
303
|
const options = parsed.options;
|
|
270
|
-
// BAPI-794 — startup diagnostics:
|
|
271
|
-
//
|
|
272
|
-
//
|
|
304
|
+
// BAPI-794/BAPI-1026 — startup diagnostics: a bounded MODE label and, when
|
|
305
|
+
// scoped, only the COUNT of runs. Never credentials, job payloads, command
|
|
306
|
+
// arguments — and, since BAPI-1026, never the run IDs either, matching the
|
|
307
|
+
// per-claim logging policy the server side already enforces.
|
|
273
308
|
if (options.epicRunIds !== undefined) {
|
|
274
|
-
errorLog(`executor
|
|
309
|
+
errorLog(`executor claim scope: scoped (epic_run_count=${options.epicRunIds.length})`);
|
|
275
310
|
}
|
|
276
311
|
else {
|
|
277
|
-
errorLog("executor
|
|
312
|
+
errorLog("executor claim scope: repo_wide (explicit --repo-wide)");
|
|
278
313
|
}
|
|
279
314
|
// The mutating executor requires an EXPLICIT base URL (BAPI-676): `--base-url`
|
|
280
315
|
// then `BAPI_BASE_URL`, never an implicit production default. Fail here —
|
package/build/executor/deps.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { execFile, spawn } from "node:child_process";
|
|
11
11
|
import { existsSync } from "node:fs";
|
|
12
|
-
import { open, readFile, writeFile, appendFile, mkdir, mkdtemp, chmod, rm, readdir, lstat, stat, statfs, } from "node:fs/promises";
|
|
12
|
+
import { open, readFile, writeFile, appendFile, mkdir, mkdtemp, chmod, rm, rename, readdir, lstat, stat, statfs, } from "node:fs/promises";
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
import { promisify } from "node:util";
|
|
15
15
|
import { resolveMcpShimInvocationForRuntime } from "../mcp-server-invocation.js";
|
|
@@ -92,6 +92,10 @@ export function createDefaultExecutorDeps() {
|
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
94
|
removeFile: (filePath) => rm(filePath, { force: true }),
|
|
95
|
+
// BAPI-1021 (AC-6): worker-log archival's rename seam. Real `fs.promises.rename`
|
|
96
|
+
// rejects with `code: "EXDEV"` across filesystems/devices, which worker-log.ts
|
|
97
|
+
// treats as an ordinary best-effort archive failure like any other.
|
|
98
|
+
rename: (oldPath, newPath) => rename(oldPath, newPath),
|
|
95
99
|
stat: (filePath) => stat(filePath).then((s) => ({ mode: s.mode })),
|
|
96
100
|
statMtimeMs: (filePath) => stat(filePath)
|
|
97
101
|
.then((s) => s.mtimeMs)
|