@oisincoveney/pipeline 2.8.0 → 2.8.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/dist/argo-submit.d.ts +2 -4
- package/dist/argo-submit.js +80 -80
- package/dist/cluster-doctor.js +89 -101
- package/dist/config/defaults.js +9 -19
- package/dist/config/load.js +32 -39
- package/dist/config/schemas.d.ts +1 -1
- package/dist/gates.js +6 -225
- package/dist/mcp/gateway-error.js +15 -0
- package/dist/mcp/gateway.js +119 -220
- package/dist/moka-global-config.js +20 -20
- package/dist/moka-submit.d.ts +1 -1
- package/dist/pipeline-runtime.js +580 -371
- package/dist/run-state/git-refs.js +124 -94
- package/dist/runner-command-contract.d.ts +2 -2
- package/dist/runner-event-sink.js +37 -69
- package/dist/runtime/agent-node/agent-node.js +214 -173
- package/dist/runtime/builtins/builtins.js +344 -57
- package/dist/runtime/changed-files/changed-files.js +15 -27
- package/dist/runtime/changed-files/index.js +2 -0
- package/dist/runtime/drain-merge/drain-merge.js +124 -82
- package/dist/runtime/gates/gates.js +46 -28
- package/dist/runtime/hooks/hooks.js +74 -29
- package/dist/runtime/opencode-server.js +27 -21
- package/dist/runtime/opencode-session-executor.js +101 -44
- package/dist/runtime/parallel-node/parallel-node.js +24 -5
- package/dist/runtime/select-candidate/select-candidate.js +45 -29
- package/dist/runtime/services/agent-node-runtime-service.js +15 -0
- package/dist/runtime/services/command-executor-service.js +8 -0
- package/dist/runtime/services/config-io-service.js +42 -0
- package/dist/runtime/services/drain-merge-git-service.js +10 -0
- package/dist/runtime/services/git-porcelain-service.js +38 -0
- package/dist/runtime/services/kubernetes-argo-service.d.ts +13 -0
- package/dist/runtime/services/kubernetes-argo-service.js +81 -0
- package/dist/runtime/services/mcp-gateway-service.js +184 -0
- package/dist/runtime/services/opencode-sdk-service.js +27 -0
- package/dist/runtime/services/runner-event-sink-http-service.js +80 -0
- package/dist/runtime/services/select-candidate-service.js +13 -0
- package/package.json +1 -1
package/dist/gates.js
CHANGED
|
@@ -1,231 +1,12 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { existsSync
|
|
1
|
+
import "./safe-json.js";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
4
|
+
import "execa";
|
|
5
|
+
import "package-manager-detector/commands";
|
|
6
|
+
import "package-manager-detector/detect";
|
|
7
7
|
//#region src/gates.ts
|
|
8
|
-
const FAILING_TEST_RE = /^[✗×✕●]\s+(.+)$/;
|
|
9
|
-
function parseFailingTests(output) {
|
|
10
|
-
return output.split("\n").flatMap((line) => {
|
|
11
|
-
const m = FAILING_TEST_RE.exec(line);
|
|
12
|
-
return m ? [m[1].trim()] : [];
|
|
13
|
-
});
|
|
14
|
-
}
|
|
15
|
-
function displayCommand(command) {
|
|
16
|
-
return [command.command, ...command.args].join(" ");
|
|
17
|
-
}
|
|
18
|
-
function readPackageScripts(worktreePath) {
|
|
19
|
-
try {
|
|
20
|
-
return parseJson(readFileSync(join(worktreePath, "package.json"), "utf-8"), "package.json").scripts ?? {};
|
|
21
|
-
} catch {
|
|
22
|
-
return {};
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
function envCommand(envName) {
|
|
26
|
-
const raw = process.env[envName]?.trim();
|
|
27
|
-
if (!raw) return null;
|
|
28
|
-
return {
|
|
29
|
-
command: raw,
|
|
30
|
-
args: [],
|
|
31
|
-
shell: true
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
async function resolvePackageScript(worktreePath, scriptName) {
|
|
35
|
-
if (!readPackageScripts(worktreePath)[scriptName]) return null;
|
|
36
|
-
const resolved = resolveCommand(await detectPackageManagerAgent(worktreePath), "run", [scriptName]);
|
|
37
|
-
if (!resolved) return null;
|
|
38
|
-
return {
|
|
39
|
-
command: resolved.command,
|
|
40
|
-
args: resolved.args
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
async function detectPackageManagerAgent(worktreePath) {
|
|
44
|
-
return (await detect({
|
|
45
|
-
cwd: worktreePath,
|
|
46
|
-
stopDir: worktreePath
|
|
47
|
-
}))?.agent ?? "npm";
|
|
48
|
-
}
|
|
49
|
-
async function resolvePackageBinaryCommand(worktreePath, binary, args) {
|
|
50
|
-
if (!existsSync(join(worktreePath, "package.json"))) return null;
|
|
51
|
-
switch (await detectPackageManagerAgent(worktreePath)) {
|
|
52
|
-
case "bun": return {
|
|
53
|
-
command: "bun",
|
|
54
|
-
args: [
|
|
55
|
-
"x",
|
|
56
|
-
binary,
|
|
57
|
-
...args
|
|
58
|
-
]
|
|
59
|
-
};
|
|
60
|
-
case "pnpm": return {
|
|
61
|
-
command: "pnpm",
|
|
62
|
-
args: [
|
|
63
|
-
"exec",
|
|
64
|
-
binary,
|
|
65
|
-
...args
|
|
66
|
-
]
|
|
67
|
-
};
|
|
68
|
-
case "yarn": return {
|
|
69
|
-
command: "yarn",
|
|
70
|
-
args: [
|
|
71
|
-
"exec",
|
|
72
|
-
binary,
|
|
73
|
-
...args
|
|
74
|
-
]
|
|
75
|
-
};
|
|
76
|
-
default: return {
|
|
77
|
-
command: "npx",
|
|
78
|
-
args: [
|
|
79
|
-
"--yes",
|
|
80
|
-
binary,
|
|
81
|
-
...args
|
|
82
|
-
]
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
async function runTests(worktreePath, signal) {
|
|
87
|
-
const projectCommand = envCommand("PIPELINE_TEST_COMMAND") ?? await resolvePackageScript(worktreePath, "test");
|
|
88
|
-
if (!projectCommand) return {
|
|
89
|
-
exitCode: 1,
|
|
90
|
-
failingTests: [],
|
|
91
|
-
output: "No test command found. Set PIPELINE_TEST_COMMAND or define a package test script."
|
|
92
|
-
};
|
|
93
|
-
const result = await runProjectCommand(projectCommand, worktreePath, signal);
|
|
94
|
-
return {
|
|
95
|
-
...result,
|
|
96
|
-
failingTests: result.exitCode === 0 ? [] : parseFailingTests(result.output)
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
async function runTypecheck(worktreePath, signal) {
|
|
100
|
-
const projectCommand = envCommand("PIPELINE_TYPECHECK_COMMAND") ?? await resolvePackageScript(worktreePath, "typecheck");
|
|
101
|
-
if (!projectCommand) return {
|
|
102
|
-
exitCode: 0,
|
|
103
|
-
output: "skipped"
|
|
104
|
-
};
|
|
105
|
-
return await runProjectCommand(projectCommand, worktreePath, signal);
|
|
106
|
-
}
|
|
107
|
-
async function runLint(worktreePath, signal) {
|
|
108
|
-
const projectCommand = envCommand("PIPELINE_LINT_COMMAND") ?? await resolvePackageScript(worktreePath, "lint");
|
|
109
|
-
if (!projectCommand) return {
|
|
110
|
-
exitCode: 0,
|
|
111
|
-
output: "skipped"
|
|
112
|
-
};
|
|
113
|
-
return await runProjectCommand(projectCommand, worktreePath, signal, { hidePipelineRuns: true });
|
|
114
|
-
}
|
|
115
|
-
async function runFallow(worktreePath, signal) {
|
|
116
|
-
return runProjectCommand(envCommand("PIPELINE_FALLOW_COMMAND") ?? await resolvePackageScript(worktreePath, "fallow") ?? await resolvePackageBinaryCommand(worktreePath, "fallow", ["audit"]) ?? {
|
|
117
|
-
args: ["audit"],
|
|
118
|
-
command: "fallow"
|
|
119
|
-
}, worktreePath, signal, { hidePipelineRuns: true });
|
|
120
|
-
}
|
|
121
|
-
async function runProjectCommand(projectCommand, worktreePath, signal, options) {
|
|
122
|
-
const hiddenRuns = options?.hidePipelineRuns ? hidePipelineRunsDirectory(worktreePath) : null;
|
|
123
|
-
try {
|
|
124
|
-
const result = await execa(projectCommand.command, projectCommand.args, {
|
|
125
|
-
cancelSignal: signal,
|
|
126
|
-
cwd: worktreePath,
|
|
127
|
-
shell: projectCommand.shell
|
|
128
|
-
});
|
|
129
|
-
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
|
130
|
-
return {
|
|
131
|
-
command: displayCommand(projectCommand),
|
|
132
|
-
exitCode: result.exitCode ?? 0,
|
|
133
|
-
output
|
|
134
|
-
};
|
|
135
|
-
} catch (err) {
|
|
136
|
-
const e = commandError(err);
|
|
137
|
-
return {
|
|
138
|
-
command: displayCommand(projectCommand),
|
|
139
|
-
exitCode: e.exitCode ?? 1,
|
|
140
|
-
output: commandErrorOutput(e)
|
|
141
|
-
};
|
|
142
|
-
} finally {
|
|
143
|
-
hiddenRuns?.restore();
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
function hidePipelineRunsDirectory(worktreePath) {
|
|
147
|
-
const pipelineDir = join(worktreePath, ".pipeline");
|
|
148
|
-
const runsDir = join(pipelineDir, "runs");
|
|
149
|
-
if (!existsSync(runsDir)) return null;
|
|
150
|
-
const hiddenRunsDir = join(pipelineDir, `.runs-hidden-${process.pid}-${Date.now()}`);
|
|
151
|
-
renameSync(runsDir, hiddenRunsDir);
|
|
152
|
-
return { restore: () => {
|
|
153
|
-
if (!existsSync(hiddenRunsDir)) return;
|
|
154
|
-
renameSync(hiddenRunsDir, runsDir);
|
|
155
|
-
} };
|
|
156
|
-
}
|
|
157
|
-
function commandError(err) {
|
|
158
|
-
return err;
|
|
159
|
-
}
|
|
160
|
-
function commandErrorOutput(err) {
|
|
161
|
-
return [err.stdout, err.stderr].filter(Boolean).join("\n") || [err.shortMessage, err.message].filter(Boolean).join("\n");
|
|
162
|
-
}
|
|
163
|
-
async function runSemgrep(worktreePath, signal, changedFiles) {
|
|
164
|
-
const overrideCommand = envCommand("PIPELINE_SEMGREP_COMMAND");
|
|
165
|
-
const targets = changedFiles ? [...new Set(changedFiles)].filter((file) => existsSync(join(worktreePath, file))) : void 0;
|
|
166
|
-
if (!overrideCommand && targets && targets.length === 0) return {
|
|
167
|
-
command: "uvx semgrep scan --config=p/ci --error",
|
|
168
|
-
exitCode: 0,
|
|
169
|
-
output: "skipped: no changed files to scan"
|
|
170
|
-
};
|
|
171
|
-
return await runProjectCommand(overrideCommand ?? {
|
|
172
|
-
args: [
|
|
173
|
-
"semgrep",
|
|
174
|
-
"scan",
|
|
175
|
-
"--config=p/ci",
|
|
176
|
-
"--error",
|
|
177
|
-
...targets ? ["--", ...targets] : ["."]
|
|
178
|
-
],
|
|
179
|
-
command: "uvx"
|
|
180
|
-
}, worktreePath, signal);
|
|
181
|
-
}
|
|
182
8
|
function artifactExists(worktreePath, filename) {
|
|
183
9
|
return existsSync(join(worktreePath, filename));
|
|
184
10
|
}
|
|
185
|
-
const JSCPD_DEFAULT_IGNORES = [
|
|
186
|
-
"**/node_modules/**",
|
|
187
|
-
"**/.git/**",
|
|
188
|
-
"**/dist/**",
|
|
189
|
-
"**/coverage/**",
|
|
190
|
-
"**/.next/**",
|
|
191
|
-
"**/.turbo/**",
|
|
192
|
-
"**/.cache/**",
|
|
193
|
-
"**/.serena/**",
|
|
194
|
-
"**/.opencode/**",
|
|
195
|
-
"**/.pipeline/host-resources/**",
|
|
196
|
-
"**/.pipeline/skills/**",
|
|
197
|
-
"**/.agents/skills/**"
|
|
198
|
-
];
|
|
199
|
-
function parseJscpdOutput(output) {
|
|
200
|
-
try {
|
|
201
|
-
return { violations: (parseJson(output, "jscpd output")?.duplicates ?? []).map((dup) => ({
|
|
202
|
-
file: dup?.firstFile?.name ?? "unknown",
|
|
203
|
-
line: dup?.firstFile?.start,
|
|
204
|
-
message: `Duplicate code block detected between ${dup?.firstFile?.name} and ${dup?.secondFile?.name}`
|
|
205
|
-
})) };
|
|
206
|
-
} catch {
|
|
207
|
-
return { violations: [] };
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
async function runJscpd(worktreePath, signal) {
|
|
211
|
-
try {
|
|
212
|
-
return parseJscpdOutput((await execa("bunx", [
|
|
213
|
-
"jscpd",
|
|
214
|
-
"--min-tokens",
|
|
215
|
-
"50",
|
|
216
|
-
"--reporters",
|
|
217
|
-
"json",
|
|
218
|
-
"--gitignore",
|
|
219
|
-
"--ignore",
|
|
220
|
-
JSCPD_DEFAULT_IGNORES.join(","),
|
|
221
|
-
"."
|
|
222
|
-
], {
|
|
223
|
-
cancelSignal: signal,
|
|
224
|
-
cwd: worktreePath
|
|
225
|
-
})).stdout ?? "");
|
|
226
|
-
} catch (err) {
|
|
227
|
-
return parseJscpdOutput(err.stdout ?? "");
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
11
|
//#endregion
|
|
231
|
-
export { artifactExists
|
|
12
|
+
export { artifactExists };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Data } from "effect";
|
|
2
|
+
//#region src/mcp/gateway-error.ts
|
|
3
|
+
/**
|
|
4
|
+
* Tagged error for the MCP (ToolHive) gateway subsystem. Lives in its own module
|
|
5
|
+
* so both the gateway facade (src/mcp/gateway.ts) and the Effect service
|
|
6
|
+
* (src/runtime/services/mcp-gateway-service.ts) can import it without forming a
|
|
7
|
+
* circular dependency between them.
|
|
8
|
+
*/
|
|
9
|
+
var PipelineMcpGatewayError = class extends Data.TaggedError("PipelineMcpGatewayError") {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super({ message });
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { PipelineMcpGatewayError };
|
package/dist/mcp/gateway.js
CHANGED
|
@@ -1,23 +1,22 @@
|
|
|
1
|
+
import { PipelineMcpGatewayError } from "./gateway-error.js";
|
|
2
|
+
import { McpGatewayService, McpGatewayServiceLive } from "../runtime/services/mcp-gateway-service.js";
|
|
1
3
|
import { resolveRepoLocalBackendSpecs } from "./repo-local-backends.js";
|
|
2
4
|
import { renderToolHiveVmcpInventory } from "./toolhive-vmcp.js";
|
|
3
|
-
import {
|
|
5
|
+
import { Effect } from "effect";
|
|
4
6
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
7
|
import { dirname, join } from "node:path";
|
|
6
8
|
import { homedir } from "node:os";
|
|
7
|
-
import { execa } from "execa";
|
|
8
9
|
//#region src/mcp/gateway.ts
|
|
9
10
|
const PIPELINE_GATEWAY_SERVER_ID = "pipeline-gateway";
|
|
10
11
|
const DEFAULT_LOCAL_GATEWAY_URL = "http://127.0.0.1:4483/mcp";
|
|
11
12
|
const LEGACY_OPENCODE_MCP_RE = /"mcp"\s*:\s*{(?!\s*"pipeline-gateway")/s;
|
|
12
13
|
const LEGACY_PIPELINE_MCP_RE = /path:\s*\.mcp\.json|uvx\s+mcpm|mcpm\s+run/;
|
|
13
|
-
var PipelineMcpGatewayError = class extends Data.TaggedError("PipelineMcpGatewayError") {
|
|
14
|
-
constructor(message) {
|
|
15
|
-
super({ message });
|
|
16
|
-
}
|
|
17
|
-
};
|
|
18
14
|
function profileNeedsMcpGateway(actor) {
|
|
19
15
|
return (actor?.mcp_servers ?? []).length > 0;
|
|
20
16
|
}
|
|
17
|
+
function runMcpGatewayEffect(program) {
|
|
18
|
+
return Effect.runPromise(Effect.provide(program, McpGatewayServiceLive));
|
|
19
|
+
}
|
|
21
20
|
function gatewayServerForProfile(config, actor, env = process.env) {
|
|
22
21
|
if (!(config && profileNeedsMcpGateway(actor))) return {};
|
|
23
22
|
return { [PIPELINE_GATEWAY_SERVER_ID]: gatewayServer(config, env) };
|
|
@@ -88,128 +87,79 @@ function configureGatewayHosts(config, options) {
|
|
|
88
87
|
};
|
|
89
88
|
});
|
|
90
89
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
90
|
+
function runGatewayDoctor(config, cwd) {
|
|
91
|
+
return runMcpGatewayEffect(Effect.gen(function* () {
|
|
92
|
+
const gateway = configuredGateway(config);
|
|
93
|
+
const checks = [
|
|
94
|
+
{
|
|
95
|
+
detail: `${gateway.provider}/${gateway.mode}`,
|
|
96
|
+
name: "gateway-config",
|
|
97
|
+
passed: true
|
|
98
|
+
},
|
|
99
|
+
checkGatewayUrl(gateway),
|
|
100
|
+
checkGatewayToken(gateway),
|
|
101
|
+
...gateway.mode === "local" ? [yield* checkThv(cwd)] : [],
|
|
102
|
+
yield* checkGatewayHealth(gateway),
|
|
103
|
+
yield* checkGatewayRequiredTools(gateway),
|
|
104
|
+
checkLegacyDirectMcp(cwd)
|
|
105
|
+
];
|
|
106
|
+
return {
|
|
107
|
+
checks,
|
|
108
|
+
passed: checks.every((check) => check.passed)
|
|
109
|
+
};
|
|
110
|
+
}));
|
|
110
111
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
"--config",
|
|
119
|
-
result.configPath,
|
|
120
|
-
"--host",
|
|
121
|
-
"127.0.0.1",
|
|
122
|
-
"--port",
|
|
123
|
-
"4483"
|
|
124
|
-
], {
|
|
125
|
-
cwd,
|
|
126
|
-
env: await toolhiveEnv(cwd),
|
|
127
|
-
stderr: "inherit",
|
|
128
|
-
stdout: "inherit"
|
|
129
|
-
});
|
|
112
|
+
function startLocalGateway(config, cwd) {
|
|
113
|
+
return runMcpGatewayEffect(Effect.gen(function* () {
|
|
114
|
+
if (configuredGateway(config).mode !== "local") return yield* Effect.fail(new PipelineMcpGatewayError("mcp gateway local-start is only valid when mcp_gateway.mode is local."));
|
|
115
|
+
const result = yield* reconcileGatewayEffect(config, cwd, process.env);
|
|
116
|
+
if (result.readinessFailures.length > 0) return yield* Effect.fail(new PipelineMcpGatewayError(`Cannot start local MCP gateway; readiness failures: ${result.readinessFailures.join("; ")}`));
|
|
117
|
+
yield* (yield* McpGatewayService).serveToolHiveVmcp(result.configPath, cwd);
|
|
118
|
+
}));
|
|
130
119
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (gateway.provider !== "toolhive") throw new PipelineMcpGatewayError(`Unsupported MCP gateway provider '${gateway.provider}'.`);
|
|
134
|
-
const workspacePath = env.PIPELINE_TARGET_PATH || cwd;
|
|
135
|
-
const repoLocalBackends = resolveRepoLocalBackendSpecs(config, {
|
|
136
|
-
cwd: workspacePath,
|
|
137
|
-
env
|
|
138
|
-
});
|
|
139
|
-
const inventory = renderToolHiveVmcpInventory(config, {
|
|
140
|
-
repoLocalBackends,
|
|
141
|
-
toolHiveWorkloads: await listToolHiveGroupWorkloads(gateway.default_profile ?? "default", workspacePath)
|
|
142
|
-
});
|
|
143
|
-
const configPath = join(workspacePath, ".pipeline", "mcp-gateway", "vmcp.yaml");
|
|
144
|
-
mkdirSync(dirname(configPath), { recursive: true });
|
|
145
|
-
writeFileSync(configPath, inventory.yaml);
|
|
146
|
-
await execa("thv", [
|
|
147
|
-
"vmcp",
|
|
148
|
-
"validate",
|
|
149
|
-
"--config",
|
|
150
|
-
configPath
|
|
151
|
-
], {
|
|
152
|
-
cwd: workspacePath,
|
|
153
|
-
env: await toolhiveEnv(workspacePath),
|
|
154
|
-
stdin: "ignore"
|
|
155
|
-
});
|
|
156
|
-
return {
|
|
157
|
-
backendCount: inventory.backends.length,
|
|
158
|
-
configPath,
|
|
159
|
-
readinessFailures: [...repoLocalBackends.filter((backend) => backend.enabled && !backend.readiness.ok).map((backend) => `${backend.id}: ${backend.readiness.reason}`), ...inventory.backends.filter((backend) => backend.enabled && backend.required && !backend.url).map((backend) => `${backend.name}: missing ToolHive workload`)],
|
|
160
|
-
workspacePath
|
|
161
|
-
};
|
|
120
|
+
function reconcileGateway(config, cwd, env = process.env) {
|
|
121
|
+
return runMcpGatewayEffect(reconcileGatewayEffect(config, cwd, env));
|
|
162
122
|
}
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
name: item.name,
|
|
188
|
-
status: typeof item.status === "string" ? item.status : void 0,
|
|
189
|
-
transport: toolHiveWorkloadTransport(item),
|
|
190
|
-
url: typeof item.url === "string" ? item.url : void 0
|
|
191
|
-
}];
|
|
123
|
+
function reconcileGatewayEffect(config, cwd, env) {
|
|
124
|
+
const gateway = configuredGateway(config);
|
|
125
|
+
if (gateway.provider !== "toolhive") return Effect.fail(new PipelineMcpGatewayError(`Unsupported MCP gateway provider '${gateway.provider}'.`));
|
|
126
|
+
return Effect.gen(function* () {
|
|
127
|
+
const service = yield* McpGatewayService;
|
|
128
|
+
const workspacePath = env.PIPELINE_TARGET_PATH || cwd;
|
|
129
|
+
const repoLocalBackends = resolveRepoLocalBackendSpecs(config, {
|
|
130
|
+
cwd: workspacePath,
|
|
131
|
+
env
|
|
132
|
+
});
|
|
133
|
+
const inventory = renderToolHiveVmcpInventory(config, {
|
|
134
|
+
repoLocalBackends,
|
|
135
|
+
toolHiveWorkloads: yield* service.listToolHiveGroupWorkloads(gateway.default_profile ?? "default", workspacePath)
|
|
136
|
+
});
|
|
137
|
+
const configPath = join(workspacePath, ".pipeline", "mcp-gateway", "vmcp.yaml");
|
|
138
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
139
|
+
writeFileSync(configPath, inventory.yaml);
|
|
140
|
+
yield* service.validateToolHiveVmcp(configPath, workspacePath);
|
|
141
|
+
return {
|
|
142
|
+
backendCount: inventory.backends.length,
|
|
143
|
+
configPath,
|
|
144
|
+
readinessFailures: [...repoLocalBackends.filter((backend) => backend.enabled && !backend.readiness.ok).map((backend) => `${backend.id}: ${backend.readiness.reason}`), ...inventory.backends.filter((backend) => backend.enabled && backend.required && !backend.url).map((backend) => `${backend.name}: missing ToolHive workload`)],
|
|
145
|
+
workspacePath
|
|
146
|
+
};
|
|
192
147
|
});
|
|
193
148
|
}
|
|
194
|
-
function
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
async function localGatewayStatus(cwd) {
|
|
199
|
-
return (await execa("thv", ["list"], {
|
|
200
|
-
cwd,
|
|
201
|
-
env: await toolhiveEnv(cwd)
|
|
202
|
-
})).stdout.trim();
|
|
149
|
+
function localGatewayStatus(cwd) {
|
|
150
|
+
return runMcpGatewayEffect(Effect.gen(function* () {
|
|
151
|
+
return yield* (yield* McpGatewayService).localGatewayStatus(cwd);
|
|
152
|
+
}));
|
|
203
153
|
}
|
|
204
|
-
|
|
154
|
+
function checkGatewayRequiredTools(gateway) {
|
|
205
155
|
const requiredPrefixes = requiredGatewayToolPrefixes(gateway);
|
|
206
|
-
if (requiredPrefixes.length === 0) return {
|
|
156
|
+
if (requiredPrefixes.length === 0) return Effect.succeed({
|
|
207
157
|
detail: "no required tools declared",
|
|
208
158
|
name: "gateway-required-tools",
|
|
209
159
|
passed: true
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
const tools =
|
|
160
|
+
});
|
|
161
|
+
return Effect.gen(function* () {
|
|
162
|
+
const tools = yield* listGatewayTools(gateway);
|
|
213
163
|
const missing = requiredPrefixes.filter((prefix) => !tools.some((tool) => tool === prefix || tool.startsWith(`${prefix}_`)));
|
|
214
164
|
return missing.length === 0 ? {
|
|
215
165
|
detail: `found: ${requiredPrefixes.join(", ")}`,
|
|
@@ -220,54 +170,45 @@ async function checkGatewayRequiredTools(gateway) {
|
|
|
220
170
|
name: "gateway-required-tools",
|
|
221
171
|
passed: false
|
|
222
172
|
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
};
|
|
229
|
-
}
|
|
173
|
+
}).pipe(Effect.catchAll((error) => Effect.succeed({
|
|
174
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
175
|
+
name: "gateway-required-tools",
|
|
176
|
+
passed: false
|
|
177
|
+
})));
|
|
230
178
|
}
|
|
231
179
|
function requiredGatewayToolPrefixes(gateway) {
|
|
232
180
|
return [...new Set(Object.values(gateway.backends).filter((backend) => backend.required).flatMap((backend) => backend.tool_prefixes))].sort();
|
|
233
181
|
}
|
|
234
|
-
|
|
182
|
+
function listGatewayTools(gateway) {
|
|
235
183
|
const url = gatewayUrl(gateway);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
184
|
+
return Effect.gen(function* () {
|
|
185
|
+
yield* callGatewayRpc(gateway, url, {
|
|
186
|
+
id: 1,
|
|
187
|
+
jsonrpc: "2.0",
|
|
188
|
+
method: "initialize",
|
|
189
|
+
params: {
|
|
190
|
+
capabilities: {},
|
|
191
|
+
clientInfo: {
|
|
192
|
+
name: "@oisincoveney/pipeline",
|
|
193
|
+
version: "1"
|
|
194
|
+
},
|
|
195
|
+
protocolVersion: "2025-06-18"
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
const tools = (yield* callGatewayRpc(gateway, url, {
|
|
199
|
+
id: 2,
|
|
200
|
+
jsonrpc: "2.0",
|
|
201
|
+
method: "tools/list",
|
|
202
|
+
params: {}
|
|
203
|
+
})).result?.tools;
|
|
204
|
+
if (!Array.isArray(tools)) return yield* Effect.fail(new PipelineMcpGatewayError("Malformed tools/list response."));
|
|
205
|
+
return tools.flatMap((tool) => tool && typeof tool.name === "string" ? [tool.name] : []);
|
|
248
206
|
});
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
params: {}
|
|
254
|
-
})).result?.tools;
|
|
255
|
-
if (!Array.isArray(tools)) throw new PipelineMcpGatewayError("Malformed tools/list response.");
|
|
256
|
-
return tools.flatMap((tool) => tool && typeof tool.name === "string" ? [tool.name] : []);
|
|
257
|
-
}
|
|
258
|
-
async function callGatewayRpc(gateway, url, body) {
|
|
259
|
-
const authorization = process.env[gateway.authorization_env];
|
|
260
|
-
const response = await fetch(url, {
|
|
261
|
-
body: JSON.stringify(body),
|
|
262
|
-
headers: {
|
|
263
|
-
Accept: "application/json",
|
|
264
|
-
"Content-Type": "application/json",
|
|
265
|
-
...authorization ? { Authorization: authorization } : {}
|
|
266
|
-
},
|
|
267
|
-
method: "POST"
|
|
207
|
+
}
|
|
208
|
+
function callGatewayRpc(gateway, url, body) {
|
|
209
|
+
return Effect.gen(function* () {
|
|
210
|
+
return yield* (yield* McpGatewayService).callGatewayRpc(url, body, process.env[gateway.authorization_env]);
|
|
268
211
|
});
|
|
269
|
-
if (!response.ok) throw new PipelineMcpGatewayError(`Gateway MCP request failed: HTTP ${response.status}.`);
|
|
270
|
-
return await response.json();
|
|
271
212
|
}
|
|
272
213
|
function selectedGatewayHosts(host) {
|
|
273
214
|
return host === "all" ? ["opencode"] : [host];
|
|
@@ -314,86 +255,44 @@ function gatewayAuthorizationHeader(gateway) {
|
|
|
314
255
|
function gatewayOpenCodeHeaders(gateway) {
|
|
315
256
|
return { Authorization: gatewayAuthorizationHeader(gateway) };
|
|
316
257
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
cwd,
|
|
321
|
-
env: await toolhiveEnv(cwd),
|
|
322
|
-
stdin: "ignore"
|
|
323
|
-
});
|
|
258
|
+
function checkThv(cwd) {
|
|
259
|
+
return Effect.gen(function* () {
|
|
260
|
+
yield* (yield* McpGatewayService).runToolHiveVersion(cwd);
|
|
324
261
|
return {
|
|
325
262
|
detail: "available",
|
|
326
263
|
name: "toolhive",
|
|
327
264
|
passed: true
|
|
328
265
|
};
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
passed: false
|
|
335
|
-
};
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
async function toolhiveEnv(cwd) {
|
|
339
|
-
if (process.env.DOCKER_HOST) return process.env;
|
|
340
|
-
const dockerHost = await activeDockerHost(cwd);
|
|
341
|
-
return dockerHost ? {
|
|
342
|
-
...process.env,
|
|
343
|
-
DOCKER_HOST: dockerHost
|
|
344
|
-
} : process.env;
|
|
345
|
-
}
|
|
346
|
-
async function activeDockerHost(cwd) {
|
|
347
|
-
try {
|
|
348
|
-
const result = await execa("docker", ["context", "inspect"], {
|
|
349
|
-
cwd,
|
|
350
|
-
stdin: "ignore"
|
|
351
|
-
});
|
|
352
|
-
const host = JSON.parse(result.stdout)[0]?.Endpoints?.docker?.Host;
|
|
353
|
-
return typeof host === "string" && host.length > 0 ? host : void 0;
|
|
354
|
-
} catch {
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
266
|
+
}).pipe(Effect.catchAll((error) => Effect.succeed({
|
|
267
|
+
detail: error.message || "not available",
|
|
268
|
+
name: "toolhive",
|
|
269
|
+
passed: false
|
|
270
|
+
})));
|
|
357
271
|
}
|
|
358
|
-
|
|
272
|
+
function checkGatewayHealth(gateway) {
|
|
359
273
|
let url;
|
|
360
274
|
try {
|
|
361
275
|
url = gatewayUrl(gateway);
|
|
362
276
|
} catch (err) {
|
|
363
|
-
return {
|
|
277
|
+
return Effect.succeed({
|
|
364
278
|
detail: err instanceof Error ? err.message : String(err),
|
|
365
279
|
name: "gateway-health",
|
|
366
280
|
passed: false
|
|
367
|
-
};
|
|
281
|
+
});
|
|
368
282
|
}
|
|
369
|
-
|
|
370
|
-
const response =
|
|
283
|
+
return Effect.gen(function* () {
|
|
284
|
+
const response = yield* (yield* McpGatewayService).firstHealthyGatewayResponse(gatewayHealthUrls(url), process.env[gateway.authorization_env]);
|
|
371
285
|
const passed = Boolean(response);
|
|
372
286
|
return {
|
|
373
287
|
detail: response ? `HTTP ${response.status} ${response.url}` : "gateway endpoint did not report healthy",
|
|
374
288
|
name: "gateway-health",
|
|
375
289
|
passed
|
|
376
290
|
};
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
};
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
async function firstHealthyGatewayResponse(url, gateway) {
|
|
386
|
-
const authorization = process.env[gateway.authorization_env];
|
|
387
|
-
for (const healthUrl of gatewayHealthUrls(url)) {
|
|
388
|
-
const response = await fetch(healthUrl, {
|
|
389
|
-
headers: {
|
|
390
|
-
Accept: "application/json, text/event-stream",
|
|
391
|
-
...authorization ? { Authorization: authorization } : {}
|
|
392
|
-
},
|
|
393
|
-
method: "GET"
|
|
394
|
-
});
|
|
395
|
-
if (response.status >= 200 && response.status < 300 || response.status === 405) return response;
|
|
396
|
-
}
|
|
291
|
+
}).pipe(Effect.catchAll((error) => Effect.succeed({
|
|
292
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
293
|
+
name: "gateway-health",
|
|
294
|
+
passed: false
|
|
295
|
+
})));
|
|
397
296
|
}
|
|
398
297
|
function gatewayHealthUrls(url) {
|
|
399
298
|
const urls = [];
|