@checkstack/healthcheck-script-backend 0.0.3 → 0.1.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # @checkstack/healthcheck-script-backend
2
2
 
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f5b1f49: Refactored health check strategies to use `createClient()` pattern with built-in collectors.
8
+
9
+ **Strategy Changes:**
10
+
11
+ - Replaced `execute()` with `createClient()` that returns a transport client
12
+ - Strategy configs now only contain connection parameters
13
+ - Collector configs handle what to do with the connection
14
+
15
+ **Built-in Collectors Added:**
16
+
17
+ - DNS: `LookupCollector` for hostname resolution
18
+ - gRPC: `HealthCollector` for gRPC health protocol
19
+ - HTTP: `RequestCollector` for HTTP requests
20
+ - MySQL: `QueryCollector` for database queries
21
+ - Ping: `PingCollector` for ICMP ping
22
+ - Postgres: `QueryCollector` for database queries
23
+ - Redis: `CommandCollector` for Redis commands
24
+ - Script: `ExecuteCollector` for script execution
25
+ - SSH: `CommandCollector` for SSH commands
26
+ - TCP: `BannerCollector` for TCP banner grabbing
27
+ - TLS: `CertificateCollector` for certificate inspection
28
+
29
+ ### Patch Changes
30
+
31
+ - Updated dependencies [f5b1f49]
32
+ - Updated dependencies [f5b1f49]
33
+ - Updated dependencies [f5b1f49]
34
+ - Updated dependencies [f5b1f49]
35
+ - @checkstack/backend-api@0.1.0
36
+ - @checkstack/healthcheck-common@0.1.0
37
+ - @checkstack/common@0.0.3
38
+
3
39
  ## 0.0.3
4
40
 
5
41
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-script-backend",
3
- "version": "0.0.3",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -0,0 +1,196 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import { ExecuteCollector, type ExecuteConfig } from "./execute-collector";
3
+ import type { ScriptTransportClient } from "./transport-client";
4
+
5
+ describe("ExecuteCollector", () => {
6
+ const createMockClient = (
7
+ response: {
8
+ exitCode?: number;
9
+ stdout?: string;
10
+ stderr?: string;
11
+ timedOut?: boolean;
12
+ error?: string;
13
+ } = {}
14
+ ): ScriptTransportClient => ({
15
+ exec: mock(() =>
16
+ Promise.resolve({
17
+ exitCode: response.exitCode ?? 0,
18
+ stdout: response.stdout ?? "",
19
+ stderr: response.stderr ?? "",
20
+ timedOut: response.timedOut ?? false,
21
+ error: response.error,
22
+ })
23
+ ),
24
+ });
25
+
26
+ describe("execute", () => {
27
+ it("should execute script successfully", async () => {
28
+ const collector = new ExecuteCollector();
29
+ const client = createMockClient({ exitCode: 0, stdout: "Hello World" });
30
+
31
+ const result = await collector.execute({
32
+ config: { command: "echo", args: ["Hello", "World"], timeout: 5000 },
33
+ client,
34
+ pluginId: "test",
35
+ });
36
+
37
+ expect(result.result.exitCode).toBe(0);
38
+ expect(result.result.stdout).toBe("Hello World");
39
+ expect(result.result.success).toBe(true);
40
+ expect(result.result.timedOut).toBe(false);
41
+ expect(result.result.executionTimeMs).toBeGreaterThanOrEqual(0);
42
+ expect(result.error).toBeUndefined();
43
+ });
44
+
45
+ it("should return error for failed script", async () => {
46
+ const collector = new ExecuteCollector();
47
+ const client = createMockClient({
48
+ exitCode: 1,
49
+ stderr: "Command not found",
50
+ });
51
+
52
+ const result = await collector.execute({
53
+ config: { command: "nonexistent", args: [], timeout: 5000 },
54
+ client,
55
+ pluginId: "test",
56
+ });
57
+
58
+ expect(result.result.exitCode).toBe(1);
59
+ expect(result.result.success).toBe(false);
60
+ expect(result.error).toContain("Exit code: 1");
61
+ });
62
+
63
+ it("should handle timeout", async () => {
64
+ const collector = new ExecuteCollector();
65
+ const client = createMockClient({ timedOut: true, exitCode: -1 });
66
+
67
+ const result = await collector.execute({
68
+ config: { command: "sleep", args: ["999"], timeout: 100 },
69
+ client,
70
+ pluginId: "test",
71
+ });
72
+
73
+ expect(result.result.timedOut).toBe(true);
74
+ expect(result.result.success).toBe(false);
75
+ });
76
+
77
+ it("should pass correct parameters to client", async () => {
78
+ const collector = new ExecuteCollector();
79
+ const client = createMockClient();
80
+
81
+ await collector.execute({
82
+ config: {
83
+ command: "/usr/bin/check",
84
+ args: ["--verbose"],
85
+ cwd: "/tmp",
86
+ env: { MY_VAR: "value" },
87
+ timeout: 3000,
88
+ },
89
+ client,
90
+ pluginId: "test",
91
+ });
92
+
93
+ expect(client.exec).toHaveBeenCalledWith({
94
+ command: "/usr/bin/check",
95
+ args: ["--verbose"],
96
+ cwd: "/tmp",
97
+ env: { MY_VAR: "value" },
98
+ timeout: 3000,
99
+ });
100
+ });
101
+ });
102
+
103
+ describe("aggregateResult", () => {
104
+ it("should calculate average execution time and success rate", () => {
105
+ const collector = new ExecuteCollector();
106
+ const runs = [
107
+ {
108
+ id: "1",
109
+ status: "healthy" as const,
110
+ latencyMs: 100,
111
+ checkId: "c1",
112
+ timestamp: new Date(),
113
+ metadata: {
114
+ exitCode: 0,
115
+ stdout: "",
116
+ stderr: "",
117
+ executionTimeMs: 50,
118
+ success: true,
119
+ timedOut: false,
120
+ },
121
+ },
122
+ {
123
+ id: "2",
124
+ status: "healthy" as const,
125
+ latencyMs: 150,
126
+ checkId: "c1",
127
+ timestamp: new Date(),
128
+ metadata: {
129
+ exitCode: 0,
130
+ stdout: "",
131
+ stderr: "",
132
+ executionTimeMs: 100,
133
+ success: true,
134
+ timedOut: false,
135
+ },
136
+ },
137
+ ];
138
+
139
+ const aggregated = collector.aggregateResult(runs);
140
+
141
+ expect(aggregated.avgExecutionTimeMs).toBe(75);
142
+ expect(aggregated.successRate).toBe(100);
143
+ });
144
+
145
+ it("should calculate success rate correctly", () => {
146
+ const collector = new ExecuteCollector();
147
+ const runs = [
148
+ {
149
+ id: "1",
150
+ status: "healthy" as const,
151
+ latencyMs: 100,
152
+ checkId: "c1",
153
+ timestamp: new Date(),
154
+ metadata: {
155
+ exitCode: 0,
156
+ stdout: "",
157
+ stderr: "",
158
+ executionTimeMs: 50,
159
+ success: true,
160
+ timedOut: false,
161
+ },
162
+ },
163
+ {
164
+ id: "2",
165
+ status: "unhealthy" as const,
166
+ latencyMs: 150,
167
+ checkId: "c1",
168
+ timestamp: new Date(),
169
+ metadata: {
170
+ exitCode: 1,
171
+ stdout: "",
172
+ stderr: "",
173
+ executionTimeMs: 100,
174
+ success: false,
175
+ timedOut: false,
176
+ },
177
+ },
178
+ ];
179
+
180
+ const aggregated = collector.aggregateResult(runs);
181
+
182
+ expect(aggregated.successRate).toBe(50);
183
+ });
184
+ });
185
+
186
+ describe("metadata", () => {
187
+ it("should have correct static properties", () => {
188
+ const collector = new ExecuteCollector();
189
+
190
+ expect(collector.id).toBe("execute");
191
+ expect(collector.displayName).toBe("Execute Script");
192
+ expect(collector.allowMultiple).toBe(true);
193
+ expect(collector.supportedPlugins).toHaveLength(1);
194
+ });
195
+ });
196
+ });
@@ -0,0 +1,178 @@
1
+ import {
2
+ Versioned,
3
+ z,
4
+ type HealthCheckRunForAggregation,
5
+ type CollectorResult,
6
+ type CollectorStrategy,
7
+ } from "@checkstack/backend-api";
8
+ import {
9
+ healthResultNumber,
10
+ healthResultString,
11
+ healthResultBoolean,
12
+ } from "@checkstack/healthcheck-common";
13
+ import { pluginMetadata } from "./plugin-metadata";
14
+ import type { ScriptTransportClient } from "./transport-client";
15
+
16
+ // ============================================================================
17
+ // CONFIGURATION SCHEMA
18
+ // ============================================================================
19
+
20
+ const executeConfigSchema = z.object({
21
+ command: z.string().min(1).describe("Command or script path to execute"),
22
+ args: z.array(z.string()).default([]).describe("Command arguments"),
23
+ cwd: z.string().optional().describe("Working directory"),
24
+ env: z
25
+ .record(z.string(), z.string())
26
+ .optional()
27
+ .describe("Environment variables"),
28
+ timeout: z
29
+ .number()
30
+ .min(100)
31
+ .default(30_000)
32
+ .describe("Timeout in milliseconds"),
33
+ });
34
+
35
+ export type ExecuteConfig = z.infer<typeof executeConfigSchema>;
36
+
37
+ // ============================================================================
38
+ // RESULT SCHEMAS
39
+ // ============================================================================
40
+
41
+ const executeResultSchema = z.object({
42
+ exitCode: healthResultNumber({
43
+ "x-chart-type": "counter",
44
+ "x-chart-label": "Exit Code",
45
+ }),
46
+ stdout: healthResultString({
47
+ "x-chart-type": "text",
48
+ "x-chart-label": "Standard Output",
49
+ }),
50
+ stderr: healthResultString({
51
+ "x-chart-type": "text",
52
+ "x-chart-label": "Standard Error",
53
+ }),
54
+ executionTimeMs: healthResultNumber({
55
+ "x-chart-type": "line",
56
+ "x-chart-label": "Execution Time",
57
+ "x-chart-unit": "ms",
58
+ }),
59
+ success: healthResultBoolean({
60
+ "x-chart-type": "boolean",
61
+ "x-chart-label": "Success",
62
+ }),
63
+ timedOut: healthResultBoolean({
64
+ "x-chart-type": "boolean",
65
+ "x-chart-label": "Timed Out",
66
+ }),
67
+ });
68
+
69
+ export type ExecuteResult = z.infer<typeof executeResultSchema>;
70
+
71
+ const executeAggregatedSchema = z.object({
72
+ avgExecutionTimeMs: healthResultNumber({
73
+ "x-chart-type": "line",
74
+ "x-chart-label": "Avg Execution Time",
75
+ "x-chart-unit": "ms",
76
+ }),
77
+ successRate: healthResultNumber({
78
+ "x-chart-type": "gauge",
79
+ "x-chart-label": "Success Rate",
80
+ "x-chart-unit": "%",
81
+ }),
82
+ });
83
+
84
+ export type ExecuteAggregatedResult = z.infer<typeof executeAggregatedSchema>;
85
+
86
+ // ============================================================================
87
+ // EXECUTE COLLECTOR
88
+ // ============================================================================
89
+
90
+ /**
91
+ * Built-in Script execute collector.
92
+ * Runs commands and checks results.
93
+ */
94
+ export class ExecuteCollector
95
+ implements
96
+ CollectorStrategy<
97
+ ScriptTransportClient,
98
+ ExecuteConfig,
99
+ ExecuteResult,
100
+ ExecuteAggregatedResult
101
+ >
102
+ {
103
+ id = "execute";
104
+ displayName = "Execute Script";
105
+ description = "Execute a command or script and check the result";
106
+
107
+ supportedPlugins = [pluginMetadata];
108
+
109
+ allowMultiple = true;
110
+
111
+ config = new Versioned({ version: 1, schema: executeConfigSchema });
112
+ result = new Versioned({ version: 1, schema: executeResultSchema });
113
+ aggregatedResult = new Versioned({
114
+ version: 1,
115
+ schema: executeAggregatedSchema,
116
+ });
117
+
118
+ async execute({
119
+ config,
120
+ client,
121
+ }: {
122
+ config: ExecuteConfig;
123
+ client: ScriptTransportClient;
124
+ pluginId: string;
125
+ }): Promise<CollectorResult<ExecuteResult>> {
126
+ const startTime = Date.now();
127
+
128
+ const response = await client.exec({
129
+ command: config.command,
130
+ args: config.args,
131
+ cwd: config.cwd,
132
+ env: config.env,
133
+ timeout: config.timeout,
134
+ });
135
+
136
+ const executionTimeMs = Date.now() - startTime;
137
+ const success = response.exitCode === 0 && !response.timedOut;
138
+
139
+ return {
140
+ result: {
141
+ exitCode: response.exitCode,
142
+ stdout: response.stdout,
143
+ stderr: response.stderr,
144
+ executionTimeMs,
145
+ success,
146
+ timedOut: response.timedOut,
147
+ },
148
+ error:
149
+ response.error ??
150
+ (success ? undefined : `Exit code: ${response.exitCode}`),
151
+ };
152
+ }
153
+
154
+ aggregateResult(
155
+ runs: HealthCheckRunForAggregation<ExecuteResult>[]
156
+ ): ExecuteAggregatedResult {
157
+ const times = runs
158
+ .map((r) => r.metadata?.executionTimeMs)
159
+ .filter((v): v is number => typeof v === "number");
160
+
161
+ const successes = runs
162
+ .map((r) => r.metadata?.success)
163
+ .filter((v): v is boolean => typeof v === "boolean");
164
+
165
+ const successCount = successes.filter(Boolean).length;
166
+
167
+ return {
168
+ avgExecutionTimeMs:
169
+ times.length > 0
170
+ ? Math.round(times.reduce((a, b) => a + b, 0) / times.length)
171
+ : 0,
172
+ successRate:
173
+ successes.length > 0
174
+ ? Math.round((successCount / successes.length) * 100)
175
+ : 0,
176
+ };
177
+ }
178
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,7 @@
1
- import {
2
- createBackendPlugin,
3
- coreServices,
4
- } from "@checkstack/backend-api";
1
+ import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
5
2
  import { ScriptHealthCheckStrategy } from "./strategy";
6
3
  import { pluginMetadata } from "./plugin-metadata";
4
+ import { ExecuteCollector } from "./execute-collector";
7
5
 
8
6
  export default createBackendPlugin({
9
7
  metadata: pluginMetadata,
@@ -11,13 +9,17 @@ export default createBackendPlugin({
11
9
  env.registerInit({
12
10
  deps: {
13
11
  healthCheckRegistry: coreServices.healthCheckRegistry,
12
+ collectorRegistry: coreServices.collectorRegistry,
14
13
  logger: coreServices.logger,
15
14
  },
16
- init: async ({ healthCheckRegistry, logger }) => {
15
+ init: async ({ healthCheckRegistry, collectorRegistry, logger }) => {
17
16
  logger.debug("🔌 Registering Script Health Check Strategy...");
18
17
  const strategy = new ScriptHealthCheckStrategy();
19
18
  healthCheckRegistry.register(strategy);
19
+ collectorRegistry.register(new ExecuteCollector());
20
20
  },
21
21
  });
22
22
  },
23
23
  });
24
+
25
+ export { pluginMetadata } from "./plugin-metadata";
@@ -24,134 +24,100 @@ describe("ScriptHealthCheckStrategy", () => {
24
24
  ),
25
25
  });
26
26
 
27
- describe("execute", () => {
28
- it("should return healthy for successful script execution", async () => {
27
+ describe("createClient", () => {
28
+ it("should return a connected client", async () => {
29
+ const strategy = new ScriptHealthCheckStrategy(createMockExecutor());
30
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
31
+
32
+ expect(connectedClient.client).toBeDefined();
33
+ expect(connectedClient.client.exec).toBeDefined();
34
+ expect(connectedClient.close).toBeDefined();
35
+ });
36
+
37
+ it("should allow closing the client", async () => {
38
+ const strategy = new ScriptHealthCheckStrategy(createMockExecutor());
39
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
40
+
41
+ expect(() => connectedClient.close()).not.toThrow();
42
+ });
43
+ });
44
+
45
+ describe("client.exec", () => {
46
+ it("should return successful result for successful script execution", async () => {
29
47
  const strategy = new ScriptHealthCheckStrategy(
30
48
  createMockExecutor({ exitCode: 0, stdout: "OK" })
31
49
  );
50
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
32
51
 
33
- const result = await strategy.execute({
52
+ const result = await connectedClient.client.exec({
34
53
  command: "/usr/bin/true",
54
+ args: [],
35
55
  timeout: 5000,
36
56
  });
37
57
 
38
- expect(result.status).toBe("healthy");
39
- expect(result.metadata?.executed).toBe(true);
40
- expect(result.metadata?.success).toBe(true);
41
- expect(result.metadata?.exitCode).toBe(0);
58
+ expect(result.exitCode).toBe(0);
59
+ expect(result.timedOut).toBe(false);
60
+
61
+ connectedClient.close();
42
62
  });
43
63
 
44
- it("should return unhealthy for non-zero exit code", async () => {
64
+ it("should return non-zero exit code for failed script", async () => {
45
65
  const strategy = new ScriptHealthCheckStrategy(
46
66
  createMockExecutor({ exitCode: 1, stderr: "Error" })
47
67
  );
68
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
48
69
 
49
- const result = await strategy.execute({
70
+ const result = await connectedClient.client.exec({
50
71
  command: "/usr/bin/false",
72
+ args: [],
51
73
  timeout: 5000,
52
74
  });
53
75
 
54
- expect(result.status).toBe("unhealthy");
55
- expect(result.metadata?.exitCode).toBe(1);
56
- expect(result.metadata?.success).toBe(false);
76
+ expect(result.exitCode).toBe(1);
77
+
78
+ connectedClient.close();
57
79
  });
58
80
 
59
- it("should return unhealthy for timeout", async () => {
81
+ it("should indicate timeout for timed out script", async () => {
60
82
  const strategy = new ScriptHealthCheckStrategy(
61
83
  createMockExecutor({ timedOut: true, exitCode: -1 })
62
84
  );
85
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
63
86
 
64
- const result = await strategy.execute({
87
+ const result = await connectedClient.client.exec({
65
88
  command: "sleep",
66
89
  args: ["60"],
67
90
  timeout: 1000,
68
91
  });
69
92
 
70
- expect(result.status).toBe("unhealthy");
71
- expect(result.message).toContain("timed out");
72
- expect(result.metadata?.timedOut).toBe(true);
93
+ expect(result.timedOut).toBe(true);
94
+
95
+ connectedClient.close();
73
96
  });
74
97
 
75
- it("should return unhealthy for execution error", async () => {
98
+ it("should return error for execution error", async () => {
76
99
  const strategy = new ScriptHealthCheckStrategy(
77
100
  createMockExecutor({ error: new Error("Command not found") })
78
101
  );
102
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
79
103
 
80
- const result = await strategy.execute({
104
+ const result = await connectedClient.client.exec({
81
105
  command: "nonexistent-command",
106
+ args: [],
82
107
  timeout: 5000,
83
108
  });
84
109
 
85
- expect(result.status).toBe("unhealthy");
86
- expect(result.message).toContain("Command not found");
87
- expect(result.metadata?.executed).toBe(false);
88
- });
89
-
90
- it("should pass executionTime assertion when below threshold", async () => {
91
- const strategy = new ScriptHealthCheckStrategy(createMockExecutor());
92
-
93
- const result = await strategy.execute({
94
- command: "/usr/bin/true",
95
- timeout: 5000,
96
- assertions: [
97
- { field: "executionTime", operator: "lessThan", value: 5000 },
98
- ],
99
- });
100
-
101
- expect(result.status).toBe("healthy");
102
- });
103
-
104
- it("should pass exitCode assertion", async () => {
105
- const strategy = new ScriptHealthCheckStrategy(
106
- createMockExecutor({ exitCode: 0 })
107
- );
108
-
109
- const result = await strategy.execute({
110
- command: "/usr/bin/true",
111
- timeout: 5000,
112
- assertions: [{ field: "exitCode", operator: "equals", value: 0 }],
113
- });
114
-
115
- expect(result.status).toBe("healthy");
116
- });
117
-
118
- it("should fail exitCode assertion when non-zero", async () => {
119
- const strategy = new ScriptHealthCheckStrategy(
120
- createMockExecutor({ exitCode: 2 })
121
- );
110
+ expect(result.error).toContain("Command not found");
122
111
 
123
- const result = await strategy.execute({
124
- command: "/usr/bin/false",
125
- timeout: 5000,
126
- assertions: [{ field: "exitCode", operator: "equals", value: 0 }],
127
- });
128
-
129
- expect(result.status).toBe("unhealthy");
130
- expect(result.message).toContain("Assertion failed");
112
+ connectedClient.close();
131
113
  });
132
114
 
133
- it("should pass stdout assertion", async () => {
134
- const strategy = new ScriptHealthCheckStrategy(
135
- createMockExecutor({ stdout: "Service is running" })
136
- );
137
-
138
- const result = await strategy.execute({
139
- command: "/usr/bin/echo",
140
- args: ["Service is running"],
141
- timeout: 5000,
142
- assertions: [
143
- { field: "stdout", operator: "contains", value: "running" },
144
- ],
145
- });
146
-
147
- expect(result.status).toBe("healthy");
148
- });
149
-
150
- it("should pass with arguments and env vars", async () => {
115
+ it("should pass arguments, cwd, and env to executor", async () => {
151
116
  const mockExecutor = createMockExecutor({ exitCode: 0 });
152
117
  const strategy = new ScriptHealthCheckStrategy(mockExecutor);
118
+ const connectedClient = await strategy.createClient({ timeout: 5000 });
153
119
 
154
- await strategy.execute({
120
+ await connectedClient.client.exec({
155
121
  command: "./check.sh",
156
122
  args: ["--verbose", "--env=prod"],
157
123
  cwd: "/opt/scripts",
@@ -167,6 +133,8 @@ describe("ScriptHealthCheckStrategy", () => {
167
133
  env: { API_KEY: "secret" },
168
134
  })
169
135
  );
136
+
137
+ connectedClient.close();
170
138
  });
171
139
  });
172
140
 
package/src/strategy.ts CHANGED
@@ -1,66 +1,50 @@
1
1
  import { spawn, type Subprocess } from "bun";
2
2
  import {
3
3
  HealthCheckStrategy,
4
- HealthCheckResult,
5
4
  HealthCheckRunForAggregation,
6
5
  Versioned,
7
6
  z,
8
- timeThresholdField,
9
- numericField,
10
- booleanField,
11
- stringField,
12
- evaluateAssertions,
7
+ type ConnectedClient,
13
8
  } from "@checkstack/backend-api";
14
9
  import {
15
10
  healthResultBoolean,
16
11
  healthResultNumber,
17
12
  healthResultString,
18
13
  } from "@checkstack/healthcheck-common";
14
+ import type {
15
+ ScriptTransportClient,
16
+ ScriptRequest,
17
+ ScriptResult as ScriptResultType,
18
+ } from "./transport-client";
19
19
 
20
20
  // ============================================================================
21
21
  // SCHEMAS
22
22
  // ============================================================================
23
23
 
24
- /**
25
- * Assertion schema for Script health checks using shared factories.
26
- */
27
- const scriptAssertionSchema = z.discriminatedUnion("field", [
28
- timeThresholdField("executionTime"),
29
- numericField("exitCode", { min: 0 }),
30
- booleanField("success"),
31
- stringField("stdout"),
32
- ]);
33
-
34
- export type ScriptAssertion = z.infer<typeof scriptAssertionSchema>;
35
-
36
24
  /**
37
25
  * Configuration schema for Script health checks.
26
+ * Global defaults only - action params moved to ExecuteCollector.
38
27
  */
39
28
  export const scriptConfigSchema = z.object({
40
- command: z.string().describe("Command or script path to execute"),
41
- args: z
42
- .array(z.string())
43
- .default([])
44
- .describe("Arguments to pass to the command"),
45
- cwd: z.string().optional().describe("Working directory for script execution"),
46
- env: z
47
- .record(z.string(), z.string())
48
- .optional()
49
- .describe("Environment variables to set"),
50
29
  timeout: z
51
30
  .number()
52
31
  .min(100)
53
32
  .default(30_000)
54
- .describe("Execution timeout in milliseconds"),
55
- assertions: z
56
- .array(scriptAssertionSchema)
57
- .optional()
58
- .describe("Validation conditions"),
33
+ .describe("Default execution timeout in milliseconds"),
59
34
  });
60
35
 
61
36
  export type ScriptConfig = z.infer<typeof scriptConfigSchema>;
62
37
  export type ScriptConfigInput = z.input<typeof scriptConfigSchema>;
63
38
 
39
+ // Legacy config type for migrations
40
+ interface ScriptConfigV1 {
41
+ command: string;
42
+ args: string[];
43
+ cwd?: string;
44
+ env?: Record<string, string>;
45
+ timeout: number;
46
+ }
47
+
64
48
  /**
65
49
  * Per-run result metadata.
66
50
  */
@@ -78,14 +62,6 @@ const scriptResultSchema = z.object({
78
62
  "x-chart-type": "counter",
79
63
  "x-chart-label": "Exit Code",
80
64
  }).optional(),
81
- stdout: healthResultString({
82
- "x-chart-type": "text",
83
- "x-chart-label": "Stdout",
84
- }).optional(),
85
- stderr: healthResultString({
86
- "x-chart-type": "text",
87
- "x-chart-label": "Stderr",
88
- }).optional(),
89
65
  success: healthResultBoolean({
90
66
  "x-chart-type": "boolean",
91
67
  "x-chart-label": "Success",
@@ -94,14 +70,13 @@ const scriptResultSchema = z.object({
94
70
  "x-chart-type": "boolean",
95
71
  "x-chart-label": "Timed Out",
96
72
  }),
97
- failedAssertion: scriptAssertionSchema.optional(),
98
73
  error: healthResultString({
99
74
  "x-chart-type": "status",
100
75
  "x-chart-label": "Error",
101
76
  }).optional(),
102
77
  });
103
78
 
104
- export type ScriptResult = z.infer<typeof scriptResultSchema>;
79
+ type ScriptResult = z.infer<typeof scriptResultSchema>;
105
80
 
106
81
  /**
107
82
  * Aggregated metadata for buckets.
@@ -127,13 +102,13 @@ const scriptAggregatedSchema = z.object({
127
102
  }),
128
103
  });
129
104
 
130
- export type ScriptAggregatedResult = z.infer<typeof scriptAggregatedSchema>;
105
+ type ScriptAggregatedResult = z.infer<typeof scriptAggregatedSchema>;
131
106
 
132
107
  // ============================================================================
133
108
  // SCRIPT EXECUTOR INTERFACE (for testability)
134
109
  // ============================================================================
135
110
 
136
- export interface ScriptExecutionResult {
111
+ interface ScriptExecutionResult {
137
112
  exitCode: number;
138
113
  stdout: string;
139
114
  stderr: string;
@@ -164,7 +139,7 @@ const defaultScriptExecutor: ScriptExecutor = {
164
139
  }, config.timeout);
165
140
  });
166
141
 
167
- const execPromise = (async () => {
142
+ try {
168
143
  proc = spawn({
169
144
  cmd: [config.command, ...config.args],
170
145
  cwd: config.cwd,
@@ -173,15 +148,14 @@ const defaultScriptExecutor: ScriptExecutor = {
173
148
  stderr: "pipe",
174
149
  });
175
150
 
176
- const stdoutStream = proc.stdout;
177
- const stderrStream = proc.stderr;
178
- const stdout = stdoutStream
179
- ? await new Response(stdoutStream as unknown as ReadableStream).text()
180
- : "";
181
- const stderr = stderrStream
182
- ? await new Response(stderrStream as unknown as ReadableStream).text()
183
- : "";
184
- const exitCode = await proc.exited;
151
+ const [stdout, stderr, exitCode] = await Promise.race([
152
+ Promise.all([
153
+ new Response(proc.stdout as ReadableStream).text(),
154
+ new Response(proc.stderr as ReadableStream).text(),
155
+ proc.exited,
156
+ ]),
157
+ timeoutPromise,
158
+ ]);
185
159
 
186
160
  return {
187
161
  exitCode,
@@ -189,10 +163,6 @@ const defaultScriptExecutor: ScriptExecutor = {
189
163
  stderr: stderr.trim(),
190
164
  timedOut: false,
191
165
  };
192
- })();
193
-
194
- try {
195
- return await Promise.race([execPromise, timeoutPromise]);
196
166
  } catch (error) {
197
167
  if (timedOut) {
198
168
  return {
@@ -213,7 +183,12 @@ const defaultScriptExecutor: ScriptExecutor = {
213
183
 
214
184
  export class ScriptHealthCheckStrategy
215
185
  implements
216
- HealthCheckStrategy<ScriptConfig, ScriptResult, ScriptAggregatedResult>
186
+ HealthCheckStrategy<
187
+ ScriptConfig,
188
+ ScriptTransportClient,
189
+ ScriptResult,
190
+ ScriptAggregatedResult
191
+ >
217
192
  {
218
193
  id = "script";
219
194
  displayName = "Script Health Check";
@@ -226,13 +201,31 @@ export class ScriptHealthCheckStrategy
226
201
  }
227
202
 
228
203
  config: Versioned<ScriptConfig> = new Versioned({
229
- version: 1,
204
+ version: 2,
230
205
  schema: scriptConfigSchema,
206
+ migrations: [
207
+ {
208
+ fromVersion: 1,
209
+ toVersion: 2,
210
+ description: "Remove command/args/cwd/env (moved to ExecuteCollector)",
211
+ migrate: (data: ScriptConfigV1): ScriptConfig => ({
212
+ timeout: data.timeout,
213
+ }),
214
+ },
215
+ ],
231
216
  });
232
217
 
233
218
  result: Versioned<ScriptResult> = new Versioned({
234
- version: 1,
219
+ version: 2,
235
220
  schema: scriptResultSchema,
221
+ migrations: [
222
+ {
223
+ fromVersion: 1,
224
+ toVersion: 2,
225
+ description: "Migrate to createClient pattern (no result changes)",
226
+ migrate: (data: unknown) => data,
227
+ },
228
+ ],
236
229
  });
237
230
 
238
231
  aggregatedResult: Versioned<ScriptAggregatedResult> = new Versioned({
@@ -243,123 +236,86 @@ export class ScriptHealthCheckStrategy
243
236
  aggregateResult(
244
237
  runs: HealthCheckRunForAggregation<ScriptResult>[]
245
238
  ): ScriptAggregatedResult {
246
- let totalExecutionTime = 0;
247
- let successCount = 0;
248
- let errorCount = 0;
249
- let timeoutCount = 0;
250
- let validRuns = 0;
251
-
252
- for (const run of runs) {
253
- if (run.metadata?.error) {
254
- errorCount++;
255
- continue;
256
- }
257
- if (run.metadata?.timedOut) {
258
- timeoutCount++;
259
- }
260
- if (run.status === "healthy") {
261
- successCount++;
262
- }
263
- if (run.metadata) {
264
- totalExecutionTime += run.metadata.executionTimeMs;
265
- validRuns++;
266
- }
267
- }
239
+ const validRuns = runs.filter((r) => r.metadata);
268
240
 
269
- return {
270
- avgExecutionTime: validRuns > 0 ? totalExecutionTime / validRuns : 0,
271
- successRate: runs.length > 0 ? (successCount / runs.length) * 100 : 0,
272
- errorCount,
273
- timeoutCount,
274
- };
275
- }
241
+ if (validRuns.length === 0) {
242
+ return {
243
+ avgExecutionTime: 0,
244
+ successRate: 0,
245
+ errorCount: 0,
246
+ timeoutCount: 0,
247
+ };
248
+ }
276
249
 
277
- async execute(
278
- config: ScriptConfigInput
279
- ): Promise<HealthCheckResult<ScriptResult>> {
280
- const validatedConfig = this.config.validate(config);
281
- const start = performance.now();
250
+ const executionTimes = validRuns
251
+ .map((r) => r.metadata?.executionTimeMs)
252
+ .filter((t): t is number => typeof t === "number");
282
253
 
283
- try {
284
- const execResult = await this.executor.execute({
285
- command: validatedConfig.command,
286
- args: validatedConfig.args,
287
- cwd: validatedConfig.cwd,
288
- env: validatedConfig.env as Record<string, string> | undefined,
289
- timeout: validatedConfig.timeout,
290
- });
254
+ const avgExecutionTime =
255
+ executionTimes.length > 0
256
+ ? Math.round(
257
+ executionTimes.reduce((a, b) => a + b, 0) / executionTimes.length
258
+ )
259
+ : 0;
291
260
 
292
- const executionTimeMs = Math.round(performance.now() - start);
293
- const success = execResult.exitCode === 0 && !execResult.timedOut;
294
-
295
- const result: Omit<ScriptResult, "failedAssertion" | "error"> = {
296
- executed: true,
297
- executionTimeMs,
298
- exitCode: execResult.exitCode,
299
- stdout: execResult.stdout,
300
- stderr: execResult.stderr,
301
- success,
302
- timedOut: execResult.timedOut,
303
- };
261
+ const successCount = validRuns.filter(
262
+ (r) => r.metadata?.success === true
263
+ ).length;
264
+ const successRate = Math.round((successCount / validRuns.length) * 100);
304
265
 
305
- // Evaluate assertions using shared utility
306
- const failedAssertion = evaluateAssertions(validatedConfig.assertions, {
307
- executionTime: executionTimeMs,
308
- exitCode: execResult.exitCode,
309
- success,
310
- stdout: execResult.stdout,
311
- });
266
+ const errorCount = validRuns.filter(
267
+ (r) => r.metadata?.error !== undefined
268
+ ).length;
312
269
 
313
- if (failedAssertion) {
314
- return {
315
- status: "unhealthy",
316
- latencyMs: executionTimeMs,
317
- message: `Assertion failed: ${failedAssertion.field} ${
318
- failedAssertion.operator
319
- }${"value" in failedAssertion ? ` ${failedAssertion.value}` : ""}`,
320
- metadata: { ...result, failedAssertion },
321
- };
322
- }
270
+ const timeoutCount = validRuns.filter(
271
+ (r) => r.metadata?.timedOut === true
272
+ ).length;
323
273
 
324
- if (execResult.timedOut) {
325
- return {
326
- status: "unhealthy",
327
- latencyMs: executionTimeMs,
328
- message: `Script timed out after ${validatedConfig.timeout}ms`,
329
- metadata: result,
330
- };
331
- }
274
+ return {
275
+ avgExecutionTime,
276
+ successRate,
277
+ errorCount,
278
+ timeoutCount,
279
+ };
280
+ }
332
281
 
333
- if (!success) {
334
- return {
335
- status: "unhealthy",
336
- latencyMs: executionTimeMs,
337
- message: `Script failed with exit code ${execResult.exitCode}`,
338
- metadata: result,
339
- };
340
- }
282
+ async createClient(
283
+ _config: ScriptConfigInput
284
+ ): Promise<ConnectedClient<ScriptTransportClient>> {
285
+ const client: ScriptTransportClient = {
286
+ exec: async (request: ScriptRequest): Promise<ScriptResultType> => {
287
+ try {
288
+ const result = await this.executor.execute({
289
+ command: request.command,
290
+ args: request.args,
291
+ cwd: request.cwd,
292
+ env: request.env,
293
+ timeout: request.timeout,
294
+ });
295
+
296
+ return {
297
+ exitCode: result.exitCode,
298
+ stdout: result.stdout,
299
+ stderr: result.stderr,
300
+ timedOut: result.timedOut,
301
+ };
302
+ } catch (error) {
303
+ return {
304
+ exitCode: -1,
305
+ stdout: "",
306
+ stderr: "",
307
+ timedOut: false,
308
+ error: error instanceof Error ? error.message : String(error),
309
+ };
310
+ }
311
+ },
312
+ };
341
313
 
342
- return {
343
- status: "healthy",
344
- latencyMs: executionTimeMs,
345
- message: `Script executed successfully (exit 0) in ${executionTimeMs}ms`,
346
- metadata: result,
347
- };
348
- } catch (error: unknown) {
349
- const end = performance.now();
350
- const isError = error instanceof Error;
351
- return {
352
- status: "unhealthy",
353
- latencyMs: Math.round(end - start),
354
- message: isError ? error.message : "Script execution failed",
355
- metadata: {
356
- executed: false,
357
- executionTimeMs: Math.round(end - start),
358
- success: false,
359
- timedOut: false,
360
- error: isError ? error.name : "UnknownError",
361
- },
362
- };
363
- }
314
+ return {
315
+ client,
316
+ close: () => {
317
+ // Script executor is stateless, nothing to close
318
+ },
319
+ };
364
320
  }
365
321
  }
@@ -0,0 +1,31 @@
1
+ import type { TransportClient } from "@checkstack/common";
2
+
3
+ /**
4
+ * Script execution request.
5
+ */
6
+ export interface ScriptRequest {
7
+ command: string;
8
+ args: string[];
9
+ cwd?: string;
10
+ env?: Record<string, string>;
11
+ timeout: number;
12
+ }
13
+
14
+ /**
15
+ * Script execution result.
16
+ */
17
+ export interface ScriptResult {
18
+ exitCode: number;
19
+ stdout: string;
20
+ stderr: string;
21
+ timedOut: boolean;
22
+ error?: string;
23
+ }
24
+
25
+ /**
26
+ * Script transport client for command execution.
27
+ */
28
+ export type ScriptTransportClient = TransportClient<
29
+ ScriptRequest,
30
+ ScriptResult
31
+ >;