@miraland-labs/conduit-bridge 0.16.2 → 0.16.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ensure-land-commit.js +77 -0
- package/dist/ensure-pull-request.js +2 -1
- package/dist/execution.js +126 -1
- package/dist/land-contract.js +47 -0
- package/dist/ops.js +39 -2
- package/dist/service.js +90 -5
- package/package.json +1 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mechanical land completion — commit scoped dirty work the agent left uncommitted before finalize.
|
|
3
|
+
*/
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { commitsAheadOfBase } from "./ensure-pull-request.js";
|
|
7
|
+
import { AgentNoLandCommitError, filterPathsInScope, requiresLandCommit, } from "./land-contract.js";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
async function workspaceHeadCommit(workspace) {
|
|
10
|
+
const { stdout } = await execFileAsync("git", ["-C", workspace, "rev-parse", "HEAD"], {
|
|
11
|
+
timeout: 30_000,
|
|
12
|
+
maxBuffer: 1_000_000,
|
|
13
|
+
});
|
|
14
|
+
const head = stdout.trim();
|
|
15
|
+
if (!head)
|
|
16
|
+
throw new Error("Could not read workspace HEAD");
|
|
17
|
+
return head;
|
|
18
|
+
}
|
|
19
|
+
async function uncommittedPaths(workspace) {
|
|
20
|
+
const { stdout } = await execFileAsync("git", ["-C", workspace, "status", "--porcelain"], {
|
|
21
|
+
timeout: 30_000,
|
|
22
|
+
maxBuffer: 8_000_000,
|
|
23
|
+
});
|
|
24
|
+
const paths = [];
|
|
25
|
+
for (const line of stdout.split("\n")) {
|
|
26
|
+
if (!line.trim())
|
|
27
|
+
continue;
|
|
28
|
+
const entry = line.slice(3).trim();
|
|
29
|
+
if (!entry)
|
|
30
|
+
continue;
|
|
31
|
+
const path = entry.includes(" -> ")
|
|
32
|
+
? entry.slice(entry.lastIndexOf(" -> ") + 4).trim()
|
|
33
|
+
: entry;
|
|
34
|
+
if (path)
|
|
35
|
+
paths.push(path.replace(/^"|"$/g, ""));
|
|
36
|
+
}
|
|
37
|
+
return paths;
|
|
38
|
+
}
|
|
39
|
+
async function commitScopedPaths(workspace, paths) {
|
|
40
|
+
if (!paths.length)
|
|
41
|
+
throw new Error("Cannot land an empty path set");
|
|
42
|
+
await execFileAsync("git", ["-C", workspace, "add", "--", ...paths], {
|
|
43
|
+
timeout: 120_000,
|
|
44
|
+
maxBuffer: 8_000_000,
|
|
45
|
+
});
|
|
46
|
+
await execFileAsync("git", ["-C", workspace, "commit", "-m", "Conduit: land scoped agent changes"], { timeout: 120_000, maxBuffer: 8_000_000 });
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* When the land contract applies, ensure the attempt branch has at least one commit after base.
|
|
50
|
+
* Commits scoped dirty files Bridge can see; throws AgentNoLandCommitError when nothing is landable.
|
|
51
|
+
*/
|
|
52
|
+
export async function ensureLandCommit(input) {
|
|
53
|
+
if (!requiresLandCommit({ grants: input.grants, spec: input.spec })) {
|
|
54
|
+
return { kind: "skipped" };
|
|
55
|
+
}
|
|
56
|
+
const base = input.spec.repository?.base_commit;
|
|
57
|
+
if (!base)
|
|
58
|
+
return { kind: "skipped" };
|
|
59
|
+
const countAhead = input.countCommitsAhead ?? commitsAheadOfBase;
|
|
60
|
+
const readHead = input.readHeadCommit ?? workspaceHeadCommit;
|
|
61
|
+
const listPending = input.listUncommittedPaths ?? uncommittedPaths;
|
|
62
|
+
const commitPaths = input.commitPaths ?? commitScopedPaths;
|
|
63
|
+
const ahead = await countAhead(input.workspace, base);
|
|
64
|
+
if (ahead !== null && ahead > 0) {
|
|
65
|
+
return { kind: "already_landed", headCommit: await readHead(input.workspace) };
|
|
66
|
+
}
|
|
67
|
+
const pending = filterPathsInScope(await listPending(input.workspace), input.spec.change_scope ?? []);
|
|
68
|
+
if (!pending.length) {
|
|
69
|
+
throw new AgentNoLandCommitError(base);
|
|
70
|
+
}
|
|
71
|
+
await commitPaths(input.workspace, pending);
|
|
72
|
+
const afterAhead = await countAhead(input.workspace, base);
|
|
73
|
+
if (afterAhead === 0) {
|
|
74
|
+
throw new AgentNoLandCommitError(base);
|
|
75
|
+
}
|
|
76
|
+
return { kind: "committed", headCommit: await readHead(input.workspace), paths: pending };
|
|
77
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { execFile } from "node:child_process";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { normalizeRepositoryUrl } from "./brief.js";
|
|
9
|
+
import { agentNoLandCommitMessage } from "./land-contract.js";
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
/** Mirror execution.ts FORGE_TRANSPORT_PATTERN — keep local to avoid import cycles. */
|
|
11
12
|
const FORGE_TRANSPORT_PATTERN = /unable to access '?https?:\/\/|error in the http2 framing layer|could not resolve host|connection (?:reset|timed out|refused)|\bcurl\b.*\b(?:52|55|56|92)\b|remote end hung up unexpectedly|\brpc failed\b|tls handshake|network is unreachable|operation timed out/i;
|
|
@@ -197,7 +198,7 @@ export async function ensureDeliveryPullRequest(input) {
|
|
|
197
198
|
if (base) {
|
|
198
199
|
const ahead = await countAhead(input.workspace, base);
|
|
199
200
|
if (ahead === 0) {
|
|
200
|
-
throw new Error(
|
|
201
|
+
throw new Error(agentNoLandCommitMessage(base));
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
204
|
await push(input.workspace);
|
package/dist/execution.js
CHANGED
|
@@ -11,6 +11,8 @@ import { execFile } from "node:child_process";
|
|
|
11
11
|
import { promisify } from "node:util";
|
|
12
12
|
import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl, resolveAttemptStartCommit } from "./brief.js";
|
|
13
13
|
import { ensureDeliveryPullRequest } from "./ensure-pull-request.js";
|
|
14
|
+
import { ensureLandCommit } from "./ensure-land-commit.js";
|
|
15
|
+
import { AgentNoLandCommitError, requiresLandCommit } from "./land-contract.js";
|
|
14
16
|
const execFileAsync = promisify(execFile);
|
|
15
17
|
import { captureVerificationFailure, ensureTestEvidence } from "./ensure-test-evidence.js";
|
|
16
18
|
/** Keep a Mac awake only while an assignment is active; display sleep remains allowed. */
|
|
@@ -63,7 +65,7 @@ export function forgeTransportFailure(message) {
|
|
|
63
65
|
/** Environment faults that must Hold — mirror CP ENVIRONMENT_FAILURES message patterns. */
|
|
64
66
|
const FINALIZE_ENVIRONMENT_PATTERN = /base[_ ]not[_ ]ancestor|required base commit is not available|source[_ ]workspace[_ ]dirty|uncommitted changes|dirty workspace|workspace[_ ]head[_ ]changed|workspace[_ ]repository|workspace[_ ]unavailable|driver[_ ]not[_ ]authenticated|not logged in|no login|not authenticated|login required|no[_ ]online[_ ]driver|bridge[_ ]preflight|stale bridge|bridge version/i;
|
|
65
67
|
/** Delivery-report / grant / evidence contract defects (non-retryable rework). */
|
|
66
|
-
const FINALIZE_CONTRACT_PATTERN = /missing required evidence|without the repo_write grant|pull_request_url|Artifact delivery requires|outside the approved scope|head commit|test evidence must include|Met acceptance criteria|Agent report|Agent reported|Delivery report/i;
|
|
68
|
+
const FINALIZE_CONTRACT_PATTERN = /missing required evidence|without the repo_write grant|pull_request_url|Artifact delivery requires|outside the approved scope|head commit|test evidence must include|Met acceptance criteria|Agent report|Agent reported|Agent did not land repository changes|Delivery report/i;
|
|
67
69
|
/**
|
|
68
70
|
* Classify throws from ensureDeliveryPullRequest + validateDeliveryReport.
|
|
69
71
|
* Unknown non-contract faults must not be laundered as execution_contract_failed.
|
|
@@ -852,6 +854,23 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
852
854
|
console.error(`Assignment ${taskId} failed: ${redactSecrets(message)}`);
|
|
853
855
|
return;
|
|
854
856
|
}
|
|
857
|
+
if (requiresLandCommit({ grants, spec })) {
|
|
858
|
+
try {
|
|
859
|
+
const land = await ensureLandCommit({ workspace: attemptWorkspace, spec, grants });
|
|
860
|
+
if (land.kind === "committed") {
|
|
861
|
+
await client.attemptRequest(taskId, "progress", {
|
|
862
|
+
phase: "preparing_delivery",
|
|
863
|
+
message: `Bridge committed scoped agent changes that were left uncommitted (${land.paths.join(", ")}).`,
|
|
864
|
+
idempotency_key: `bridge:progress:${active.attemptId}:land-commit`,
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
catch (error) {
|
|
869
|
+
if (!(error instanceof AgentNoLandCommitError))
|
|
870
|
+
throw error;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
let agentSessionId = result.sessionId ?? undefined;
|
|
855
874
|
let report;
|
|
856
875
|
let reportText = result.resultText ?? "";
|
|
857
876
|
try {
|
|
@@ -900,6 +919,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
900
919
|
if (repaired.sessionId) {
|
|
901
920
|
config.sessions = { ...config.sessions, [taskId]: repaired.sessionId };
|
|
902
921
|
resumeSessionId = repaired.sessionId;
|
|
922
|
+
agentSessionId = repaired.sessionId;
|
|
903
923
|
}
|
|
904
924
|
if (repaired.status === "failed") {
|
|
905
925
|
const message = repaired.error ?? "Delivery report repair failed";
|
|
@@ -952,6 +972,25 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
952
972
|
report = parsed;
|
|
953
973
|
}
|
|
954
974
|
try {
|
|
975
|
+
report = await finalizeRepositoryLand({
|
|
976
|
+
client,
|
|
977
|
+
taskId,
|
|
978
|
+
attemptId: active.attemptId,
|
|
979
|
+
workspace: attemptWorkspace,
|
|
980
|
+
spec,
|
|
981
|
+
grants,
|
|
982
|
+
report,
|
|
983
|
+
driver,
|
|
984
|
+
executionClass,
|
|
985
|
+
capabilities: spec.required_capabilities ?? [],
|
|
986
|
+
verificationCommands: attemptBrief?.verification ?? [],
|
|
987
|
+
objective: task.objective,
|
|
988
|
+
selection,
|
|
989
|
+
fuel,
|
|
990
|
+
fuelSource,
|
|
991
|
+
timeoutMs,
|
|
992
|
+
resumeSessionId: agentSessionId,
|
|
993
|
+
});
|
|
955
994
|
// Mechanical land path: agent may forget gh; Bridge pushes and the control plane opens the PR.
|
|
956
995
|
report = await ensureDeliveryPullRequest({
|
|
957
996
|
client,
|
|
@@ -1037,6 +1076,92 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1037
1076
|
}
|
|
1038
1077
|
}
|
|
1039
1078
|
}
|
|
1079
|
+
function buildLandContinuationPrompt(input) {
|
|
1080
|
+
const scope = input.spec.change_scope ?? [];
|
|
1081
|
+
return [
|
|
1082
|
+
"The implementation turn finished without landing repository changes on the attempt branch.",
|
|
1083
|
+
"Perform exactly one land-only continuation: make the minimal scoped edits required by the approved plan, commit them on the current branch, run any bounded verification commands you are allowed to run, then return one fenced ```json Delivery object.",
|
|
1084
|
+
"Do not open a pull request — Bridge opens it after finalize when pr_create is granted.",
|
|
1085
|
+
"Your working branch is already checked out. Use git add and git commit; report the resulting sha as head_commit.",
|
|
1086
|
+
"",
|
|
1087
|
+
"OBJECTIVE",
|
|
1088
|
+
input.objective,
|
|
1089
|
+
"",
|
|
1090
|
+
"APPROVED CHANGE SCOPE",
|
|
1091
|
+
...(scope.length ? scope.map((path) => `- ${path}`) : ["- (none — stay within the plan)"]),
|
|
1092
|
+
"",
|
|
1093
|
+
"APPROVED ACCEPTANCE CRITERIA",
|
|
1094
|
+
...(input.acceptance.length ? input.acceptance.map((criterion) => `- ${criterion}`) : ["- None"]),
|
|
1095
|
+
"",
|
|
1096
|
+
"REQUIRED DELIVERY SHAPE",
|
|
1097
|
+
agentReportTemplate,
|
|
1098
|
+
].join("\n");
|
|
1099
|
+
}
|
|
1100
|
+
function bindLandHeadCommit(report, headCommit) {
|
|
1101
|
+
return { ...report, head_commit: headCommit };
|
|
1102
|
+
}
|
|
1103
|
+
async function finalizeRepositoryLand(input) {
|
|
1104
|
+
if (!requiresLandCommit({ grants: input.grants, spec: input.spec }))
|
|
1105
|
+
return input.report;
|
|
1106
|
+
const applyLand = async (report) => {
|
|
1107
|
+
const land = await ensureLandCommit({
|
|
1108
|
+
workspace: input.workspace,
|
|
1109
|
+
spec: input.spec,
|
|
1110
|
+
grants: input.grants,
|
|
1111
|
+
});
|
|
1112
|
+
if (land.kind === "committed") {
|
|
1113
|
+
await input.client.attemptRequest(input.taskId, "progress", {
|
|
1114
|
+
phase: "preparing_delivery",
|
|
1115
|
+
message: `Bridge committed scoped agent changes that were left uncommitted (${land.paths.join(", ")}).`,
|
|
1116
|
+
idempotency_key: `bridge:progress:${input.attemptId}:land-commit`,
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
if (land.kind === "already_landed" || land.kind === "committed") {
|
|
1120
|
+
return bindLandHeadCommit(report, land.headCommit);
|
|
1121
|
+
}
|
|
1122
|
+
return report;
|
|
1123
|
+
};
|
|
1124
|
+
try {
|
|
1125
|
+
return await applyLand(input.report);
|
|
1126
|
+
}
|
|
1127
|
+
catch (error) {
|
|
1128
|
+
if (!(error instanceof AgentNoLandCommitError))
|
|
1129
|
+
throw error;
|
|
1130
|
+
}
|
|
1131
|
+
await input.client.attemptRequest(input.taskId, "progress", {
|
|
1132
|
+
phase: "changing",
|
|
1133
|
+
message: "Agent finished without landing repository changes; starting one land-only continuation turn.",
|
|
1134
|
+
idempotency_key: `bridge:progress:${input.attemptId}:land-continuation`,
|
|
1135
|
+
});
|
|
1136
|
+
const continuation = await input.driver.run({
|
|
1137
|
+
prompt: buildLandContinuationPrompt({
|
|
1138
|
+
objective: input.objective,
|
|
1139
|
+
spec: input.spec,
|
|
1140
|
+
acceptance: input.spec.acceptance ?? [],
|
|
1141
|
+
}),
|
|
1142
|
+
workspace: input.workspace,
|
|
1143
|
+
grants: input.grants,
|
|
1144
|
+
capabilities: input.capabilities,
|
|
1145
|
+
verificationCommands: input.verificationCommands,
|
|
1146
|
+
executionClass: "mutate_repo",
|
|
1147
|
+
resumeSessionId: input.resumeSessionId ?? undefined,
|
|
1148
|
+
timeoutMs: input.timeoutMs,
|
|
1149
|
+
model: input.selection.model ?? undefined,
|
|
1150
|
+
fuel: input.fuel,
|
|
1151
|
+
fuelSource: input.fuelSource,
|
|
1152
|
+
});
|
|
1153
|
+
if (continuation.status === "failed") {
|
|
1154
|
+
throw new AgentNoLandCommitError(input.spec.repository?.base_commit ?? "unknown");
|
|
1155
|
+
}
|
|
1156
|
+
let report = input.report;
|
|
1157
|
+
try {
|
|
1158
|
+
report = parseAgentReport(continuation.resultText ?? "", input.spec.acceptance ?? []);
|
|
1159
|
+
}
|
|
1160
|
+
catch {
|
|
1161
|
+
// Land may have succeeded even when the continuation envelope is malformed — git is authoritative.
|
|
1162
|
+
}
|
|
1163
|
+
return applyLand(report);
|
|
1164
|
+
}
|
|
1040
1165
|
function buildDeliveryRepairPrompt(parseError, previousReply, acceptance) {
|
|
1041
1166
|
const replyTail = redactSecrets(previousReply.slice(-8_000));
|
|
1042
1167
|
return [
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Land contract — when a repository delivery must carry a commit on the attempt branch.
|
|
3
|
+
* Mirrors driver mustCommit / executionClassPromptRules; observe/verify/artifact paths stay out.
|
|
4
|
+
*/
|
|
5
|
+
/** True when finalize must prove a commit after the plan base on the attempt branch. */
|
|
6
|
+
export function requiresLandCommit(input) {
|
|
7
|
+
if (input.spec.deliverable === "artifact")
|
|
8
|
+
return false;
|
|
9
|
+
const executionClass = input.spec.execution_class;
|
|
10
|
+
if (executionClass === "observe" || executionClass === "observe_network" || executionClass === "verify") {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
if (executionClass === "publish_artifact")
|
|
14
|
+
return false;
|
|
15
|
+
return input.grants.includes("repo_write") && input.grants.includes("branch_create");
|
|
16
|
+
}
|
|
17
|
+
/** Stable prefix for classifyFinalizeFailure and the control-plane failure envelope. */
|
|
18
|
+
export const AGENT_NO_LAND_COMMIT_PREFIX = "Agent did not land repository changes";
|
|
19
|
+
export function agentNoLandCommitMessage(baseCommit) {
|
|
20
|
+
return `${AGENT_NO_LAND_COMMIT_PREFIX}: the attempt branch has no commit after base ${baseCommit.slice(0, 12)}.`;
|
|
21
|
+
}
|
|
22
|
+
/** Legacy ensureDeliveryPullRequest text — kept recognizable for stored failures. */
|
|
23
|
+
export const LEGACY_NO_LAND_COMMIT_PATTERN = /no commit after base .*; this run produced no change to deliver/i;
|
|
24
|
+
export function isAgentNoLandCommitMessage(message) {
|
|
25
|
+
return message.startsWith(AGENT_NO_LAND_COMMIT_PREFIX) || LEGACY_NO_LAND_COMMIT_PATTERN.test(message);
|
|
26
|
+
}
|
|
27
|
+
export class AgentNoLandCommitError extends Error {
|
|
28
|
+
baseCommit;
|
|
29
|
+
constructor(baseCommit) {
|
|
30
|
+
super(agentNoLandCommitMessage(baseCommit));
|
|
31
|
+
this.name = "AgentNoLandCommitError";
|
|
32
|
+
this.baseCommit = baseCommit;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function pathMatchesScope(path, scope) {
|
|
36
|
+
const normalized = scope.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
37
|
+
if (normalized.endsWith("/**")) {
|
|
38
|
+
const prefix = normalized.slice(0, -3);
|
|
39
|
+
return path === prefix || path.startsWith(`${prefix}/`);
|
|
40
|
+
}
|
|
41
|
+
return path === normalized;
|
|
42
|
+
}
|
|
43
|
+
export function filterPathsInScope(paths, changeScope) {
|
|
44
|
+
if (!changeScope.length)
|
|
45
|
+
return [...paths];
|
|
46
|
+
return paths.filter((path) => changeScope.some((scope) => pathMatchesScope(path, scope)));
|
|
47
|
+
}
|
package/dist/ops.js
CHANGED
|
@@ -9,9 +9,11 @@ import { dirname, join, resolve } from "node:path";
|
|
|
9
9
|
import { parseArgs } from "node:util";
|
|
10
10
|
import { ConduitClient } from "./client.js";
|
|
11
11
|
import { loadConfig } from "./config.js";
|
|
12
|
+
import { ensureCheckout } from "./checkout.js";
|
|
12
13
|
import { detectInstalledClients, probeAgentHealth } from "./detect.js";
|
|
13
14
|
import { driverIdsFromDetectedLabels } from "./drivers.js";
|
|
14
15
|
import { BRIDGE_PROTOCOL_VERSION, describePreflightIssue, runBridgePreflight } from "./preflight.js";
|
|
16
|
+
import { applyRunnerToolPath, runnerServiceWorkspaceWarnings } from "./service.js";
|
|
15
17
|
import { bridgeVersion } from "./version.js";
|
|
16
18
|
export const OPS_VERBS = [
|
|
17
19
|
"connect", "enroll", "install", "switch", "online", "offline", "status", "doctor", "disconnect", "uninstall",
|
|
@@ -154,6 +156,17 @@ export async function resolveDrivers(env, argv, detect = detectInstalledClients)
|
|
|
154
156
|
throw new Error("No coding agent found on this computer. Install one (Claude Code, Codex, Cursor, OpenCode, Pi, Kiro, Antigravity, Grok Build), " +
|
|
155
157
|
"then retry — or set CONDUIT_DRIVERS / pass driver ids explicitly.");
|
|
156
158
|
}
|
|
159
|
+
/** When CONDUIT_DRIVERS is unset, shared-capacity machines should not auto-online every detected IDE. */
|
|
160
|
+
export function resolveInstallDrivers(env, argv, detect = detectInstalledClients) {
|
|
161
|
+
return resolveDrivers(env, argv, detect).then((drivers) => {
|
|
162
|
+
if (argv.length || splitOpsList(env.CONDUIT_DRIVERS).length || drivers.length <= 1)
|
|
163
|
+
return drivers;
|
|
164
|
+
const preferred = drivers.includes("claude-code") ? "claude-code" : drivers[0];
|
|
165
|
+
console.log(`Multiple agents detected — bringing only ${preferred} online. ` +
|
|
166
|
+
"Set CONDUIT_DRIVERS in ops.env to choose lanes explicitly.");
|
|
167
|
+
return [preferred];
|
|
168
|
+
});
|
|
169
|
+
}
|
|
157
170
|
/** Quote args for a copy-pasteable shell/cmd line (paths with spaces). */
|
|
158
171
|
export function shellQuoteArgs(args) {
|
|
159
172
|
return args.map((arg) => {
|
|
@@ -220,6 +233,11 @@ export async function runOps(verb, argv = [], deps = {}) {
|
|
|
220
233
|
console.log(`Repo: ${env.CONDUIT_REPO || "(none)"}`);
|
|
221
234
|
console.log(`Drivers: ${env.CONDUIT_DRIVERS || "(auto-detect installed agents)"}`);
|
|
222
235
|
console.log(`Roles: ${env.CONDUIT_ROLES}`);
|
|
236
|
+
if (env.CONDUIT_WORKSPACE) {
|
|
237
|
+
for (const warning of runnerServiceWorkspaceWarnings(resolve(expandOpsValue(env.CONDUIT_WORKSPACE)))) {
|
|
238
|
+
console.warn(`WARNING: ${warning}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
223
241
|
console.log("");
|
|
224
242
|
}
|
|
225
243
|
else {
|
|
@@ -232,10 +250,15 @@ export async function runOps(verb, argv = [], deps = {}) {
|
|
|
232
250
|
requireOpsEnv(env);
|
|
233
251
|
if (!env.CONDUIT_WORKSPACE)
|
|
234
252
|
throw new Error(`Set CONDUIT_WORKSPACE in ${defaultOpsEnvPath()}`);
|
|
253
|
+
applyRunnerToolPath();
|
|
235
254
|
const config = await (deps.loadBridgeConfig ?? loadConfig)();
|
|
255
|
+
const workspace = resolve(expandOpsValue(env.CONDUIT_WORKSPACE));
|
|
256
|
+
for (const warning of runnerServiceWorkspaceWarnings(workspace)) {
|
|
257
|
+
console.warn(`WARNING: ${warning}`);
|
|
258
|
+
}
|
|
236
259
|
const report = await (deps.preflight ?? runBridgePreflight)({
|
|
237
260
|
config,
|
|
238
|
-
workspace
|
|
261
|
+
workspace,
|
|
239
262
|
expectedRepository: env.CONDUIT_REPO || undefined,
|
|
240
263
|
});
|
|
241
264
|
const installed = await detectInstalledClients();
|
|
@@ -257,6 +280,12 @@ export async function runOps(verb, argv = [], deps = {}) {
|
|
|
257
280
|
console.log("");
|
|
258
281
|
}
|
|
259
282
|
if (report.ready) {
|
|
283
|
+
const serviceWarnings = runnerServiceWorkspaceWarnings(workspace);
|
|
284
|
+
if (serviceWarnings.length) {
|
|
285
|
+
for (const warning of serviceWarnings)
|
|
286
|
+
console.warn(`WARNING: ${warning}`);
|
|
287
|
+
throw new Error("Background runner service workspace does not match ops.env");
|
|
288
|
+
}
|
|
260
289
|
console.log("Workspace, online lanes, local fuel, CLI availability, and authentication are ready.");
|
|
261
290
|
return;
|
|
262
291
|
}
|
|
@@ -446,12 +475,20 @@ export async function runOps(verb, argv = [], deps = {}) {
|
|
|
446
475
|
throw new Error(`No workspace declared. Rerun with --workspace /path/to/repo, or set CONDUIT_WORKSPACE in ${defaultOpsEnvPath()}`);
|
|
447
476
|
}
|
|
448
477
|
const workspace = resolve(expandOpsValue(installEnv.CONDUIT_WORKSPACE));
|
|
449
|
-
const drivers = await
|
|
478
|
+
const drivers = await (deps.resolveInstallDrivers ?? resolveInstallDrivers)(installEnv, installArgv);
|
|
450
479
|
for (const id of drivers) {
|
|
451
480
|
if (LOCAL_FUEL_DRIVERS.has(id))
|
|
452
481
|
runBridge(["drivers", "fuel", id, "local"]);
|
|
453
482
|
}
|
|
454
483
|
runBridge(["drivers", "online", ...drivers]);
|
|
484
|
+
applyRunnerToolPath();
|
|
485
|
+
if (installEnv.CONDUIT_REPO) {
|
|
486
|
+
const checkout = deps.ensureCheckout ?? ensureCheckout;
|
|
487
|
+
const result = await checkout(workspace, installEnv.CONDUIT_REPO);
|
|
488
|
+
console.log(result === "cloned"
|
|
489
|
+
? `Cloned ${installEnv.CONDUIT_REPO} into ${workspace}`
|
|
490
|
+
: `Workspace already matches ${installEnv.CONDUIT_REPO}`);
|
|
491
|
+
}
|
|
455
492
|
// Prove the exact local environment before installing a service that advertises availability.
|
|
456
493
|
runBridge(["ops", "doctor"]);
|
|
457
494
|
const installArgs = ["install-service", "--workspace", workspace];
|
package/dist/service.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { homedir, platform } from "node:os";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
-
import { realpathSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
5
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
6
6
|
export const SERVICE_LABEL = "io.miraland.conduit-runner";
|
|
7
7
|
function configDir() {
|
|
@@ -124,8 +124,88 @@ function escapeSystemd(value) {
|
|
|
124
124
|
function launchAgentsPath() {
|
|
125
125
|
return join(homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`);
|
|
126
126
|
}
|
|
127
|
-
function systemdUnitPath() {
|
|
128
|
-
return join(
|
|
127
|
+
export function systemdUnitPath(home = homedir()) {
|
|
128
|
+
return join(home, ".config", "systemd", "user", "conduit-runner.service");
|
|
129
|
+
}
|
|
130
|
+
export function systemdDropInDir(home = homedir()) {
|
|
131
|
+
return join(home, ".config", "systemd", "user", "conduit-runner.service.d");
|
|
132
|
+
}
|
|
133
|
+
/** Parse `--workspace` from a runner argv list or systemd ExecStart line. */
|
|
134
|
+
export function parseRunnerWorkspaceFromProgramArguments(args) {
|
|
135
|
+
const index = args.indexOf("--workspace");
|
|
136
|
+
return index >= 0 && args[index + 1] ? args[index + 1] : null;
|
|
137
|
+
}
|
|
138
|
+
export function parseRunnerWorkspaceFromExecStart(execStart) {
|
|
139
|
+
const match = /--workspace(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s]+))/.exec(execStart);
|
|
140
|
+
return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
|
|
141
|
+
}
|
|
142
|
+
function dropInOverridesExecStart(content) {
|
|
143
|
+
return /^\s*ExecStart\s*=/m.test(content);
|
|
144
|
+
}
|
|
145
|
+
/** Drop-in fragments that override ExecStart can silently hijack ops install — strip them. */
|
|
146
|
+
export async function removeExecStartOverridesFromSystemdDropIns(home = homedir()) {
|
|
147
|
+
const dir = systemdDropInDir(home);
|
|
148
|
+
if (!existsSync(dir))
|
|
149
|
+
return [];
|
|
150
|
+
const removed = [];
|
|
151
|
+
for (const name of await readdir(dir)) {
|
|
152
|
+
if (!name.endsWith(".conf"))
|
|
153
|
+
continue;
|
|
154
|
+
const path = join(dir, name);
|
|
155
|
+
const content = await readFile(path, "utf8");
|
|
156
|
+
if (!dropInOverridesExecStart(content))
|
|
157
|
+
continue;
|
|
158
|
+
const kept = content.split(/\r?\n/).filter((line) => {
|
|
159
|
+
const trimmed = line.trim();
|
|
160
|
+
return trimmed && !trimmed.startsWith("#") && !/^ExecStart\s*=/.test(trimmed);
|
|
161
|
+
});
|
|
162
|
+
if (kept.length === 0) {
|
|
163
|
+
await unlink(path);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const next = content.split(/\r?\n/).filter((line) => !/^\s*ExecStart\s*=/.test(line)).join("\n").trimEnd();
|
|
167
|
+
await writeFile(path, next ? `${next}\n` : "", { mode: 0o644 });
|
|
168
|
+
if (!next)
|
|
169
|
+
await unlink(path).catch(() => undefined);
|
|
170
|
+
}
|
|
171
|
+
removed.push(name);
|
|
172
|
+
}
|
|
173
|
+
return removed;
|
|
174
|
+
}
|
|
175
|
+
export async function readStoredRunnerServiceWorkspace(home = homedir()) {
|
|
176
|
+
try {
|
|
177
|
+
const stored = JSON.parse(await readFile(serviceStatePath(), "utf8"));
|
|
178
|
+
return stored.options.workspace
|
|
179
|
+
?? parseRunnerWorkspaceFromProgramArguments(stored.programArguments)
|
|
180
|
+
?? null;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** Effective workspace from the installed supervisor (systemd user unit on Linux). */
|
|
187
|
+
export function readEffectiveLinuxRunnerWorkspace(home = homedir()) {
|
|
188
|
+
if (platform() !== "linux")
|
|
189
|
+
return null;
|
|
190
|
+
const result = spawnSync("systemctl", ["--user", "cat", "conduit-runner.service"], { encoding: "utf8" });
|
|
191
|
+
if (result.status !== 0)
|
|
192
|
+
return null;
|
|
193
|
+
let execStart = null;
|
|
194
|
+
for (const line of result.stdout.split(/\r?\n/)) {
|
|
195
|
+
if (line.startsWith("ExecStart="))
|
|
196
|
+
execStart = line.slice("ExecStart=".length);
|
|
197
|
+
}
|
|
198
|
+
return execStart ? parseRunnerWorkspaceFromExecStart(execStart) : null;
|
|
199
|
+
}
|
|
200
|
+
export function runnerServiceWorkspaceWarnings(declaredWorkspace, home = homedir()) {
|
|
201
|
+
const declared = resolve(declaredWorkspace);
|
|
202
|
+
const warnings = [];
|
|
203
|
+
const stored = platform() === "linux" ? readEffectiveLinuxRunnerWorkspace(home) : null;
|
|
204
|
+
if (stored && resolve(stored) !== declared) {
|
|
205
|
+
warnings.push(`Background runner service uses workspace ${stored}, but ops.env declares ${declaredWorkspace}. ` +
|
|
206
|
+
"Run ops install to realign the service, or remove stale systemd drop-ins under conduit-runner.service.d/.");
|
|
207
|
+
}
|
|
208
|
+
return warnings;
|
|
129
209
|
}
|
|
130
210
|
export const WINDOWS_TASK_NAME = "ConduitBridgeRunner";
|
|
131
211
|
/** Quote one argv token for a Windows Task Scheduler /TR command line. */
|
|
@@ -256,6 +336,11 @@ export async function installRunnerService(options = {}) {
|
|
|
256
336
|
return { path: plistPath, platform: "darwin" };
|
|
257
337
|
}
|
|
258
338
|
const unitPath = systemdUnitPath();
|
|
339
|
+
const removedDropIns = await removeExecStartOverridesFromSystemdDropIns();
|
|
340
|
+
if (removedDropIns.length) {
|
|
341
|
+
console.warn(`Removed stale systemd drop-in(s) that overrode ExecStart: ${removedDropIns.join(", ")}. ` +
|
|
342
|
+
"Those files can silently point the runner at the wrong checkout after ops switch.");
|
|
343
|
+
}
|
|
259
344
|
await mkdir(dirname(unitPath), { recursive: true });
|
|
260
345
|
await mkdir(linuxLogDir(), { recursive: true });
|
|
261
346
|
await writeFile(unitPath, systemdUserUnit(programArguments, configDir()), { mode: 0o644 });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraland-labs/conduit-bridge",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.3",
|
|
4
4
|
"description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity / Grok Build agents for a Conduit organization",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|