@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.
Files changed (317) hide show
  1. package/dist/agent/debrief-retention.d.ts +12 -0
  2. package/dist/agent/debrief-retention.d.ts.map +1 -0
  3. package/dist/agent/debrief-retention.js +27 -0
  4. package/dist/agent/debrief-retention.js.map +1 -0
  5. package/dist/agent/envoy-client.d.ts +216 -0
  6. package/dist/agent/envoy-client.d.ts.map +1 -0
  7. package/dist/agent/envoy-client.js +266 -0
  8. package/dist/agent/envoy-client.js.map +1 -0
  9. package/dist/agent/envoy-registry.d.ts +102 -0
  10. package/dist/agent/envoy-registry.d.ts.map +1 -0
  11. package/dist/agent/envoy-registry.js +319 -0
  12. package/dist/agent/envoy-registry.js.map +1 -0
  13. package/dist/agent/health-checker.d.ts +39 -0
  14. package/dist/agent/health-checker.d.ts.map +1 -0
  15. package/dist/agent/health-checker.js +49 -0
  16. package/dist/agent/health-checker.js.map +1 -0
  17. package/dist/agent/mcp-client-manager.d.ts +36 -0
  18. package/dist/agent/mcp-client-manager.d.ts.map +1 -0
  19. package/dist/agent/mcp-client-manager.js +106 -0
  20. package/dist/agent/mcp-client-manager.js.map +1 -0
  21. package/dist/agent/stale-deployment-detector.d.ts +15 -0
  22. package/dist/agent/stale-deployment-detector.d.ts.map +1 -0
  23. package/dist/agent/stale-deployment-detector.js +50 -0
  24. package/dist/agent/stale-deployment-detector.js.map +1 -0
  25. package/dist/agent/step-runner.d.ts +31 -0
  26. package/dist/agent/step-runner.d.ts.map +1 -0
  27. package/dist/agent/step-runner.js +80 -0
  28. package/dist/agent/step-runner.js.map +1 -0
  29. package/dist/agent/synth-agent.d.ts +168 -0
  30. package/dist/agent/synth-agent.d.ts.map +1 -0
  31. package/dist/agent/synth-agent.js +1195 -0
  32. package/dist/agent/synth-agent.js.map +1 -0
  33. package/dist/api/agent.d.ts +36 -0
  34. package/dist/api/agent.d.ts.map +1 -0
  35. package/dist/api/agent.js +867 -0
  36. package/dist/api/agent.js.map +1 -0
  37. package/dist/api/api-keys.d.ts +4 -0
  38. package/dist/api/api-keys.d.ts.map +1 -0
  39. package/dist/api/api-keys.js +118 -0
  40. package/dist/api/api-keys.js.map +1 -0
  41. package/dist/api/artifacts.d.ts +5 -0
  42. package/dist/api/artifacts.d.ts.map +1 -0
  43. package/dist/api/artifacts.js +142 -0
  44. package/dist/api/artifacts.js.map +1 -0
  45. package/dist/api/auth.d.ts +4 -0
  46. package/dist/api/auth.d.ts.map +1 -0
  47. package/dist/api/auth.js +280 -0
  48. package/dist/api/auth.js.map +1 -0
  49. package/dist/api/deployments.d.ts +11 -0
  50. package/dist/api/deployments.d.ts.map +1 -0
  51. package/dist/api/deployments.js +1098 -0
  52. package/dist/api/deployments.js.map +1 -0
  53. package/dist/api/environments.d.ts +5 -0
  54. package/dist/api/environments.d.ts.map +1 -0
  55. package/dist/api/environments.js +69 -0
  56. package/dist/api/environments.js.map +1 -0
  57. package/dist/api/envoy-reports.d.ts +17 -0
  58. package/dist/api/envoy-reports.d.ts.map +1 -0
  59. package/dist/api/envoy-reports.js +138 -0
  60. package/dist/api/envoy-reports.js.map +1 -0
  61. package/dist/api/envoys.d.ts +5 -0
  62. package/dist/api/envoys.d.ts.map +1 -0
  63. package/dist/api/envoys.js +192 -0
  64. package/dist/api/envoys.js.map +1 -0
  65. package/dist/api/fleet.d.ts +11 -0
  66. package/dist/api/fleet.d.ts.map +1 -0
  67. package/dist/api/fleet.js +394 -0
  68. package/dist/api/fleet.js.map +1 -0
  69. package/dist/api/graph.d.ts +8 -0
  70. package/dist/api/graph.d.ts.map +1 -0
  71. package/dist/api/graph.js +355 -0
  72. package/dist/api/graph.js.map +1 -0
  73. package/dist/api/health.d.ts +20 -0
  74. package/dist/api/health.d.ts.map +1 -0
  75. package/dist/api/health.js +248 -0
  76. package/dist/api/health.js.map +1 -0
  77. package/dist/api/idp-schemas.d.ts +41 -0
  78. package/dist/api/idp-schemas.d.ts.map +1 -0
  79. package/dist/api/idp-schemas.js +17 -0
  80. package/dist/api/idp-schemas.js.map +1 -0
  81. package/dist/api/idp.d.ts +6 -0
  82. package/dist/api/idp.d.ts.map +1 -0
  83. package/dist/api/idp.js +620 -0
  84. package/dist/api/idp.js.map +1 -0
  85. package/dist/api/intake.d.ts +10 -0
  86. package/dist/api/intake.d.ts.map +1 -0
  87. package/dist/api/intake.js +418 -0
  88. package/dist/api/intake.js.map +1 -0
  89. package/dist/api/partitions.d.ts +5 -0
  90. package/dist/api/partitions.d.ts.map +1 -0
  91. package/dist/api/partitions.js +113 -0
  92. package/dist/api/partitions.js.map +1 -0
  93. package/dist/api/progress-event-store.d.ts +62 -0
  94. package/dist/api/progress-event-store.d.ts.map +1 -0
  95. package/dist/api/progress-event-store.js +118 -0
  96. package/dist/api/progress-event-store.js.map +1 -0
  97. package/dist/api/schemas.d.ts +1000 -0
  98. package/dist/api/schemas.d.ts.map +1 -0
  99. package/dist/api/schemas.js +328 -0
  100. package/dist/api/schemas.js.map +1 -0
  101. package/dist/api/security-boundaries.d.ts +4 -0
  102. package/dist/api/security-boundaries.d.ts.map +1 -0
  103. package/dist/api/security-boundaries.js +32 -0
  104. package/dist/api/security-boundaries.js.map +1 -0
  105. package/dist/api/settings.d.ts +4 -0
  106. package/dist/api/settings.d.ts.map +1 -0
  107. package/dist/api/settings.js +99 -0
  108. package/dist/api/settings.js.map +1 -0
  109. package/dist/api/system.d.ts +75 -0
  110. package/dist/api/system.d.ts.map +1 -0
  111. package/dist/api/system.js +558 -0
  112. package/dist/api/system.js.map +1 -0
  113. package/dist/api/telemetry.d.ts +4 -0
  114. package/dist/api/telemetry.d.ts.map +1 -0
  115. package/dist/api/telemetry.js +24 -0
  116. package/dist/api/telemetry.js.map +1 -0
  117. package/dist/api/users.d.ts +4 -0
  118. package/dist/api/users.d.ts.map +1 -0
  119. package/dist/api/users.js +173 -0
  120. package/dist/api/users.js.map +1 -0
  121. package/dist/archive-unpacker.d.ts +24 -0
  122. package/dist/archive-unpacker.d.ts.map +1 -0
  123. package/dist/archive-unpacker.js +239 -0
  124. package/dist/archive-unpacker.js.map +1 -0
  125. package/dist/artifact-analyzer.d.ts +59 -0
  126. package/dist/artifact-analyzer.d.ts.map +1 -0
  127. package/dist/artifact-analyzer.js +334 -0
  128. package/dist/artifact-analyzer.js.map +1 -0
  129. package/dist/auth/idp/index.d.ts +9 -0
  130. package/dist/auth/idp/index.d.ts.map +1 -0
  131. package/dist/auth/idp/index.js +5 -0
  132. package/dist/auth/idp/index.js.map +1 -0
  133. package/dist/auth/idp/ldap.d.ts +56 -0
  134. package/dist/auth/idp/ldap.d.ts.map +1 -0
  135. package/dist/auth/idp/ldap.js +276 -0
  136. package/dist/auth/idp/ldap.js.map +1 -0
  137. package/dist/auth/idp/oidc.d.ts +27 -0
  138. package/dist/auth/idp/oidc.d.ts.map +1 -0
  139. package/dist/auth/idp/oidc.js +97 -0
  140. package/dist/auth/idp/oidc.js.map +1 -0
  141. package/dist/auth/idp/role-mapping.d.ts +9 -0
  142. package/dist/auth/idp/role-mapping.d.ts.map +1 -0
  143. package/dist/auth/idp/role-mapping.js +16 -0
  144. package/dist/auth/idp/role-mapping.js.map +1 -0
  145. package/dist/auth/idp/saml.d.ts +40 -0
  146. package/dist/auth/idp/saml.d.ts.map +1 -0
  147. package/dist/auth/idp/saml.js +117 -0
  148. package/dist/auth/idp/saml.js.map +1 -0
  149. package/dist/auth/idp/types.d.ts +23 -0
  150. package/dist/auth/idp/types.d.ts.map +1 -0
  151. package/dist/auth/idp/types.js +2 -0
  152. package/dist/auth/idp/types.js.map +1 -0
  153. package/dist/fleet/fleet-executor.d.ts +35 -0
  154. package/dist/fleet/fleet-executor.d.ts.map +1 -0
  155. package/dist/fleet/fleet-executor.js +228 -0
  156. package/dist/fleet/fleet-executor.js.map +1 -0
  157. package/dist/fleet/fleet-store.d.ts +13 -0
  158. package/dist/fleet/fleet-store.d.ts.map +1 -0
  159. package/dist/fleet/fleet-store.js +13 -0
  160. package/dist/fleet/fleet-store.js.map +1 -0
  161. package/dist/fleet/index.d.ts +5 -0
  162. package/dist/fleet/index.d.ts.map +1 -0
  163. package/dist/fleet/index.js +4 -0
  164. package/dist/fleet/index.js.map +1 -0
  165. package/dist/fleet/representative-selector.d.ts +15 -0
  166. package/dist/fleet/representative-selector.d.ts.map +1 -0
  167. package/dist/fleet/representative-selector.js +71 -0
  168. package/dist/fleet/representative-selector.js.map +1 -0
  169. package/dist/graph/graph-executor.d.ts +36 -0
  170. package/dist/graph/graph-executor.d.ts.map +1 -0
  171. package/dist/graph/graph-executor.js +348 -0
  172. package/dist/graph/graph-executor.js.map +1 -0
  173. package/dist/graph/graph-inference.d.ts +22 -0
  174. package/dist/graph/graph-inference.d.ts.map +1 -0
  175. package/dist/graph/graph-inference.js +149 -0
  176. package/dist/graph/graph-inference.js.map +1 -0
  177. package/dist/graph/graph-store.d.ts +12 -0
  178. package/dist/graph/graph-store.d.ts.map +1 -0
  179. package/dist/graph/graph-store.js +61 -0
  180. package/dist/graph/graph-store.js.map +1 -0
  181. package/dist/graph/index.d.ts +5 -0
  182. package/dist/graph/index.d.ts.map +1 -0
  183. package/dist/graph/index.js +4 -0
  184. package/dist/graph/index.js.map +1 -0
  185. package/dist/index.d.ts +2 -0
  186. package/dist/index.d.ts.map +1 -0
  187. package/dist/index.js +837 -0
  188. package/dist/index.js.map +1 -0
  189. package/dist/intake/index.d.ts +6 -0
  190. package/dist/intake/index.d.ts.map +1 -0
  191. package/dist/intake/index.js +5 -0
  192. package/dist/intake/index.js.map +1 -0
  193. package/dist/intake/intake-processor.d.ts +17 -0
  194. package/dist/intake/intake-processor.d.ts.map +1 -0
  195. package/dist/intake/intake-processor.js +99 -0
  196. package/dist/intake/intake-processor.js.map +1 -0
  197. package/dist/intake/intake-store.d.ts +7 -0
  198. package/dist/intake/intake-store.d.ts.map +1 -0
  199. package/dist/intake/intake-store.js +7 -0
  200. package/dist/intake/intake-store.js.map +1 -0
  201. package/dist/intake/registry-poller.d.ts +41 -0
  202. package/dist/intake/registry-poller.d.ts.map +1 -0
  203. package/dist/intake/registry-poller.js +202 -0
  204. package/dist/intake/registry-poller.js.map +1 -0
  205. package/dist/intake/webhook-handlers.d.ts +37 -0
  206. package/dist/intake/webhook-handlers.d.ts.map +1 -0
  207. package/dist/intake/webhook-handlers.js +268 -0
  208. package/dist/intake/webhook-handlers.js.map +1 -0
  209. package/dist/logger.d.ts +5 -0
  210. package/dist/logger.d.ts.map +1 -0
  211. package/dist/logger.js +15 -0
  212. package/dist/logger.js.map +1 -0
  213. package/dist/mcp/resources.d.ts +9 -0
  214. package/dist/mcp/resources.d.ts.map +1 -0
  215. package/dist/mcp/resources.js +72 -0
  216. package/dist/mcp/resources.js.map +1 -0
  217. package/dist/mcp/server.d.ts +15 -0
  218. package/dist/mcp/server.d.ts.map +1 -0
  219. package/dist/mcp/server.js +20 -0
  220. package/dist/mcp/server.js.map +1 -0
  221. package/dist/mcp/tools.d.ts +9 -0
  222. package/dist/mcp/tools.d.ts.map +1 -0
  223. package/dist/mcp/tools.js +88 -0
  224. package/dist/mcp/tools.js.map +1 -0
  225. package/dist/middleware/auth.d.ts +29 -0
  226. package/dist/middleware/auth.d.ts.map +1 -0
  227. package/dist/middleware/auth.js +76 -0
  228. package/dist/middleware/auth.js.map +1 -0
  229. package/dist/middleware/permissions.d.ts +13 -0
  230. package/dist/middleware/permissions.d.ts.map +1 -0
  231. package/dist/middleware/permissions.js +32 -0
  232. package/dist/middleware/permissions.js.map +1 -0
  233. package/dist/pattern-store.d.ts +104 -0
  234. package/dist/pattern-store.d.ts.map +1 -0
  235. package/dist/pattern-store.js +299 -0
  236. package/dist/pattern-store.js.map +1 -0
  237. package/package.json +54 -0
  238. package/src/agent/debrief-retention.ts +44 -0
  239. package/src/agent/envoy-client.ts +474 -0
  240. package/src/agent/envoy-registry.ts +384 -0
  241. package/src/agent/health-checker.ts +70 -0
  242. package/src/agent/mcp-client-manager.ts +131 -0
  243. package/src/agent/stale-deployment-detector.ts +79 -0
  244. package/src/agent/step-runner.ts +124 -0
  245. package/src/agent/synth-agent.ts +1567 -0
  246. package/src/api/agent.ts +1075 -0
  247. package/src/api/api-keys.ts +129 -0
  248. package/src/api/artifacts.ts +194 -0
  249. package/src/api/auth.ts +320 -0
  250. package/src/api/deployments.ts +1347 -0
  251. package/src/api/environments.ts +97 -0
  252. package/src/api/envoy-reports.ts +159 -0
  253. package/src/api/envoys.ts +237 -0
  254. package/src/api/fleet.ts +510 -0
  255. package/src/api/graph.ts +516 -0
  256. package/src/api/health.ts +311 -0
  257. package/src/api/idp-schemas.ts +19 -0
  258. package/src/api/idp.ts +735 -0
  259. package/src/api/intake.ts +537 -0
  260. package/src/api/partitions.ts +147 -0
  261. package/src/api/progress-event-store.ts +153 -0
  262. package/src/api/schemas.ts +376 -0
  263. package/src/api/security-boundaries.ts +54 -0
  264. package/src/api/settings.ts +118 -0
  265. package/src/api/system.ts +704 -0
  266. package/src/api/telemetry.ts +32 -0
  267. package/src/api/users.ts +210 -0
  268. package/src/archive-unpacker.ts +271 -0
  269. package/src/artifact-analyzer.ts +438 -0
  270. package/src/auth/idp/index.ts +8 -0
  271. package/src/auth/idp/ldap.ts +340 -0
  272. package/src/auth/idp/oidc.ts +117 -0
  273. package/src/auth/idp/role-mapping.ts +22 -0
  274. package/src/auth/idp/saml.ts +148 -0
  275. package/src/auth/idp/types.ts +22 -0
  276. package/src/fleet/fleet-executor.ts +309 -0
  277. package/src/fleet/fleet-store.ts +13 -0
  278. package/src/fleet/index.ts +4 -0
  279. package/src/fleet/representative-selector.ts +83 -0
  280. package/src/graph/graph-executor.ts +446 -0
  281. package/src/graph/graph-inference.ts +184 -0
  282. package/src/graph/graph-store.ts +75 -0
  283. package/src/graph/index.ts +4 -0
  284. package/src/index.ts +916 -0
  285. package/src/intake/index.ts +5 -0
  286. package/src/intake/intake-processor.ts +111 -0
  287. package/src/intake/intake-store.ts +7 -0
  288. package/src/intake/registry-poller.ts +230 -0
  289. package/src/intake/webhook-handlers.ts +328 -0
  290. package/src/logger.ts +19 -0
  291. package/src/mcp/resources.ts +98 -0
  292. package/src/mcp/server.ts +34 -0
  293. package/src/mcp/tools.ts +117 -0
  294. package/src/middleware/auth.ts +103 -0
  295. package/src/middleware/permissions.ts +35 -0
  296. package/src/pattern-store.ts +409 -0
  297. package/tests/agent-mode.test.ts +536 -0
  298. package/tests/api-handlers.test.ts +1245 -0
  299. package/tests/archive-unpacker.test.ts +179 -0
  300. package/tests/artifact-analyzer.test.ts +240 -0
  301. package/tests/auth-middleware.test.ts +189 -0
  302. package/tests/decision-diary.test.ts +957 -0
  303. package/tests/diary-reader.test.ts +782 -0
  304. package/tests/envoy-client.test.ts +342 -0
  305. package/tests/envoy-reports.test.ts +156 -0
  306. package/tests/mcp-tools.test.ts +213 -0
  307. package/tests/orchestration.test.ts +536 -0
  308. package/tests/partition-deletion.test.ts +143 -0
  309. package/tests/partition-isolation.test.ts +830 -0
  310. package/tests/pattern-store.test.ts +371 -0
  311. package/tests/rbac-enforcement.test.ts +409 -0
  312. package/tests/ssrf-validation.test.ts +56 -0
  313. package/tests/stale-deployment.test.ts +85 -0
  314. package/tests/step-runner.test.ts +308 -0
  315. package/tests/ui-journey.test.ts +330 -0
  316. package/tsconfig.json +11 -0
  317. package/vitest.config.ts +27 -0
@@ -0,0 +1,342 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { EnvoyClient, EnvoyHealthChecker } from "../src/agent/envoy-client.js";
3
+ import type { EnvoyHealthResponse } from "../src/agent/envoy-client.js";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Shared helpers
7
+ // ---------------------------------------------------------------------------
8
+
9
+ function makeHealthResponse(overrides: Partial<EnvoyHealthResponse> = {}): EnvoyHealthResponse {
10
+ return {
11
+ status: "healthy",
12
+ service: "envoy",
13
+ hostname: "test-host",
14
+ timestamp: new Date().toISOString(),
15
+ readiness: { ready: true, reason: "ok" },
16
+ summary: { totalDeployments: 5, succeeded: 4, failed: 1, executing: 0, environments: 2 },
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ const originalFetch = globalThis.fetch;
22
+
23
+ afterEach(() => {
24
+ globalThis.fetch = originalFetch;
25
+ vi.restoreAllMocks();
26
+ });
27
+
28
+ // ===========================================================================
29
+ // EnvoyClient retry logic
30
+ // ===========================================================================
31
+
32
+ describe("EnvoyClient retry logic", () => {
33
+ it("succeeds on first attempt without retry", async () => {
34
+ const mockFetch = vi.fn().mockResolvedValueOnce(
35
+ new Response(JSON.stringify(makeHealthResponse()), { status: 200 }),
36
+ );
37
+ globalThis.fetch = mockFetch;
38
+
39
+ const client = new EnvoyClient("http://envoy.test:8080", 5000);
40
+ const result = await client.checkHealth();
41
+
42
+ expect(result.status).toBe("healthy");
43
+ expect(mockFetch).toHaveBeenCalledTimes(1);
44
+ });
45
+
46
+ it("retries on connection error and succeeds", async () => {
47
+ const mockFetch = vi.fn()
48
+ .mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED"))
49
+ .mockResolvedValueOnce(new Response(JSON.stringify(makeHealthResponse()), { status: 200 }));
50
+ globalThis.fetch = mockFetch;
51
+
52
+ const client = new EnvoyClient("http://envoy.test:8080", 5000);
53
+ const result = await client.checkHealth();
54
+
55
+ expect(result.status).toBe("healthy");
56
+ expect(mockFetch).toHaveBeenCalledTimes(2);
57
+ });
58
+
59
+ it("retries on 503 and succeeds", async () => {
60
+ const mockFetch = vi.fn()
61
+ .mockResolvedValueOnce(new Response("Service Unavailable", { status: 503 }))
62
+ .mockResolvedValueOnce(new Response(JSON.stringify(makeHealthResponse()), { status: 200 }));
63
+ globalThis.fetch = mockFetch;
64
+
65
+ const client = new EnvoyClient("http://envoy.test:8080", 5000);
66
+ const result = await client.checkHealth();
67
+
68
+ expect(result.status).toBe("healthy");
69
+ expect(mockFetch).toHaveBeenCalledTimes(2);
70
+ });
71
+
72
+ it("does not retry on 400 (permanent error)", async () => {
73
+ const mockFetch = vi.fn()
74
+ .mockResolvedValueOnce(new Response("Bad Request", { status: 400 }));
75
+ globalThis.fetch = mockFetch;
76
+
77
+ const client = new EnvoyClient("http://envoy.test:8080", 5000);
78
+
79
+ await expect(client.checkHealth()).rejects.toThrow("HTTP 400");
80
+ expect(mockFetch).toHaveBeenCalledTimes(1);
81
+ });
82
+ });
83
+
84
+ // ===========================================================================
85
+ // EnvoyClient.checkHealth — detailed scenarios
86
+ // ===========================================================================
87
+
88
+ describe("EnvoyClient.checkHealth", () => {
89
+ it("returns full health response with all fields", async () => {
90
+ const expected = makeHealthResponse({
91
+ summary: { totalDeployments: 10, succeeded: 8, failed: 2, executing: 0, environments: 3 },
92
+ });
93
+ globalThis.fetch = vi.fn().mockResolvedValueOnce(
94
+ new Response(JSON.stringify(expected), { status: 200 }),
95
+ );
96
+
97
+ const client = new EnvoyClient("http://envoy.test:8080", 5000);
98
+ const result = await client.checkHealth();
99
+
100
+ expect(result.status).toBe("healthy");
101
+ expect(result.service).toBe("envoy");
102
+ expect(result.hostname).toBe("test-host");
103
+ expect(result.readiness.ready).toBe(true);
104
+ expect(result.summary.totalDeployments).toBe(10);
105
+ expect(result.summary.succeeded).toBe(8);
106
+ expect(result.summary.environments).toBe(3);
107
+ });
108
+
109
+ it("throws on non-retryable HTTP error without retrying", async () => {
110
+ const mockFetch = vi.fn().mockResolvedValueOnce(
111
+ new Response("Internal Server Error", { status: 500 }),
112
+ );
113
+ globalThis.fetch = mockFetch;
114
+
115
+ const client = new EnvoyClient("http://envoy.test:8080", 5000);
116
+ await expect(client.checkHealth()).rejects.toThrow("HTTP 500");
117
+ // 500 is not in the retryable set (502, 503, 504), so no retry
118
+ expect(mockFetch).toHaveBeenCalledTimes(1);
119
+ });
120
+
121
+ it("throws on malformed JSON response", async () => {
122
+ globalThis.fetch = vi.fn().mockResolvedValueOnce(
123
+ new Response("this is not json", { status: 200, headers: { "Content-Type": "text/plain" } }),
124
+ );
125
+
126
+ const client = new EnvoyClient("http://envoy.test:8080", 5000);
127
+ // response.json() will throw a SyntaxError on invalid JSON
128
+ await expect(client.checkHealth()).rejects.toThrow();
129
+ });
130
+
131
+ it("throws on non-transient error without retrying", async () => {
132
+ // Errors that do NOT match isTransientError patterns are thrown immediately
133
+ const mockFetch = vi.fn().mockRejectedValueOnce(new Error("certificate expired"));
134
+ globalThis.fetch = mockFetch;
135
+
136
+ const client = new EnvoyClient("http://envoy.test:8080", 5000);
137
+ await expect(client.checkHealth()).rejects.toThrow("certificate expired");
138
+ // Non-transient error should not trigger retries
139
+ expect(mockFetch).toHaveBeenCalledTimes(1);
140
+ });
141
+
142
+ it("calls the correct URL for health endpoint", async () => {
143
+ const mockFetch = vi.fn().mockResolvedValueOnce(
144
+ new Response(JSON.stringify(makeHealthResponse()), { status: 200 }),
145
+ );
146
+ globalThis.fetch = mockFetch;
147
+
148
+ const client = new EnvoyClient("http://my-envoy:9090", 5000);
149
+ await client.checkHealth();
150
+
151
+ const [url] = mockFetch.mock.calls[0];
152
+ expect(url).toBe("http://my-envoy:9090/health");
153
+ });
154
+ });
155
+
156
+ // ===========================================================================
157
+ // EnvoyClient.deploy
158
+ // ===========================================================================
159
+
160
+ describe("EnvoyClient.deploy", () => {
161
+ const deployInstruction = {
162
+ deploymentId: "dep-1",
163
+ partitionId: "part-1",
164
+ environmentId: "env-1",
165
+ operationId: "op-1",
166
+ version: "1.0.0",
167
+ variables: { DB_HOST: "localhost" },
168
+ environmentName: "staging",
169
+ partitionName: "acme-corp",
170
+ };
171
+
172
+ it("sends POST with JSON body and returns deploy result", async () => {
173
+ const deployResult = {
174
+ deploymentId: "dep-1",
175
+ success: true,
176
+ workspacePath: "/tmp/deploy",
177
+ artifacts: ["artifact.tar.gz"],
178
+ executionDurationMs: 500,
179
+ totalDurationMs: 800,
180
+ verificationPassed: true,
181
+ verificationChecks: [],
182
+ failureReason: null,
183
+ debriefEntryIds: ["entry-1"],
184
+ debriefEntries: [],
185
+ };
186
+
187
+ const mockFetch = vi.fn().mockResolvedValueOnce(
188
+ new Response(JSON.stringify(deployResult), { status: 200 }),
189
+ );
190
+ globalThis.fetch = mockFetch;
191
+
192
+ const client = new EnvoyClient("http://envoy.test:8080", 5000);
193
+ const result = await client.deploy(deployInstruction);
194
+
195
+ expect(result.deploymentId).toBe("dep-1");
196
+ expect(result.success).toBe(true);
197
+ expect(result.workspacePath).toBe("/tmp/deploy");
198
+
199
+ // Verify the fetch was called with correct URL and method
200
+ expect(mockFetch).toHaveBeenCalledTimes(1);
201
+ const [url, init] = mockFetch.mock.calls[0];
202
+ expect(url).toBe("http://envoy.test:8080/deploy");
203
+ expect(init.method).toBe("POST");
204
+ expect(init.headers).toEqual({ "Content-Type": "application/json" });
205
+ expect(JSON.parse(init.body as string)).toEqual(deployInstruction);
206
+ });
207
+
208
+ it("uses 3x the base timeout for deploy calls", async () => {
209
+ const deployResult = {
210
+ deploymentId: "dep-1",
211
+ success: true,
212
+ workspacePath: "/tmp/deploy",
213
+ artifacts: [],
214
+ executionDurationMs: 100,
215
+ totalDurationMs: 200,
216
+ verificationPassed: true,
217
+ verificationChecks: [],
218
+ failureReason: null,
219
+ debriefEntryIds: [],
220
+ debriefEntries: [],
221
+ };
222
+
223
+ // Track the abort signal to verify timeout is set
224
+ let capturedSignal: AbortSignal | undefined;
225
+ const mockFetch = vi.fn().mockImplementation((_url: string, init: RequestInit) => {
226
+ capturedSignal = init.signal ?? undefined;
227
+ return Promise.resolve(new Response(JSON.stringify(deployResult), { status: 200 }));
228
+ });
229
+ globalThis.fetch = mockFetch;
230
+
231
+ const client = new EnvoyClient("http://envoy.test:8080", 5000);
232
+ await client.deploy(deployInstruction);
233
+
234
+ // The signal should exist (fetchWithRetry always creates one)
235
+ expect(capturedSignal).toBeDefined();
236
+ });
237
+ });
238
+
239
+ // ===========================================================================
240
+ // EnvoyHealthChecker — adapter for ServiceHealthChecker interface
241
+ //
242
+ // These tests mock EnvoyClient.checkHealth directly to avoid going through
243
+ // the retry/fetch layer, which would introduce real sleep delays.
244
+ // ===========================================================================
245
+
246
+ describe("EnvoyHealthChecker", () => {
247
+ const context = { partitionId: "part-1", environmentName: "staging" };
248
+
249
+ it("returns healthy when no envoy is registered for serviceId", async () => {
250
+ const checker = new EnvoyHealthChecker();
251
+ const result = await checker.check("op-1/staging", context);
252
+
253
+ expect(result.reachable).toBe(true);
254
+ expect(result.responseTimeMs).toBe(0);
255
+ expect(result.error).toBeNull();
256
+ });
257
+
258
+ it("returns healthy when registered envoy reports healthy", async () => {
259
+ const checker = new EnvoyHealthChecker();
260
+ const envoyClient = new EnvoyClient("http://envoy.test:8080", 5000);
261
+ vi.spyOn(envoyClient, "checkHealth").mockResolvedValueOnce(makeHealthResponse());
262
+ checker.registerEnvoy("op-1/staging", envoyClient);
263
+
264
+ const result = await checker.check("op-1/staging", context);
265
+ expect(result.reachable).toBe(true);
266
+ expect(result.error).toBeNull();
267
+ expect(typeof result.responseTimeMs).toBe("number");
268
+ });
269
+
270
+ it("returns not reachable when envoy reports degraded and not ready", async () => {
271
+ const checker = new EnvoyHealthChecker();
272
+ const envoyClient = new EnvoyClient("http://envoy.test:8080", 5000);
273
+ vi.spyOn(envoyClient, "checkHealth").mockResolvedValueOnce(
274
+ makeHealthResponse({
275
+ status: "degraded",
276
+ readiness: { ready: false, reason: "disk full" },
277
+ }),
278
+ );
279
+ checker.registerEnvoy("op-1/staging", envoyClient);
280
+
281
+ const result = await checker.check("op-1/staging", context);
282
+ expect(result.reachable).toBe(false);
283
+ expect(result.error).toContain("degraded");
284
+ expect(result.error).toContain("disk full");
285
+ });
286
+
287
+ it("returns ETIMEDOUT error when envoy health check is aborted", async () => {
288
+ const checker = new EnvoyHealthChecker();
289
+ const envoyClient = new EnvoyClient("http://envoy.test:8080", 1);
290
+ vi.spyOn(envoyClient, "checkHealth").mockRejectedValueOnce(
291
+ new DOMException("The operation was aborted", "AbortError"),
292
+ );
293
+ checker.registerEnvoy("op-1/staging", envoyClient);
294
+
295
+ const result = await checker.check("op-1/staging", context);
296
+ expect(result.reachable).toBe(false);
297
+ expect(result.error).toContain("ETIMEDOUT");
298
+ });
299
+
300
+ it("returns ECONNREFUSED error when envoy is not responding", async () => {
301
+ const checker = new EnvoyHealthChecker();
302
+ const envoyClient = new EnvoyClient("http://envoy.test:8080", 5000);
303
+ vi.spyOn(envoyClient, "checkHealth").mockRejectedValueOnce(
304
+ new Error("fetch failed: ECONNREFUSED"),
305
+ );
306
+ checker.registerEnvoy("op-1/staging", envoyClient);
307
+
308
+ const result = await checker.check("op-1/staging", context);
309
+ expect(result.reachable).toBe(false);
310
+ expect(result.error).toContain("ECONNREFUSED");
311
+ });
312
+
313
+ it("returns generic error for unexpected failures", async () => {
314
+ const checker = new EnvoyHealthChecker();
315
+ const envoyClient = new EnvoyClient("http://envoy.test:8080", 5000);
316
+ vi.spyOn(envoyClient, "checkHealth").mockRejectedValueOnce(
317
+ new Error("something completely unexpected"),
318
+ );
319
+ checker.registerEnvoy("op-1/staging", envoyClient);
320
+
321
+ const result = await checker.check("op-1/staging", context);
322
+ expect(result.reachable).toBe(false);
323
+ expect(result.error).toContain("health check failed");
324
+ });
325
+
326
+ it("returns healthy=true even when status is healthy but readiness is false", async () => {
327
+ const checker = new EnvoyHealthChecker();
328
+ const envoyClient = new EnvoyClient("http://envoy.test:8080", 5000);
329
+ vi.spyOn(envoyClient, "checkHealth").mockResolvedValueOnce(
330
+ makeHealthResponse({
331
+ status: "healthy",
332
+ readiness: { ready: false, reason: "warming up" },
333
+ }),
334
+ );
335
+ checker.registerEnvoy("op-1/staging", envoyClient);
336
+
337
+ const result = await checker.check("op-1/staging", context);
338
+ // healthy status but not ready -> not reachable
339
+ expect(result.reachable).toBe(false);
340
+ expect(result.error).toContain("warming up");
341
+ });
342
+ });
@@ -0,0 +1,156 @@
1
+ import { describe, it, expect, beforeEach, afterAll } from "vitest";
2
+ import Fastify from "fastify";
3
+ import type { FastifyInstance } from "fastify";
4
+ import { DecisionDebrief } from "@synth-deploy/core";
5
+ import { InMemoryDeploymentStore } from "../src/agent/synth-agent.js";
6
+ import { registerEnvoyReportRoutes } from "../src/api/envoy-reports.js";
7
+ import type { EnvoyRegistry } from "../src/agent/envoy-registry.js";
8
+
9
+ const TEST_TOKEN = "test-envoy-token";
10
+
11
+ // Minimal mock registry — validates TEST_TOKEN, rejects all others
12
+ const mockRegistry = {
13
+ validateToken: (token: string) => token === TEST_TOKEN ? { id: "envoy-1" } : undefined,
14
+ } as unknown as EnvoyRegistry;
15
+
16
+ function makeReport(overrides: Record<string, unknown> = {}) {
17
+ return {
18
+ type: "deployment-result",
19
+ envoyId: "envoy-1",
20
+ deploymentId: "dep-1",
21
+ success: true,
22
+ failureReason: null,
23
+ debriefEntries: [
24
+ {
25
+ id: "entry-1",
26
+ timestamp: new Date().toISOString(),
27
+ partitionId: "part-1",
28
+ deploymentId: "dep-1",
29
+ agent: "envoy",
30
+ decisionType: "deployment-execution",
31
+ decision: "Ran step",
32
+ reasoning: "It was the next step",
33
+ context: {},
34
+ },
35
+ ],
36
+ summary: {
37
+ artifacts: [],
38
+ workspacePath: "/tmp/ws",
39
+ executionDurationMs: 100,
40
+ totalDurationMs: 200,
41
+ verificationPassed: true,
42
+ verificationChecks: [],
43
+ },
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ describe("Envoy report ingestion", () => {
49
+ let app: FastifyInstance;
50
+ let debrief: DecisionDebrief;
51
+ let deployments: InMemoryDeploymentStore;
52
+
53
+ beforeEach(async () => {
54
+ app = Fastify();
55
+ debrief = new DecisionDebrief();
56
+ deployments = new InMemoryDeploymentStore();
57
+ registerEnvoyReportRoutes(app, debrief, deployments, mockRegistry);
58
+ await app.ready();
59
+ });
60
+
61
+ afterAll(async () => {
62
+ await app?.close();
63
+ });
64
+
65
+ it("accepts a valid report when deployment belongs to partition", async () => {
66
+ deployments.save({
67
+ id: "dep-1",
68
+ operationId: "op-1",
69
+ partitionId: "part-1",
70
+ environmentId: "env-1",
71
+ version: "1.0",
72
+ status: "running",
73
+ variables: {},
74
+ debriefEntryIds: [],
75
+ orderId: null,
76
+ createdAt: new Date(),
77
+ });
78
+
79
+ const res = await app.inject({
80
+ method: "POST",
81
+ url: "/api/envoy/report",
82
+ headers: { Authorization: `Bearer ${TEST_TOKEN}` },
83
+ payload: makeReport(),
84
+ });
85
+
86
+ expect(res.statusCode).toBe(200);
87
+ expect(res.json().accepted).toBe(true);
88
+ expect(res.json().entriesIngested).toBe(1);
89
+ });
90
+
91
+ it("rejects report with cross-partition deployment (403)", async () => {
92
+ // Deployment belongs to part-2, but report claims part-1
93
+ deployments.save({
94
+ id: "dep-1",
95
+ operationId: "op-1",
96
+ partitionId: "part-2",
97
+ environmentId: "env-1",
98
+ version: "1.0",
99
+ status: "running",
100
+ variables: {},
101
+ debriefEntryIds: [],
102
+ orderId: null,
103
+ createdAt: new Date(),
104
+ });
105
+
106
+ const res = await app.inject({
107
+ method: "POST",
108
+ url: "/api/envoy/report",
109
+ headers: { Authorization: `Bearer ${TEST_TOKEN}` },
110
+ payload: makeReport(),
111
+ });
112
+
113
+ expect(res.statusCode).toBe(403);
114
+ expect(res.json().error).toContain("Partition boundary");
115
+ });
116
+
117
+ it("rejects report with unknown deployment (403)", async () => {
118
+ // No deployment saved — dep-1 doesn't exist
119
+ const res = await app.inject({
120
+ method: "POST",
121
+ url: "/api/envoy/report",
122
+ headers: { Authorization: `Bearer ${TEST_TOKEN}` },
123
+ payload: makeReport(),
124
+ });
125
+
126
+ expect(res.statusCode).toBe(403);
127
+ });
128
+
129
+ it("rejects invalid decisionType", async () => {
130
+ deployments.save({
131
+ id: "dep-1",
132
+ operationId: "op-1",
133
+ partitionId: "part-1",
134
+ environmentId: "env-1",
135
+ version: "1.0",
136
+ status: "running",
137
+ variables: {},
138
+ debriefEntryIds: [],
139
+ orderId: null,
140
+ createdAt: new Date(),
141
+ });
142
+
143
+ const report = makeReport();
144
+ (report as any).debriefEntries[0].decisionType = "totally-fake-type";
145
+
146
+ const res = await app.inject({
147
+ method: "POST",
148
+ url: "/api/envoy/report",
149
+ headers: { Authorization: `Bearer ${TEST_TOKEN}` },
150
+ payload: report,
151
+ });
152
+
153
+ expect(res.statusCode).toBe(400);
154
+ expect(res.json().error).toContain("Invalid");
155
+ });
156
+ });