@synth-deploy/server 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/dist/agent/debrief-retention.d.ts +12 -0
- package/dist/agent/debrief-retention.d.ts.map +1 -0
- package/dist/agent/debrief-retention.js +27 -0
- package/dist/agent/debrief-retention.js.map +1 -0
- package/dist/agent/envoy-client.d.ts +216 -0
- package/dist/agent/envoy-client.d.ts.map +1 -0
- package/dist/agent/envoy-client.js +266 -0
- package/dist/agent/envoy-client.js.map +1 -0
- package/dist/agent/envoy-registry.d.ts +102 -0
- package/dist/agent/envoy-registry.d.ts.map +1 -0
- package/dist/agent/envoy-registry.js +319 -0
- package/dist/agent/envoy-registry.js.map +1 -0
- package/dist/agent/health-checker.d.ts +39 -0
- package/dist/agent/health-checker.d.ts.map +1 -0
- package/dist/agent/health-checker.js +49 -0
- package/dist/agent/health-checker.js.map +1 -0
- package/dist/agent/mcp-client-manager.d.ts +36 -0
- package/dist/agent/mcp-client-manager.d.ts.map +1 -0
- package/dist/agent/mcp-client-manager.js +106 -0
- package/dist/agent/mcp-client-manager.js.map +1 -0
- package/dist/agent/stale-deployment-detector.d.ts +15 -0
- package/dist/agent/stale-deployment-detector.d.ts.map +1 -0
- package/dist/agent/stale-deployment-detector.js +50 -0
- package/dist/agent/stale-deployment-detector.js.map +1 -0
- package/dist/agent/step-runner.d.ts +31 -0
- package/dist/agent/step-runner.d.ts.map +1 -0
- package/dist/agent/step-runner.js +80 -0
- package/dist/agent/step-runner.js.map +1 -0
- package/dist/agent/synth-agent.d.ts +168 -0
- package/dist/agent/synth-agent.d.ts.map +1 -0
- package/dist/agent/synth-agent.js +1195 -0
- package/dist/agent/synth-agent.js.map +1 -0
- package/dist/api/agent.d.ts +36 -0
- package/dist/api/agent.d.ts.map +1 -0
- package/dist/api/agent.js +867 -0
- package/dist/api/agent.js.map +1 -0
- package/dist/api/api-keys.d.ts +4 -0
- package/dist/api/api-keys.d.ts.map +1 -0
- package/dist/api/api-keys.js +118 -0
- package/dist/api/api-keys.js.map +1 -0
- package/dist/api/artifacts.d.ts +5 -0
- package/dist/api/artifacts.d.ts.map +1 -0
- package/dist/api/artifacts.js +142 -0
- package/dist/api/artifacts.js.map +1 -0
- package/dist/api/auth.d.ts +4 -0
- package/dist/api/auth.d.ts.map +1 -0
- package/dist/api/auth.js +280 -0
- package/dist/api/auth.js.map +1 -0
- package/dist/api/deployments.d.ts +11 -0
- package/dist/api/deployments.d.ts.map +1 -0
- package/dist/api/deployments.js +1098 -0
- package/dist/api/deployments.js.map +1 -0
- package/dist/api/environments.d.ts +5 -0
- package/dist/api/environments.d.ts.map +1 -0
- package/dist/api/environments.js +69 -0
- package/dist/api/environments.js.map +1 -0
- package/dist/api/envoy-reports.d.ts +17 -0
- package/dist/api/envoy-reports.d.ts.map +1 -0
- package/dist/api/envoy-reports.js +138 -0
- package/dist/api/envoy-reports.js.map +1 -0
- package/dist/api/envoys.d.ts +5 -0
- package/dist/api/envoys.d.ts.map +1 -0
- package/dist/api/envoys.js +192 -0
- package/dist/api/envoys.js.map +1 -0
- package/dist/api/fleet.d.ts +11 -0
- package/dist/api/fleet.d.ts.map +1 -0
- package/dist/api/fleet.js +394 -0
- package/dist/api/fleet.js.map +1 -0
- package/dist/api/graph.d.ts +8 -0
- package/dist/api/graph.d.ts.map +1 -0
- package/dist/api/graph.js +355 -0
- package/dist/api/graph.js.map +1 -0
- package/dist/api/health.d.ts +20 -0
- package/dist/api/health.d.ts.map +1 -0
- package/dist/api/health.js +248 -0
- package/dist/api/health.js.map +1 -0
- package/dist/api/idp-schemas.d.ts +41 -0
- package/dist/api/idp-schemas.d.ts.map +1 -0
- package/dist/api/idp-schemas.js +17 -0
- package/dist/api/idp-schemas.js.map +1 -0
- package/dist/api/idp.d.ts +6 -0
- package/dist/api/idp.d.ts.map +1 -0
- package/dist/api/idp.js +620 -0
- package/dist/api/idp.js.map +1 -0
- package/dist/api/intake.d.ts +10 -0
- package/dist/api/intake.d.ts.map +1 -0
- package/dist/api/intake.js +418 -0
- package/dist/api/intake.js.map +1 -0
- package/dist/api/partitions.d.ts +5 -0
- package/dist/api/partitions.d.ts.map +1 -0
- package/dist/api/partitions.js +113 -0
- package/dist/api/partitions.js.map +1 -0
- package/dist/api/progress-event-store.d.ts +62 -0
- package/dist/api/progress-event-store.d.ts.map +1 -0
- package/dist/api/progress-event-store.js +118 -0
- package/dist/api/progress-event-store.js.map +1 -0
- package/dist/api/schemas.d.ts +1000 -0
- package/dist/api/schemas.d.ts.map +1 -0
- package/dist/api/schemas.js +328 -0
- package/dist/api/schemas.js.map +1 -0
- package/dist/api/security-boundaries.d.ts +4 -0
- package/dist/api/security-boundaries.d.ts.map +1 -0
- package/dist/api/security-boundaries.js +32 -0
- package/dist/api/security-boundaries.js.map +1 -0
- package/dist/api/settings.d.ts +4 -0
- package/dist/api/settings.d.ts.map +1 -0
- package/dist/api/settings.js +99 -0
- package/dist/api/settings.js.map +1 -0
- package/dist/api/system.d.ts +75 -0
- package/dist/api/system.d.ts.map +1 -0
- package/dist/api/system.js +558 -0
- package/dist/api/system.js.map +1 -0
- package/dist/api/telemetry.d.ts +4 -0
- package/dist/api/telemetry.d.ts.map +1 -0
- package/dist/api/telemetry.js +24 -0
- package/dist/api/telemetry.js.map +1 -0
- package/dist/api/users.d.ts +4 -0
- package/dist/api/users.d.ts.map +1 -0
- package/dist/api/users.js +173 -0
- package/dist/api/users.js.map +1 -0
- package/dist/archive-unpacker.d.ts +24 -0
- package/dist/archive-unpacker.d.ts.map +1 -0
- package/dist/archive-unpacker.js +239 -0
- package/dist/archive-unpacker.js.map +1 -0
- package/dist/artifact-analyzer.d.ts +59 -0
- package/dist/artifact-analyzer.d.ts.map +1 -0
- package/dist/artifact-analyzer.js +334 -0
- package/dist/artifact-analyzer.js.map +1 -0
- package/dist/auth/idp/index.d.ts +9 -0
- package/dist/auth/idp/index.d.ts.map +1 -0
- package/dist/auth/idp/index.js +5 -0
- package/dist/auth/idp/index.js.map +1 -0
- package/dist/auth/idp/ldap.d.ts +56 -0
- package/dist/auth/idp/ldap.d.ts.map +1 -0
- package/dist/auth/idp/ldap.js +276 -0
- package/dist/auth/idp/ldap.js.map +1 -0
- package/dist/auth/idp/oidc.d.ts +27 -0
- package/dist/auth/idp/oidc.d.ts.map +1 -0
- package/dist/auth/idp/oidc.js +97 -0
- package/dist/auth/idp/oidc.js.map +1 -0
- package/dist/auth/idp/role-mapping.d.ts +9 -0
- package/dist/auth/idp/role-mapping.d.ts.map +1 -0
- package/dist/auth/idp/role-mapping.js +16 -0
- package/dist/auth/idp/role-mapping.js.map +1 -0
- package/dist/auth/idp/saml.d.ts +40 -0
- package/dist/auth/idp/saml.d.ts.map +1 -0
- package/dist/auth/idp/saml.js +117 -0
- package/dist/auth/idp/saml.js.map +1 -0
- package/dist/auth/idp/types.d.ts +23 -0
- package/dist/auth/idp/types.d.ts.map +1 -0
- package/dist/auth/idp/types.js +2 -0
- package/dist/auth/idp/types.js.map +1 -0
- package/dist/fleet/fleet-executor.d.ts +35 -0
- package/dist/fleet/fleet-executor.d.ts.map +1 -0
- package/dist/fleet/fleet-executor.js +228 -0
- package/dist/fleet/fleet-executor.js.map +1 -0
- package/dist/fleet/fleet-store.d.ts +13 -0
- package/dist/fleet/fleet-store.d.ts.map +1 -0
- package/dist/fleet/fleet-store.js +13 -0
- package/dist/fleet/fleet-store.js.map +1 -0
- package/dist/fleet/index.d.ts +5 -0
- package/dist/fleet/index.d.ts.map +1 -0
- package/dist/fleet/index.js +4 -0
- package/dist/fleet/index.js.map +1 -0
- package/dist/fleet/representative-selector.d.ts +15 -0
- package/dist/fleet/representative-selector.d.ts.map +1 -0
- package/dist/fleet/representative-selector.js +71 -0
- package/dist/fleet/representative-selector.js.map +1 -0
- package/dist/graph/graph-executor.d.ts +36 -0
- package/dist/graph/graph-executor.d.ts.map +1 -0
- package/dist/graph/graph-executor.js +348 -0
- package/dist/graph/graph-executor.js.map +1 -0
- package/dist/graph/graph-inference.d.ts +22 -0
- package/dist/graph/graph-inference.d.ts.map +1 -0
- package/dist/graph/graph-inference.js +149 -0
- package/dist/graph/graph-inference.js.map +1 -0
- package/dist/graph/graph-store.d.ts +12 -0
- package/dist/graph/graph-store.d.ts.map +1 -0
- package/dist/graph/graph-store.js +61 -0
- package/dist/graph/graph-store.js.map +1 -0
- package/dist/graph/index.d.ts +5 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +4 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +837 -0
- package/dist/index.js.map +1 -0
- package/dist/intake/index.d.ts +6 -0
- package/dist/intake/index.d.ts.map +1 -0
- package/dist/intake/index.js +5 -0
- package/dist/intake/index.js.map +1 -0
- package/dist/intake/intake-processor.d.ts +17 -0
- package/dist/intake/intake-processor.d.ts.map +1 -0
- package/dist/intake/intake-processor.js +99 -0
- package/dist/intake/intake-processor.js.map +1 -0
- package/dist/intake/intake-store.d.ts +7 -0
- package/dist/intake/intake-store.d.ts.map +1 -0
- package/dist/intake/intake-store.js +7 -0
- package/dist/intake/intake-store.js.map +1 -0
- package/dist/intake/registry-poller.d.ts +41 -0
- package/dist/intake/registry-poller.d.ts.map +1 -0
- package/dist/intake/registry-poller.js +202 -0
- package/dist/intake/registry-poller.js.map +1 -0
- package/dist/intake/webhook-handlers.d.ts +37 -0
- package/dist/intake/webhook-handlers.d.ts.map +1 -0
- package/dist/intake/webhook-handlers.js +268 -0
- package/dist/intake/webhook-handlers.js.map +1 -0
- package/dist/logger.d.ts +5 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +15 -0
- package/dist/logger.js.map +1 -0
- package/dist/mcp/resources.d.ts +9 -0
- package/dist/mcp/resources.d.ts.map +1 -0
- package/dist/mcp/resources.js +72 -0
- package/dist/mcp/resources.js.map +1 -0
- package/dist/mcp/server.d.ts +15 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +20 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +9 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +88 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/middleware/auth.d.ts +29 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +76 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/permissions.d.ts +13 -0
- package/dist/middleware/permissions.d.ts.map +1 -0
- package/dist/middleware/permissions.js +32 -0
- package/dist/middleware/permissions.js.map +1 -0
- package/dist/pattern-store.d.ts +104 -0
- package/dist/pattern-store.d.ts.map +1 -0
- package/dist/pattern-store.js +299 -0
- package/dist/pattern-store.js.map +1 -0
- package/package.json +54 -0
- package/src/agent/debrief-retention.ts +44 -0
- package/src/agent/envoy-client.ts +474 -0
- package/src/agent/envoy-registry.ts +384 -0
- package/src/agent/health-checker.ts +70 -0
- package/src/agent/mcp-client-manager.ts +131 -0
- package/src/agent/stale-deployment-detector.ts +79 -0
- package/src/agent/step-runner.ts +124 -0
- package/src/agent/synth-agent.ts +1567 -0
- package/src/api/agent.ts +1075 -0
- package/src/api/api-keys.ts +129 -0
- package/src/api/artifacts.ts +194 -0
- package/src/api/auth.ts +320 -0
- package/src/api/deployments.ts +1347 -0
- package/src/api/environments.ts +97 -0
- package/src/api/envoy-reports.ts +159 -0
- package/src/api/envoys.ts +237 -0
- package/src/api/fleet.ts +510 -0
- package/src/api/graph.ts +516 -0
- package/src/api/health.ts +311 -0
- package/src/api/idp-schemas.ts +19 -0
- package/src/api/idp.ts +735 -0
- package/src/api/intake.ts +537 -0
- package/src/api/partitions.ts +147 -0
- package/src/api/progress-event-store.ts +153 -0
- package/src/api/schemas.ts +376 -0
- package/src/api/security-boundaries.ts +54 -0
- package/src/api/settings.ts +118 -0
- package/src/api/system.ts +704 -0
- package/src/api/telemetry.ts +32 -0
- package/src/api/users.ts +210 -0
- package/src/archive-unpacker.ts +271 -0
- package/src/artifact-analyzer.ts +438 -0
- package/src/auth/idp/index.ts +8 -0
- package/src/auth/idp/ldap.ts +340 -0
- package/src/auth/idp/oidc.ts +117 -0
- package/src/auth/idp/role-mapping.ts +22 -0
- package/src/auth/idp/saml.ts +148 -0
- package/src/auth/idp/types.ts +22 -0
- package/src/fleet/fleet-executor.ts +309 -0
- package/src/fleet/fleet-store.ts +13 -0
- package/src/fleet/index.ts +4 -0
- package/src/fleet/representative-selector.ts +83 -0
- package/src/graph/graph-executor.ts +446 -0
- package/src/graph/graph-inference.ts +184 -0
- package/src/graph/graph-store.ts +75 -0
- package/src/graph/index.ts +4 -0
- package/src/index.ts +916 -0
- package/src/intake/index.ts +5 -0
- package/src/intake/intake-processor.ts +111 -0
- package/src/intake/intake-store.ts +7 -0
- package/src/intake/registry-poller.ts +230 -0
- package/src/intake/webhook-handlers.ts +328 -0
- package/src/logger.ts +19 -0
- package/src/mcp/resources.ts +98 -0
- package/src/mcp/server.ts +34 -0
- package/src/mcp/tools.ts +117 -0
- package/src/middleware/auth.ts +103 -0
- package/src/middleware/permissions.ts +35 -0
- package/src/pattern-store.ts +409 -0
- package/tests/agent-mode.test.ts +536 -0
- package/tests/api-handlers.test.ts +1245 -0
- package/tests/archive-unpacker.test.ts +179 -0
- package/tests/artifact-analyzer.test.ts +240 -0
- package/tests/auth-middleware.test.ts +189 -0
- package/tests/decision-diary.test.ts +957 -0
- package/tests/diary-reader.test.ts +782 -0
- package/tests/envoy-client.test.ts +342 -0
- package/tests/envoy-reports.test.ts +156 -0
- package/tests/mcp-tools.test.ts +213 -0
- package/tests/orchestration.test.ts +536 -0
- package/tests/partition-deletion.test.ts +143 -0
- package/tests/partition-isolation.test.ts +830 -0
- package/tests/pattern-store.test.ts +371 -0
- package/tests/rbac-enforcement.test.ts +409 -0
- package/tests/ssrf-validation.test.ts +56 -0
- package/tests/stale-deployment.test.ts +85 -0
- package/tests/step-runner.test.ts +308 -0
- package/tests/ui-journey.test.ts +330 -0
- package/tsconfig.json +11 -0
- package/vitest.config.ts +27 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { EnvoyClient } from "./envoy-client.js";
|
|
3
|
+
import type { EnvoyHealthResponse } from "./envoy-client.js";
|
|
4
|
+
import type { PersistentEnvoyRegistryStore, PersistedEnvoyRegistration } from "@synth-deploy/core";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Types
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export interface EnvoyRegistration {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
url: string;
|
|
14
|
+
/** Shared secret for authenticating requests between Command and this Envoy */
|
|
15
|
+
token: string;
|
|
16
|
+
/** Environments this Envoy is assigned to (empty = available for any) */
|
|
17
|
+
assignedEnvironments: string[];
|
|
18
|
+
/** Partitions this Envoy is assigned to */
|
|
19
|
+
assignedPartitions: string[];
|
|
20
|
+
registeredAt: string;
|
|
21
|
+
lastHealthCheck: string | null;
|
|
22
|
+
lastHealthStatus: "healthy" | "degraded" | "unreachable" | null;
|
|
23
|
+
/** Cached from last successful health probe */
|
|
24
|
+
cachedHostname: string | null;
|
|
25
|
+
cachedOs: string | null;
|
|
26
|
+
cachedSummary: EnvoyHealthResponse["summary"] | null;
|
|
27
|
+
cachedReadiness: EnvoyHealthResponse["readiness"] | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface EnvoyRegistryEntry extends EnvoyRegistration {
|
|
31
|
+
health: "OK" | "Degraded" | "Unreachable";
|
|
32
|
+
hostname: string | null;
|
|
33
|
+
os: string | null;
|
|
34
|
+
lastSeen: string | null;
|
|
35
|
+
summary: EnvoyHealthResponse["summary"] | null;
|
|
36
|
+
readiness: EnvoyHealthResponse["readiness"] | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Helpers to convert between persisted and domain types
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
function fromPersisted(p: PersistedEnvoyRegistration): EnvoyRegistration {
|
|
44
|
+
return {
|
|
45
|
+
id: p.id,
|
|
46
|
+
name: p.name,
|
|
47
|
+
url: p.url,
|
|
48
|
+
token: p.token,
|
|
49
|
+
assignedEnvironments: p.assignedEnvironments,
|
|
50
|
+
assignedPartitions: p.assignedPartitions,
|
|
51
|
+
registeredAt: p.registeredAt,
|
|
52
|
+
lastHealthCheck: p.lastHealthCheck,
|
|
53
|
+
lastHealthStatus: p.lastHealthStatus,
|
|
54
|
+
cachedHostname: p.cachedHostname,
|
|
55
|
+
cachedOs: p.cachedOs,
|
|
56
|
+
cachedSummary: p.cachedSummary as EnvoyHealthResponse["summary"] | null,
|
|
57
|
+
cachedReadiness: p.cachedReadiness as EnvoyHealthResponse["readiness"] | null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function toPersisted(r: EnvoyRegistration): PersistedEnvoyRegistration {
|
|
62
|
+
return {
|
|
63
|
+
id: r.id,
|
|
64
|
+
name: r.name,
|
|
65
|
+
url: r.url,
|
|
66
|
+
token: r.token,
|
|
67
|
+
assignedEnvironments: r.assignedEnvironments,
|
|
68
|
+
assignedPartitions: r.assignedPartitions,
|
|
69
|
+
registeredAt: r.registeredAt,
|
|
70
|
+
lastHealthCheck: r.lastHealthCheck,
|
|
71
|
+
lastHealthStatus: r.lastHealthStatus,
|
|
72
|
+
cachedHostname: r.cachedHostname,
|
|
73
|
+
cachedOs: r.cachedOs,
|
|
74
|
+
cachedSummary: r.cachedSummary,
|
|
75
|
+
cachedReadiness: r.cachedReadiness,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// EnvoyRegistry — SQLite-backed registry for multiple Envoy instances
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
export class EnvoyRegistry {
|
|
84
|
+
constructor(private store?: PersistentEnvoyRegistryStore) {}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Register a new Envoy. Returns the registration with a generated token.
|
|
88
|
+
*/
|
|
89
|
+
register(params: {
|
|
90
|
+
name: string;
|
|
91
|
+
url: string;
|
|
92
|
+
assignedEnvironments?: string[];
|
|
93
|
+
assignedPartitions?: string[];
|
|
94
|
+
}): EnvoyRegistration {
|
|
95
|
+
const id = crypto.randomUUID();
|
|
96
|
+
const token = crypto.randomBytes(32).toString("hex");
|
|
97
|
+
|
|
98
|
+
const registration: EnvoyRegistration = {
|
|
99
|
+
id,
|
|
100
|
+
name: params.name,
|
|
101
|
+
url: params.url,
|
|
102
|
+
token,
|
|
103
|
+
assignedEnvironments: params.assignedEnvironments ?? [],
|
|
104
|
+
assignedPartitions: params.assignedPartitions ?? [],
|
|
105
|
+
registeredAt: new Date().toISOString(),
|
|
106
|
+
lastHealthCheck: null,
|
|
107
|
+
lastHealthStatus: null,
|
|
108
|
+
cachedHostname: null,
|
|
109
|
+
cachedOs: null,
|
|
110
|
+
cachedSummary: null,
|
|
111
|
+
cachedReadiness: null,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
this.store?.insert(toPersisted(registration));
|
|
115
|
+
return registration;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Deregister an Envoy by ID.
|
|
120
|
+
*/
|
|
121
|
+
deregister(id: string): boolean {
|
|
122
|
+
if (this.store) {
|
|
123
|
+
return this.store.delete(id);
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* List all registered Envoys.
|
|
130
|
+
*/
|
|
131
|
+
list(): EnvoyRegistration[] {
|
|
132
|
+
if (this.store) {
|
|
133
|
+
return this.store.list().map(fromPersisted);
|
|
134
|
+
}
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Update an Envoy's assignment or name.
|
|
140
|
+
*/
|
|
141
|
+
update(id: string, updates: {
|
|
142
|
+
name?: string;
|
|
143
|
+
url?: string;
|
|
144
|
+
assignedEnvironments?: string[];
|
|
145
|
+
assignedPartitions?: string[];
|
|
146
|
+
}): EnvoyRegistration | undefined {
|
|
147
|
+
if (!this.store) return undefined;
|
|
148
|
+
const existing = this.store.getById(id);
|
|
149
|
+
if (!existing) return undefined;
|
|
150
|
+
|
|
151
|
+
this.store.update(id, {
|
|
152
|
+
name: updates.name,
|
|
153
|
+
url: updates.url,
|
|
154
|
+
assignedEnvironments: updates.assignedEnvironments,
|
|
155
|
+
assignedPartitions: updates.assignedPartitions,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return fromPersisted(this.store.getById(id)!);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Ensure an envoy at `url` is registered with a specific `token`.
|
|
163
|
+
* If already registered, updates its token. If not, creates a new entry.
|
|
164
|
+
* Used to bootstrap the default envoy from environment variables (SYNTH_ENVOY_TOKEN).
|
|
165
|
+
*/
|
|
166
|
+
ensureRegisteredWithToken(params: { name: string; url: string }, token: string): EnvoyRegistration {
|
|
167
|
+
const existing = this.list().find((r) => r.url === params.url);
|
|
168
|
+
if (existing) {
|
|
169
|
+
this.store?.updateToken(existing.id, token);
|
|
170
|
+
return { ...existing, token };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const id = crypto.randomUUID();
|
|
174
|
+
const registration: EnvoyRegistration = {
|
|
175
|
+
id,
|
|
176
|
+
name: params.name,
|
|
177
|
+
url: params.url,
|
|
178
|
+
token,
|
|
179
|
+
assignedEnvironments: [],
|
|
180
|
+
assignedPartitions: [],
|
|
181
|
+
registeredAt: new Date().toISOString(),
|
|
182
|
+
lastHealthCheck: null,
|
|
183
|
+
lastHealthStatus: null,
|
|
184
|
+
cachedHostname: null,
|
|
185
|
+
cachedOs: null,
|
|
186
|
+
cachedSummary: null,
|
|
187
|
+
cachedReadiness: null,
|
|
188
|
+
};
|
|
189
|
+
this.store?.insert(toPersisted(registration));
|
|
190
|
+
return registration;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Rotate the token for an Envoy. Returns the new token.
|
|
195
|
+
*/
|
|
196
|
+
rotateToken(id: string): string | undefined {
|
|
197
|
+
if (!this.store) return undefined;
|
|
198
|
+
const existing = this.store.getById(id);
|
|
199
|
+
if (!existing) return undefined;
|
|
200
|
+
|
|
201
|
+
const newToken = crypto.randomBytes(32).toString("hex");
|
|
202
|
+
this.store.updateToken(id, newToken);
|
|
203
|
+
return newToken;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Validate a token against a registered Envoy.
|
|
208
|
+
*/
|
|
209
|
+
validateToken(token: string): EnvoyRegistration | undefined {
|
|
210
|
+
if (!this.store) return undefined;
|
|
211
|
+
const persisted = this.store.getByToken(token);
|
|
212
|
+
return persisted ? fromPersisted(persisted) : undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Update health status for an Envoy (called after probing).
|
|
217
|
+
*/
|
|
218
|
+
updateHealth(id: string, status: "healthy" | "degraded" | "unreachable"): void {
|
|
219
|
+
this.store?.updateHealth(id, status, new Date().toISOString());
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Find the best Envoy for a given environment.
|
|
224
|
+
* Priority: assigned + healthy > assigned + degraded > unassigned + healthy > fallback
|
|
225
|
+
*/
|
|
226
|
+
findForEnvironment(environmentName: string): EnvoyRegistration | undefined {
|
|
227
|
+
const all = this.list();
|
|
228
|
+
if (all.length === 0) return undefined;
|
|
229
|
+
|
|
230
|
+
// Assigned and healthy
|
|
231
|
+
const assignedHealthy = all.find(
|
|
232
|
+
(e) =>
|
|
233
|
+
e.assignedEnvironments.includes(environmentName) &&
|
|
234
|
+
e.lastHealthStatus === "healthy",
|
|
235
|
+
);
|
|
236
|
+
if (assignedHealthy) return assignedHealthy;
|
|
237
|
+
|
|
238
|
+
// Assigned (any status)
|
|
239
|
+
const assigned = all.find((e) =>
|
|
240
|
+
e.assignedEnvironments.includes(environmentName),
|
|
241
|
+
);
|
|
242
|
+
if (assigned) return assigned;
|
|
243
|
+
|
|
244
|
+
// Unassigned and healthy (available for any environment)
|
|
245
|
+
const unassignedHealthy = all.find(
|
|
246
|
+
(e) =>
|
|
247
|
+
e.assignedEnvironments.length === 0 &&
|
|
248
|
+
e.lastHealthStatus === "healthy",
|
|
249
|
+
);
|
|
250
|
+
if (unassignedHealthy) return unassignedHealthy;
|
|
251
|
+
|
|
252
|
+
// Unassigned (any status)
|
|
253
|
+
const unassigned = all.find((e) => e.assignedEnvironments.length === 0);
|
|
254
|
+
if (unassigned) return unassigned;
|
|
255
|
+
|
|
256
|
+
// Fallback: any envoy
|
|
257
|
+
return all[0];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Return cached registry entry without probing (instant).
|
|
262
|
+
*/
|
|
263
|
+
get(id: string): EnvoyRegistryEntry | undefined {
|
|
264
|
+
if (!this.store) return undefined;
|
|
265
|
+
const persisted = this.store.getById(id);
|
|
266
|
+
if (!persisted) return undefined;
|
|
267
|
+
const reg = fromPersisted(persisted);
|
|
268
|
+
const healthMap: Record<string, "OK" | "Degraded" | "Unreachable"> = {
|
|
269
|
+
healthy: "OK",
|
|
270
|
+
degraded: "Degraded",
|
|
271
|
+
unreachable: "Unreachable",
|
|
272
|
+
};
|
|
273
|
+
return {
|
|
274
|
+
...reg,
|
|
275
|
+
health: healthMap[reg.lastHealthStatus ?? "unreachable"] ?? "Unreachable",
|
|
276
|
+
hostname: reg.cachedHostname,
|
|
277
|
+
os: reg.cachedOs,
|
|
278
|
+
lastSeen: reg.lastHealthCheck,
|
|
279
|
+
summary: reg.cachedSummary,
|
|
280
|
+
readiness: reg.cachedReadiness,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Return all cached registry entries without probing (instant).
|
|
286
|
+
*/
|
|
287
|
+
listEntries(): EnvoyRegistryEntry[] {
|
|
288
|
+
return this.list().map((reg) => {
|
|
289
|
+
const healthMap: Record<string, "OK" | "Degraded" | "Unreachable"> = {
|
|
290
|
+
healthy: "OK",
|
|
291
|
+
degraded: "Degraded",
|
|
292
|
+
unreachable: "Unreachable",
|
|
293
|
+
};
|
|
294
|
+
return {
|
|
295
|
+
...reg,
|
|
296
|
+
health: healthMap[reg.lastHealthStatus ?? "unreachable"] ?? "Unreachable",
|
|
297
|
+
hostname: reg.cachedHostname,
|
|
298
|
+
os: reg.cachedOs,
|
|
299
|
+
lastSeen: reg.lastHealthCheck,
|
|
300
|
+
summary: reg.cachedSummary,
|
|
301
|
+
readiness: reg.cachedReadiness,
|
|
302
|
+
};
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Probe an Envoy's health and update its registry entry.
|
|
308
|
+
*/
|
|
309
|
+
async probe(id: string): Promise<EnvoyRegistryEntry | undefined> {
|
|
310
|
+
if (!this.store) return undefined;
|
|
311
|
+
const persisted = this.store.getById(id);
|
|
312
|
+
if (!persisted) return undefined;
|
|
313
|
+
const registration = fromPersisted(persisted);
|
|
314
|
+
|
|
315
|
+
const client = new EnvoyClient(registration.url, 5000);
|
|
316
|
+
try {
|
|
317
|
+
const health = await client.checkHealth();
|
|
318
|
+
const status = health.status === "healthy" ? "healthy" : "degraded";
|
|
319
|
+
const timestamp = new Date().toISOString();
|
|
320
|
+
|
|
321
|
+
this.store.updateCachedProbe(id, {
|
|
322
|
+
lastHealthCheck: timestamp,
|
|
323
|
+
lastHealthStatus: status,
|
|
324
|
+
cachedHostname: health.hostname,
|
|
325
|
+
cachedOs: health.os ?? null,
|
|
326
|
+
cachedSummary: health.summary,
|
|
327
|
+
cachedReadiness: health.readiness,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
...registration,
|
|
332
|
+
lastHealthCheck: timestamp,
|
|
333
|
+
lastHealthStatus: status,
|
|
334
|
+
cachedHostname: health.hostname,
|
|
335
|
+
cachedOs: health.os ?? null,
|
|
336
|
+
cachedSummary: health.summary,
|
|
337
|
+
cachedReadiness: health.readiness,
|
|
338
|
+
health: status === "healthy" ? "OK" : "Degraded",
|
|
339
|
+
hostname: health.hostname,
|
|
340
|
+
os: health.os ?? null,
|
|
341
|
+
lastSeen: health.timestamp,
|
|
342
|
+
summary: health.summary,
|
|
343
|
+
readiness: health.readiness,
|
|
344
|
+
};
|
|
345
|
+
} catch {
|
|
346
|
+
const timestamp = new Date().toISOString();
|
|
347
|
+
this.store.updateCachedProbe(id, {
|
|
348
|
+
lastHealthCheck: timestamp,
|
|
349
|
+
lastHealthStatus: "unreachable",
|
|
350
|
+
cachedHostname: null,
|
|
351
|
+
cachedOs: null,
|
|
352
|
+
cachedSummary: null,
|
|
353
|
+
cachedReadiness: null,
|
|
354
|
+
});
|
|
355
|
+
return {
|
|
356
|
+
...registration,
|
|
357
|
+
lastHealthCheck: timestamp,
|
|
358
|
+
lastHealthStatus: "unreachable",
|
|
359
|
+
cachedHostname: null,
|
|
360
|
+
cachedOs: null,
|
|
361
|
+
cachedSummary: null,
|
|
362
|
+
cachedReadiness: null,
|
|
363
|
+
health: "Unreachable",
|
|
364
|
+
hostname: null,
|
|
365
|
+
os: null,
|
|
366
|
+
lastSeen: null,
|
|
367
|
+
summary: null,
|
|
368
|
+
readiness: null,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Probe all registered Envoys and return their status.
|
|
375
|
+
*/
|
|
376
|
+
async probeAll(): Promise<EnvoyRegistryEntry[]> {
|
|
377
|
+
const entries: EnvoyRegistryEntry[] = [];
|
|
378
|
+
for (const envoy of this.list()) {
|
|
379
|
+
const entry = await this.probe(envoy.id);
|
|
380
|
+
if (entry) entries.push(entry);
|
|
381
|
+
}
|
|
382
|
+
return entries;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { serverLog, serverWarn, serverError } from "../logger.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Service health check abstraction.
|
|
5
|
+
*
|
|
6
|
+
* Injected into the Command Agent so tests can control health check outcomes
|
|
7
|
+
* without real network calls. In later phases, a real implementation will
|
|
8
|
+
* make HTTP calls to Envoy health endpoints.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface HealthCheckResult {
|
|
12
|
+
reachable: boolean;
|
|
13
|
+
responseTimeMs: number | null;
|
|
14
|
+
error: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ServiceHealthChecker {
|
|
18
|
+
/**
|
|
19
|
+
* Check if the target service/environment is healthy and reachable.
|
|
20
|
+
*/
|
|
21
|
+
check(
|
|
22
|
+
serviceId: string,
|
|
23
|
+
context: { partitionId: string; environmentName: string },
|
|
24
|
+
): Promise<HealthCheckResult>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default health checker — always reports healthy.
|
|
29
|
+
* Used in development and when no real infrastructure is connected.
|
|
30
|
+
*/
|
|
31
|
+
export class DefaultHealthChecker implements ServiceHealthChecker {
|
|
32
|
+
async check(): Promise<HealthCheckResult> {
|
|
33
|
+
return { reachable: true, responseTimeMs: 1, error: null };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Real health checker that makes HTTP requests to an Envoy health endpoint.
|
|
39
|
+
* Used when an Envoy URL is configured.
|
|
40
|
+
*/
|
|
41
|
+
export class EnvoyHealthChecker implements ServiceHealthChecker {
|
|
42
|
+
constructor(private readonly envoyUrl: string, private readonly timeoutMs = 5000) {}
|
|
43
|
+
|
|
44
|
+
async check(): Promise<HealthCheckResult> {
|
|
45
|
+
serverLog("HEALTH-CHECK", { url: this.envoyUrl });
|
|
46
|
+
const start = Date.now();
|
|
47
|
+
try {
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
50
|
+
const res = await fetch(`${this.envoyUrl}/health`, { signal: controller.signal });
|
|
51
|
+
clearTimeout(timeout);
|
|
52
|
+
const responseTimeMs = Date.now() - start;
|
|
53
|
+
|
|
54
|
+
if (res.ok) {
|
|
55
|
+
serverLog("HEALTH-OK", { url: this.envoyUrl, responseTimeMs });
|
|
56
|
+
return { reachable: true, responseTimeMs, error: null };
|
|
57
|
+
}
|
|
58
|
+
serverWarn("HEALTH-FAILED", { url: this.envoyUrl, status: res.status, responseTimeMs });
|
|
59
|
+
return { reachable: false, responseTimeMs, error: `HTTP ${res.status}` };
|
|
60
|
+
} catch (err) {
|
|
61
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
62
|
+
serverError("HEALTH-ERROR", { url: this.envoyUrl, error: message });
|
|
63
|
+
return {
|
|
64
|
+
reachable: false,
|
|
65
|
+
responseTimeMs: null,
|
|
66
|
+
error: message,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
3
|
+
import type { McpServerConfig } from "@synth-deploy/core";
|
|
4
|
+
|
|
5
|
+
export interface McpToolResult {
|
|
6
|
+
serverName: string;
|
|
7
|
+
toolName: string;
|
|
8
|
+
result: unknown;
|
|
9
|
+
error?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Manages connections to external MCP servers (monitoring, incident management,
|
|
14
|
+
* etc.) and exposes their tools/resources for pre-deployment checks and
|
|
15
|
+
* diagnostic investigations.
|
|
16
|
+
*
|
|
17
|
+
* Design principles:
|
|
18
|
+
* - Graceful degradation: unreachable servers are logged and skipped, never block deployments
|
|
19
|
+
* - All external data access must be recorded to the Debrief by the caller
|
|
20
|
+
* - Lightweight: if no servers are configured, the manager is a no-op
|
|
21
|
+
*/
|
|
22
|
+
export class McpClientManager {
|
|
23
|
+
private clients: Map<string, { client: Client; config: McpServerConfig }> =
|
|
24
|
+
new Map();
|
|
25
|
+
|
|
26
|
+
async connect(config: McpServerConfig): Promise<void> {
|
|
27
|
+
try {
|
|
28
|
+
const transport = new StreamableHTTPClientTransport(new URL(config.url));
|
|
29
|
+
const client = new Client({
|
|
30
|
+
name: "synth-server",
|
|
31
|
+
version: "0.1.0",
|
|
32
|
+
});
|
|
33
|
+
await client.connect(transport);
|
|
34
|
+
this.clients.set(config.name, { client, config });
|
|
35
|
+
} catch (error) {
|
|
36
|
+
// Graceful degradation -- log but don't throw
|
|
37
|
+
console.warn(
|
|
38
|
+
`[MCP] Failed to connect to ${config.name} at ${config.url}: ${error}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async connectAll(configs: McpServerConfig[]): Promise<void> {
|
|
44
|
+
await Promise.allSettled(configs.map((c) => this.connect(c)));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async listTools(): Promise<
|
|
48
|
+
Array<{ server: string; name: string; description?: string }>
|
|
49
|
+
> {
|
|
50
|
+
const tools: Array<{
|
|
51
|
+
server: string;
|
|
52
|
+
name: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
}> = [];
|
|
55
|
+
for (const [name, { client }] of this.clients) {
|
|
56
|
+
try {
|
|
57
|
+
const result = await client.listTools();
|
|
58
|
+
for (const tool of result.tools) {
|
|
59
|
+
tools.push({
|
|
60
|
+
server: name,
|
|
61
|
+
name: tool.name,
|
|
62
|
+
description: tool.description,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
// Server unreachable -- skip
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return tools;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async callTool(
|
|
73
|
+
serverName: string,
|
|
74
|
+
toolName: string,
|
|
75
|
+
args: Record<string, unknown>,
|
|
76
|
+
): Promise<McpToolResult> {
|
|
77
|
+
const entry = this.clients.get(serverName);
|
|
78
|
+
if (!entry) {
|
|
79
|
+
return {
|
|
80
|
+
serverName,
|
|
81
|
+
toolName,
|
|
82
|
+
result: null,
|
|
83
|
+
error: `Server '${serverName}' not connected`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const result = await entry.client.callTool({
|
|
88
|
+
name: toolName,
|
|
89
|
+
arguments: args,
|
|
90
|
+
});
|
|
91
|
+
return { serverName, toolName, result: result.content };
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return { serverName, toolName, result: null, error: String(error) };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async listResources(
|
|
98
|
+
serverName: string,
|
|
99
|
+
): Promise<Array<{ uri: string; name?: string }>> {
|
|
100
|
+
const entry = this.clients.get(serverName);
|
|
101
|
+
if (!entry) return [];
|
|
102
|
+
try {
|
|
103
|
+
const result = await entry.client.listResources();
|
|
104
|
+
return result.resources.map((r) => ({ uri: r.uri, name: r.name }));
|
|
105
|
+
} catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
getConnectedServers(): string[] {
|
|
111
|
+
return Array.from(this.clients.keys());
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async disconnect(serverName: string): Promise<void> {
|
|
115
|
+
const entry = this.clients.get(serverName);
|
|
116
|
+
if (entry) {
|
|
117
|
+
try {
|
|
118
|
+
await entry.client.close();
|
|
119
|
+
} catch {
|
|
120
|
+
/* ignore */
|
|
121
|
+
}
|
|
122
|
+
this.clients.delete(serverName);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async disconnectAll(): Promise<void> {
|
|
127
|
+
for (const name of this.clients.keys()) {
|
|
128
|
+
await this.disconnect(name);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { DebriefWriter } from "@synth-deploy/core";
|
|
2
|
+
import type { DeploymentStore } from "./synth-agent.js";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_STALE_THRESHOLD_MS = Number(
|
|
5
|
+
process.env.SYNTH_STALE_DEPLOYMENT_TIMEOUT_MS ?? 30 * 60 * 1000, // 30 minutes
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
const DEFAULT_SCAN_INTERVAL_MS = Number(
|
|
9
|
+
process.env.SYNTH_STALE_SCAN_INTERVAL_MS ?? 60 * 1000, // 1 minute
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Scans for deployments stuck in "running" status beyond the stale threshold
|
|
14
|
+
* and marks them as failed with a clear explanation.
|
|
15
|
+
*
|
|
16
|
+
* Returns the number of deployments marked as stale.
|
|
17
|
+
*/
|
|
18
|
+
export function markStaleDeployments(
|
|
19
|
+
deployments: DeploymentStore,
|
|
20
|
+
debrief: DebriefWriter,
|
|
21
|
+
thresholdMs: number = DEFAULT_STALE_THRESHOLD_MS,
|
|
22
|
+
): number {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const stale = deployments.list().filter(
|
|
25
|
+
(d) =>
|
|
26
|
+
d.status === "running" &&
|
|
27
|
+
now - new Date(d.createdAt).getTime() > thresholdMs,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
for (const deployment of stale) {
|
|
31
|
+
deployment.status = "failed";
|
|
32
|
+
deployments.save(deployment);
|
|
33
|
+
|
|
34
|
+
const staleDurationMin = Math.round(
|
|
35
|
+
(now - new Date(deployment.createdAt).getTime()) / 60_000,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
debrief.record({
|
|
39
|
+
partitionId: deployment.partitionId ?? null,
|
|
40
|
+
deploymentId: deployment.id,
|
|
41
|
+
agent: "server",
|
|
42
|
+
decisionType: "deployment-failure",
|
|
43
|
+
decision: `Marked deployment as failed: exceeded ${Math.round(thresholdMs / 60_000)} minute stale threshold`,
|
|
44
|
+
reasoning:
|
|
45
|
+
`Deployment ${deployment.id.slice(0, 8)} has been in "running" status for ${staleDurationMin} minutes ` +
|
|
46
|
+
`without receiving a completion report. This typically indicates Command lost connection to the Envoy ` +
|
|
47
|
+
`or the Envoy process crashed during execution. The deployment has been marked as failed to prevent ` +
|
|
48
|
+
`indefinite "running" status.`,
|
|
49
|
+
context: {
|
|
50
|
+
deploymentId: deployment.id,
|
|
51
|
+
partitionId: deployment.partitionId ?? null,
|
|
52
|
+
staleDurationMinutes: staleDurationMin,
|
|
53
|
+
thresholdMinutes: Math.round(thresholdMs / 60_000),
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return stale.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Starts a periodic scan for stale deployments.
|
|
63
|
+
* Returns a cleanup function to stop the interval.
|
|
64
|
+
*/
|
|
65
|
+
export function startStaleDeploymentScanner(
|
|
66
|
+
deployments: DeploymentStore,
|
|
67
|
+
debrief: DebriefWriter,
|
|
68
|
+
intervalMs: number = DEFAULT_SCAN_INTERVAL_MS,
|
|
69
|
+
thresholdMs: number = DEFAULT_STALE_THRESHOLD_MS,
|
|
70
|
+
): () => void {
|
|
71
|
+
const timer = setInterval(() => {
|
|
72
|
+
const count = markStaleDeployments(deployments, debrief, thresholdMs);
|
|
73
|
+
if (count > 0) {
|
|
74
|
+
console.log(`[stale-detector] Marked ${count} stale deployment(s) as failed`);
|
|
75
|
+
}
|
|
76
|
+
}, intervalMs);
|
|
77
|
+
|
|
78
|
+
return () => clearInterval(timer);
|
|
79
|
+
}
|