@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.
Files changed (38) hide show
  1. package/dist/argo-submit.d.ts +2 -4
  2. package/dist/argo-submit.js +80 -80
  3. package/dist/cluster-doctor.js +89 -101
  4. package/dist/config/defaults.js +9 -19
  5. package/dist/config/load.js +32 -39
  6. package/dist/config/schemas.d.ts +1 -1
  7. package/dist/gates.js +6 -225
  8. package/dist/mcp/gateway-error.js +15 -0
  9. package/dist/mcp/gateway.js +119 -220
  10. package/dist/moka-global-config.js +20 -20
  11. package/dist/moka-submit.d.ts +1 -1
  12. package/dist/pipeline-runtime.js +580 -371
  13. package/dist/run-state/git-refs.js +124 -94
  14. package/dist/runner-command-contract.d.ts +2 -2
  15. package/dist/runner-event-sink.js +37 -69
  16. package/dist/runtime/agent-node/agent-node.js +214 -173
  17. package/dist/runtime/builtins/builtins.js +344 -57
  18. package/dist/runtime/changed-files/changed-files.js +15 -27
  19. package/dist/runtime/changed-files/index.js +2 -0
  20. package/dist/runtime/drain-merge/drain-merge.js +124 -82
  21. package/dist/runtime/gates/gates.js +46 -28
  22. package/dist/runtime/hooks/hooks.js +74 -29
  23. package/dist/runtime/opencode-server.js +27 -21
  24. package/dist/runtime/opencode-session-executor.js +101 -44
  25. package/dist/runtime/parallel-node/parallel-node.js +24 -5
  26. package/dist/runtime/select-candidate/select-candidate.js +45 -29
  27. package/dist/runtime/services/agent-node-runtime-service.js +15 -0
  28. package/dist/runtime/services/command-executor-service.js +8 -0
  29. package/dist/runtime/services/config-io-service.js +42 -0
  30. package/dist/runtime/services/drain-merge-git-service.js +10 -0
  31. package/dist/runtime/services/git-porcelain-service.js +38 -0
  32. package/dist/runtime/services/kubernetes-argo-service.d.ts +13 -0
  33. package/dist/runtime/services/kubernetes-argo-service.js +81 -0
  34. package/dist/runtime/services/mcp-gateway-service.js +184 -0
  35. package/dist/runtime/services/opencode-sdk-service.js +27 -0
  36. package/dist/runtime/services/runner-event-sink-http-service.js +80 -0
  37. package/dist/runtime/services/select-candidate-service.js +13 -0
  38. package/package.json +1 -1
package/dist/gates.js CHANGED
@@ -1,231 +1,12 @@
1
- import { parseJson } from "./safe-json.js";
2
- import { existsSync, readFileSync, renameSync } from "node:fs";
1
+ import "./safe-json.js";
2
+ import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { execa } from "execa";
5
- import { resolveCommand } from "package-manager-detector/commands";
6
- import { detect } from "package-manager-detector/detect";
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, runFallow, runJscpd, runLint, runSemgrep, runTests, runTypecheck };
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 };
@@ -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 { Data } from "effect";
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
- async function runGatewayDoctor(config, cwd) {
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" ? [await checkThv(cwd)] : [],
102
- await checkGatewayHealth(gateway),
103
- await checkGatewayRequiredTools(gateway),
104
- checkLegacyDirectMcp(cwd)
105
- ];
106
- return {
107
- checks,
108
- passed: checks.every((check) => check.passed)
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
- async function startLocalGateway(config, cwd) {
112
- if (configuredGateway(config).mode !== "local") throw new PipelineMcpGatewayError("mcp gateway local-start is only valid when mcp_gateway.mode is local.");
113
- const result = await reconcileGateway(config, cwd);
114
- if (result.readinessFailures.length > 0) throw new PipelineMcpGatewayError(`Cannot start local MCP gateway; readiness failures: ${result.readinessFailures.join("; ")}`);
115
- await execa("thv", [
116
- "vmcp",
117
- "serve",
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
- async function reconcileGateway(config, cwd, env = process.env) {
132
- const gateway = configuredGateway(config);
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
- async function listToolHiveGroupWorkloads(group, cwd) {
164
- const result = await execa("thv", [
165
- "list",
166
- "--group",
167
- group,
168
- "--format",
169
- "json"
170
- ], {
171
- cwd,
172
- env: await toolhiveEnv(cwd),
173
- stdin: "ignore"
174
- });
175
- let parsed;
176
- const stdout = result.stdout.trim();
177
- if (!stdout) return [];
178
- try {
179
- parsed = JSON.parse(stdout);
180
- } catch {
181
- throw new PipelineMcpGatewayError("ToolHive list returned malformed JSON while reconciling MCP gateway workloads.");
182
- }
183
- if (!Array.isArray(parsed)) throw new PipelineMcpGatewayError("ToolHive list returned a non-array payload while reconciling MCP gateway workloads.");
184
- return parsed.flatMap((item) => {
185
- if (!item || typeof item.name !== "string") return [];
186
- return [{
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 toolHiveWorkloadTransport(item) {
195
- if (typeof item.transport_type === "string") return item.transport_type;
196
- if (typeof item.transport === "string") return item.transport;
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
- async function checkGatewayRequiredTools(gateway) {
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
- try {
212
- const tools = await listGatewayTools(gateway);
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
- } catch (err) {
224
- return {
225
- detail: err instanceof Error ? err.message : String(err),
226
- name: "gateway-required-tools",
227
- passed: false
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
- async function listGatewayTools(gateway) {
182
+ function listGatewayTools(gateway) {
235
183
  const url = gatewayUrl(gateway);
236
- await callGatewayRpc(gateway, url, {
237
- id: 1,
238
- jsonrpc: "2.0",
239
- method: "initialize",
240
- params: {
241
- capabilities: {},
242
- clientInfo: {
243
- name: "@oisincoveney/pipeline",
244
- version: "1"
245
- },
246
- protocolVersion: "2025-06-18"
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
- const tools = (await callGatewayRpc(gateway, url, {
250
- id: 2,
251
- jsonrpc: "2.0",
252
- method: "tools/list",
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
- async function checkThv(cwd) {
318
- try {
319
- await execa("thv", ["version"], {
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
- } catch (err) {
330
- const error = err;
331
- return {
332
- detail: (error.shortMessage || error.stderr || "not available").trim(),
333
- name: "toolhive",
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
- async function checkGatewayHealth(gateway) {
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
- try {
370
- const response = await firstHealthyGatewayResponse(url, gateway);
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
- } catch (err) {
378
- return {
379
- detail: err instanceof Error ? err.message : String(err),
380
- name: "gateway-health",
381
- passed: false
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 = [];