@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,536 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { DecisionDebrief, PartitionStore, EnvironmentStore, ArtifactStore } from "@synth-deploy/core";
3
+ import type { Partition, Environment, DebriefEntry } from "@synth-deploy/core";
4
+ import {
5
+ SynthAgent,
6
+ InMemoryDeploymentStore,
7
+ } from "../src/agent/synth-agent.js";
8
+ import type {
9
+ ServiceHealthChecker,
10
+ HealthCheckResult,
11
+ } from "../src/agent/health-checker.js";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Test helpers
15
+ // ---------------------------------------------------------------------------
16
+
17
+ class MockHealthChecker implements ServiceHealthChecker {
18
+ private responses: HealthCheckResult[] = [];
19
+ public callCount = 0;
20
+
21
+ willReturn(...results: HealthCheckResult[]): void {
22
+ this.responses.push(...results);
23
+ }
24
+
25
+ async check(): Promise<HealthCheckResult> {
26
+ this.callCount++;
27
+ const next = this.responses.shift();
28
+ if (next) return next;
29
+ return { reachable: true, responseTimeMs: 1, error: null };
30
+ }
31
+ }
32
+
33
+ const HEALTHY: HealthCheckResult = {
34
+ reachable: true,
35
+ responseTimeMs: 5,
36
+ error: null,
37
+ };
38
+
39
+ const CONN_REFUSED: HealthCheckResult = {
40
+ reachable: false,
41
+ responseTimeMs: null,
42
+ error: "ECONNREFUSED: Connection refused",
43
+ };
44
+
45
+ const DNS_FAILURE: HealthCheckResult = {
46
+ reachable: false,
47
+ responseTimeMs: null,
48
+ error: "ENOTFOUND: DNS lookup failed for staging.internal",
49
+ };
50
+
51
+ const TIMEOUT: HealthCheckResult = {
52
+ reachable: false,
53
+ responseTimeMs: null,
54
+ error: "ETIMEDOUT: Request timed out after 5000ms",
55
+ };
56
+
57
+ const SERVER_ERROR: HealthCheckResult = {
58
+ reachable: false,
59
+ responseTimeMs: null,
60
+ error: "HTTP 503 Service Unavailable",
61
+ };
62
+
63
+ function findDecisions(entries: DebriefEntry[], substr: string): DebriefEntry[] {
64
+ return entries.filter((e) =>
65
+ e.decision.toLowerCase().includes(substr.toLowerCase()),
66
+ );
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Shared test state
71
+ // ---------------------------------------------------------------------------
72
+
73
+ let diary: DecisionDebrief;
74
+ let deployments: InMemoryDeploymentStore;
75
+ let healthChecker: MockHealthChecker;
76
+ let artifactStore: ArtifactStore;
77
+ let environmentStore: EnvironmentStore;
78
+ let partitionStore: PartitionStore;
79
+ let agent: SynthAgent;
80
+
81
+ /** Seed a minimal artifact in the store for testing. */
82
+ function seedArtifact(name = "web-app") {
83
+ return artifactStore.create({
84
+ name,
85
+ type: "nodejs",
86
+ analysis: {
87
+ summary: "Test artifact",
88
+ dependencies: [],
89
+ configurationExpectations: {},
90
+ deploymentIntent: "rolling",
91
+ confidence: 0.9,
92
+ },
93
+ annotations: [],
94
+ learningHistory: [],
95
+ });
96
+ }
97
+
98
+ /** Seed an environment and return it (also registered in the store). */
99
+ function seedEnvironment(name = "production", variables: Record<string, string> = {}) {
100
+ return environmentStore.create(name, variables);
101
+ }
102
+
103
+ /** Seed a partition and return it (also registered in the store). */
104
+ function seedPartition(name = "Acme Corp", variables: Record<string, string> = {}) {
105
+ return partitionStore.create(name, variables);
106
+ }
107
+
108
+ /**
109
+ * Build a deployment trigger from seeded entities.
110
+ */
111
+ function makeTrigger(opts: {
112
+ artifact?: { id: string };
113
+ partition?: { id: string };
114
+ environment?: { id: string };
115
+ version?: string;
116
+ variables?: Record<string, string>;
117
+ }) {
118
+ return {
119
+ artifactId: opts.artifact?.id ?? "",
120
+ artifactVersionId: opts.version ?? "2.0.0",
121
+ partitionId: opts.partition?.id,
122
+ environmentId: opts.environment?.id ?? "",
123
+ triggeredBy: "user" as const,
124
+ ...(opts.variables ? { variables: opts.variables } : {}),
125
+ };
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Tests
130
+ // ---------------------------------------------------------------------------
131
+
132
+ describe("Deployment Orchestration Engine", () => {
133
+ beforeEach(() => {
134
+ diary = new DecisionDebrief();
135
+ deployments = new InMemoryDeploymentStore();
136
+ healthChecker = new MockHealthChecker();
137
+ artifactStore = new ArtifactStore();
138
+ environmentStore = new EnvironmentStore();
139
+ partitionStore = new PartitionStore();
140
+ agent = new SynthAgent(
141
+ diary, deployments, artifactStore, environmentStore, partitionStore,
142
+ healthChecker, { healthCheckBackoffMs: 1, executionDelayMs: 1 },
143
+ );
144
+ });
145
+
146
+ // -----------------------------------------------------------------------
147
+ // Scenario 1: Successful deployment with variable resolution
148
+ // -----------------------------------------------------------------------
149
+
150
+ describe("successful deployment with variable resolution", () => {
151
+ it("resolves variables, executes full pipeline, and records every decision", async () => {
152
+ const artifact = seedArtifact();
153
+ const partition = seedPartition("Acme Corp", { APP_ENV: "production", DB_HOST: "acme-db-1" });
154
+ const env = seedEnvironment("production", { APP_ENV: "production", LOG_LEVEL: "warn" });
155
+
156
+ healthChecker.willReturn(HEALTHY);
157
+
158
+ const trigger = makeTrigger({
159
+ artifact, partition, environment: env,
160
+ variables: { LOG_LEVEL: "error" },
161
+ });
162
+ const result = await agent.triggerDeployment(trigger);
163
+
164
+ expect(result.status).toBe("succeeded");
165
+ expect(result.completedAt).not.toBeUndefined();
166
+
167
+ // Variables resolved with correct precedence
168
+ expect(result.variables).toEqual({
169
+ APP_ENV: "production",
170
+ LOG_LEVEL: "error", // trigger > environment
171
+ DB_HOST: "acme-db-1", // partition-only
172
+ });
173
+
174
+ // Diary records every pipeline step
175
+ const entries = diary.getByDeployment(result.id);
176
+ expect(entries.length).toBeGreaterThanOrEqual(5);
177
+
178
+ // Each entry has a real decision and reasoning
179
+ for (const entry of entries) {
180
+ expect(entry.decision.length).toBeGreaterThan(0);
181
+ expect(entry.reasoning.length).toBeGreaterThan(0);
182
+ }
183
+
184
+ // Key pipeline milestones present
185
+ expect(findDecisions(entries, "pipeline")).toHaveLength(1);
186
+ expect(findDecisions(entries, "Accepted configuration")).toHaveLength(1);
187
+ expect(findDecisions(entries, "Marking deployment of")).toHaveLength(1);
188
+ });
189
+
190
+ it("handles deployment with no variable conflicts", async () => {
191
+ const artifact = seedArtifact();
192
+ const partition = seedPartition("Acme Corp", { PARTITION_SPECIFIC: "abc" });
193
+ const env = seedEnvironment("production", { ENV_SPECIFIC: "xyz" });
194
+
195
+ healthChecker.willReturn(HEALTHY);
196
+
197
+ const trigger = makeTrigger({ artifact, partition, environment: env });
198
+ const result = await agent.triggerDeployment(trigger);
199
+
200
+ expect(result.status).toBe("succeeded");
201
+ expect(result.variables).toEqual({
202
+ ENV_SPECIFIC: "xyz",
203
+ PARTITION_SPECIFIC: "abc",
204
+ });
205
+
206
+ const entries = diary.getByDeployment(result.id);
207
+ const completion = findDecisions(entries, "Marking deployment of")[0];
208
+ expect(completion.reasoning).toContain("No variable conflicts");
209
+ });
210
+ });
211
+
212
+ // -----------------------------------------------------------------------
213
+ // Scenario 2: Service not responding — error-type-dependent reasoning
214
+ // -----------------------------------------------------------------------
215
+
216
+ describe("service not responding", () => {
217
+ it("connection refused → retries, then fails with actionable reasoning", async () => {
218
+ healthChecker.willReturn(CONN_REFUSED, CONN_REFUSED);
219
+
220
+ const artifact = seedArtifact();
221
+ const partition = seedPartition();
222
+ const env = seedEnvironment("staging");
223
+
224
+ const trigger = makeTrigger({ artifact, partition, environment: env });
225
+ const result = await agent.triggerDeployment(trigger);
226
+
227
+ expect(result.status).toBe("failed");
228
+ expect(result.failureReason).toContain("unreachable");
229
+
230
+ // Should have retried (initial + 1 retry = 2 calls)
231
+ expect(healthChecker.callCount).toBe(2);
232
+
233
+ const entries = diary.getByDeployment(result.id);
234
+ const retryEntries = findDecisions(entries, "attempting retry");
235
+ expect(retryEntries).toHaveLength(1);
236
+ expect(retryEntries[0].reasoning).toContain("restarting");
237
+
238
+ // Final failure is specific
239
+ const failEntries = findDecisions(entries, "Deployment failed");
240
+ expect(failEntries).toHaveLength(1);
241
+ expect(failEntries[0].reasoning).toContain("persistent");
242
+ expect(failEntries[0].reasoning).toContain("Recommended action");
243
+ });
244
+
245
+ it("DNS failure → aborts immediately without retrying", async () => {
246
+ healthChecker.willReturn(DNS_FAILURE);
247
+
248
+ const artifact = seedArtifact();
249
+ const partition = seedPartition();
250
+ const env = seedEnvironment("staging");
251
+
252
+ const trigger = makeTrigger({ artifact, partition, environment: env });
253
+ const result = await agent.triggerDeployment(trigger);
254
+
255
+ expect(result.status).toBe("failed");
256
+
257
+ // Only called once — no retry for DNS errors
258
+ expect(healthChecker.callCount).toBe(1);
259
+
260
+ const entries = diary.getByDeployment(result.id);
261
+
262
+ // Should have decided NOT to retry
263
+ const abortEntries = findDecisions(entries, "aborting without retry");
264
+ expect(abortEntries).toHaveLength(1);
265
+ expect(abortEntries[0].reasoning).toContain("DNS");
266
+ expect(abortEntries[0].reasoning).toContain("not a transient failure");
267
+ expect(abortEntries[0].context).toHaveProperty("retriesSkipped", true);
268
+ expect(abortEntries[0].context).toHaveProperty("errorCategory", "dns");
269
+
270
+ // No retry entries should exist
271
+ const retryEntries = findDecisions(entries, "attempting retry");
272
+ expect(retryEntries).toHaveLength(0);
273
+ });
274
+
275
+ it("timeout in production → retries with extended backoff", async () => {
276
+ healthChecker.willReturn(TIMEOUT, TIMEOUT);
277
+
278
+ const artifact = seedArtifact();
279
+ const partition = seedPartition();
280
+ const env = seedEnvironment("production");
281
+
282
+ const trigger = makeTrigger({ artifact, partition, environment: env });
283
+ const result = await agent.triggerDeployment(trigger);
284
+
285
+ expect(result.status).toBe("failed");
286
+
287
+ const entries = diary.getByDeployment(result.id);
288
+ const retryEntries = findDecisions(entries, "attempting retry");
289
+ expect(retryEntries).toHaveLength(1);
290
+
291
+ // Reasoning should mention production-specific extended backoff
292
+ expect(retryEntries[0].reasoning).toContain("production");
293
+ expect(retryEntries[0].reasoning).toContain("extended backoff");
294
+ expect(retryEntries[0].reasoning).toContain("heavy load");
295
+
296
+ // Backoff should be 2x normal (2ms instead of 1ms with our test config)
297
+ expect(retryEntries[0].context).toHaveProperty("backoffMs", 2);
298
+ });
299
+
300
+ it("timeout in staging → retries with standard backoff (not extended)", async () => {
301
+ healthChecker.willReturn(TIMEOUT, TIMEOUT);
302
+
303
+ const artifact = seedArtifact();
304
+ const partition = seedPartition();
305
+ const env = seedEnvironment("staging");
306
+
307
+ const trigger = makeTrigger({ artifact, partition, environment: env });
308
+ const result = await agent.triggerDeployment(trigger);
309
+
310
+ expect(result.status).toBe("failed");
311
+
312
+ const entries = diary.getByDeployment(result.id);
313
+ const retryEntries = findDecisions(entries, "attempting retry");
314
+ expect(retryEntries).toHaveLength(1);
315
+
316
+ // Staging should NOT get extended backoff
317
+ expect(retryEntries[0].reasoning).not.toContain("extended backoff");
318
+ expect(retryEntries[0].context).toHaveProperty("backoffMs", 1);
319
+ });
320
+
321
+ it("recovery on retry → completes deployment", async () => {
322
+ healthChecker.willReturn(CONN_REFUSED, HEALTHY);
323
+
324
+ const artifact = seedArtifact();
325
+ const partition = seedPartition();
326
+ const env = seedEnvironment("production");
327
+
328
+ const trigger = makeTrigger({ artifact, partition, environment: env });
329
+ const result = await agent.triggerDeployment(trigger);
330
+
331
+ expect(result.status).toBe("succeeded");
332
+
333
+ const entries = diary.getByDeployment(result.id);
334
+ const recoveryEntries = findDecisions(entries, "recovered on retry");
335
+ expect(recoveryEntries).toHaveLength(1);
336
+ expect(recoveryEntries[0].reasoning).toContain("transient");
337
+ expect(recoveryEntries[0].reasoning).toContain("confirmed healthy");
338
+ });
339
+
340
+ it("server error (503) → retries with appropriate reasoning", async () => {
341
+ healthChecker.willReturn(SERVER_ERROR, HEALTHY);
342
+
343
+ const artifact = seedArtifact();
344
+ const partition = seedPartition();
345
+ const env = seedEnvironment("production");
346
+
347
+ const trigger = makeTrigger({ artifact, partition, environment: env });
348
+ const result = await agent.triggerDeployment(trigger);
349
+
350
+ expect(result.status).toBe("succeeded");
351
+
352
+ const entries = diary.getByDeployment(result.id);
353
+ const retryEntries = findDecisions(entries, "attempting retry");
354
+ expect(retryEntries).toHaveLength(1);
355
+ // Server errors get different reasoning than connection refused
356
+ expect(retryEntries[0].reasoning).toContain("unhealthy");
357
+ expect(retryEntries[0].reasoning).toContain("upstream dependency");
358
+ });
359
+ });
360
+
361
+ // -----------------------------------------------------------------------
362
+ // Scenario 3: Variable conflicts — different outcomes based on risk
363
+ // -----------------------------------------------------------------------
364
+
365
+ describe("variable conflict reasoning", () => {
366
+ it("single cross-env connectivity var → proceeds with warning", async () => {
367
+ const artifact = seedArtifact();
368
+ const partition = seedPartition("Acme Corp", { DB_HOST: "prod-db.internal" });
369
+ const env = seedEnvironment("staging", { DB_HOST: "staging-db.internal" });
370
+
371
+ healthChecker.willReturn(HEALTHY);
372
+
373
+ const trigger = makeTrigger({ artifact, partition, environment: env });
374
+ const result = await agent.triggerDeployment(trigger);
375
+
376
+ // Single override → agent proceeds (might be intentional)
377
+ expect(result.status).toBe("succeeded");
378
+ expect(result.variables.DB_HOST).toBe("prod-db.internal");
379
+
380
+ const entries = diary.getByDeployment(result.id);
381
+ const crossEnvEntries = findDecisions(entries, "Cross-environment");
382
+ expect(crossEnvEntries).toHaveLength(1);
383
+
384
+ // Reasoning acknowledges it might be intentional
385
+ expect(crossEnvEntries[0].reasoning).toContain("intentional");
386
+ expect(crossEnvEntries[0].reasoning).toContain("single");
387
+ expect(crossEnvEntries[0].context).toHaveProperty("action", "proceed");
388
+ expect(crossEnvEntries[0].context).toHaveProperty("riskLevel", "medium");
389
+ });
390
+
391
+ it("multiple cross-env connectivity vars → BLOCKS deployment", async () => {
392
+ const artifact = seedArtifact();
393
+ const partition = seedPartition("Acme Corp", {
394
+ DB_HOST: "prod-db.internal",
395
+ CACHE_HOST: "prod-cache:6379",
396
+ });
397
+ const env = seedEnvironment("staging", {
398
+ DB_HOST: "staging-db.internal",
399
+ CACHE_HOST: "staging-cache:6379",
400
+ });
401
+
402
+ healthChecker.willReturn(HEALTHY);
403
+
404
+ const trigger = makeTrigger({ artifact, partition, environment: env });
405
+ const result = await agent.triggerDeployment(trigger);
406
+
407
+ // THIS IS THE KEY BEHAVIORAL DIFFERENCE:
408
+ // Multiple cross-env connectivity overrides → deployment blocked
409
+ expect(result.status).toBe("failed");
410
+ expect(result.failureReason).toContain("high-risk");
411
+
412
+ const entries = diary.getByDeployment(result.id);
413
+
414
+ // Agent explains WHY it blocked
415
+ const blockEntries = findDecisions(entries, "Blocking deployment");
416
+ expect(blockEntries).toHaveLength(1);
417
+ expect(blockEntries[0].reasoning).toContain("misconfigured");
418
+ expect(blockEntries[0].reasoning).toContain("cross-environment");
419
+ expect(blockEntries[0].context).toHaveProperty("action", "block");
420
+ expect(blockEntries[0].context).toHaveProperty("riskLevel", "high");
421
+
422
+ // The failure step is resolve-configuration, not health check
423
+ const failEntries = findDecisions(entries, "Deployment failed");
424
+ expect(failEntries[0].context).toHaveProperty(
425
+ "step",
426
+ "resolve-configuration",
427
+ );
428
+ });
429
+
430
+ it("cross-env non-connectivity vars → proceeds (lower risk)", async () => {
431
+ const artifact = seedArtifact();
432
+ const partition = seedPartition("Acme Corp", { APP_LABEL: "production-canary" });
433
+ const env = seedEnvironment("staging", { APP_LABEL: "staging-primary" });
434
+
435
+ healthChecker.willReturn(HEALTHY);
436
+
437
+ const trigger = makeTrigger({ artifact, partition, environment: env });
438
+ const result = await agent.triggerDeployment(trigger);
439
+
440
+ // Non-connectivity cross-env → proceeds (can't route traffic)
441
+ expect(result.status).toBe("succeeded");
442
+ });
443
+
444
+ it("sensitive variable overrides → audit logging without values", async () => {
445
+ const artifact = seedArtifact();
446
+ const partition = seedPartition("Acme Corp", { API_SECRET: "partition-secret-xyz" });
447
+ const env = seedEnvironment("production", { API_SECRET: "default-env-secret" });
448
+
449
+ healthChecker.willReturn(HEALTHY);
450
+
451
+ const trigger = makeTrigger({ artifact, partition, environment: env });
452
+ const result = await agent.triggerDeployment(trigger);
453
+
454
+ expect(result.status).toBe("succeeded");
455
+ expect(result.variables.API_SECRET).toBe("partition-secret-xyz");
456
+
457
+ const entries = diary.getByDeployment(result.id);
458
+ const sensitiveEntries = findDecisions(entries, "Security-sensitive");
459
+ expect(sensitiveEntries).toHaveLength(1);
460
+ expect(sensitiveEntries[0].reasoning).toContain("audit");
461
+
462
+ // Values must NOT appear in context
463
+ const contextStr = JSON.stringify(sensitiveEntries[0].context);
464
+ expect(contextStr).not.toContain("partition-secret-xyz");
465
+ expect(contextStr).not.toContain("default-env-secret");
466
+ });
467
+ });
468
+
469
+ // -----------------------------------------------------------------------
470
+ // Decision trail completeness
471
+ // -----------------------------------------------------------------------
472
+
473
+ describe("decision trail", () => {
474
+ it("every diary entry has partition isolation via partitionId", async () => {
475
+ const artifact = seedArtifact();
476
+ const partition = seedPartition("Isolated Partition");
477
+ const env = seedEnvironment("production");
478
+
479
+ healthChecker.willReturn(HEALTHY);
480
+
481
+ const trigger = makeTrigger({ artifact, partition, environment: env });
482
+ const result = await agent.triggerDeployment(trigger);
483
+ const entries = diary.getByDeployment(result.id);
484
+
485
+ for (const entry of entries) {
486
+ expect(entry.partitionId).toBe(partition.id);
487
+ }
488
+
489
+ const partitionEntries = diary.getByPartition(partition.id);
490
+ // Partition entries include all deployment-scoped entries
491
+ expect(partitionEntries.length).toBeGreaterThanOrEqual(entries.length);
492
+ });
493
+
494
+ it("failed deployment trail includes the failing step", async () => {
495
+ healthChecker.willReturn(CONN_REFUSED, CONN_REFUSED);
496
+
497
+ const artifact = seedArtifact();
498
+ const partition = seedPartition();
499
+ const env = seedEnvironment("production");
500
+
501
+ const trigger = makeTrigger({ artifact, partition, environment: env });
502
+ const result = await agent.triggerDeployment(trigger);
503
+
504
+ expect(result.status).toBe("failed");
505
+
506
+ for (const entryId of result.debriefEntryIds) {
507
+ const entry = diary.getById(entryId);
508
+ expect(entry).toBeDefined();
509
+ expect(entry!.deploymentId).toBe(result.id);
510
+ }
511
+
512
+ const entries = diary.getByDeployment(result.id);
513
+ const failEntry = findDecisions(entries, "Deployment failed")[0];
514
+ expect(failEntry.context).toHaveProperty(
515
+ "step",
516
+ "preflight-health-check",
517
+ );
518
+ });
519
+
520
+ it("deployment store persists the final state", async () => {
521
+ healthChecker.willReturn(HEALTHY);
522
+
523
+ const artifact = seedArtifact();
524
+ const partition = seedPartition();
525
+ const env = seedEnvironment("production");
526
+
527
+ const trigger = makeTrigger({ artifact, partition, environment: env });
528
+ const result = await agent.triggerDeployment(trigger);
529
+
530
+ const stored = deployments.get(result.id);
531
+ expect(stored).toBeDefined();
532
+ expect(stored!.status).toBe("succeeded");
533
+ expect(stored!.variables).toEqual(result.variables);
534
+ });
535
+ });
536
+ });
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect, beforeEach, afterAll } from "vitest";
2
+ import Fastify from "fastify";
3
+ import type { FastifyInstance } from "fastify";
4
+ import { DecisionDebrief, PartitionStore, TelemetryStore } from "@synth-deploy/core";
5
+ import { InMemoryDeploymentStore } from "../src/agent/synth-agent.js";
6
+ import { registerPartitionRoutes } from "../src/api/partitions.js";
7
+
8
+ function addMockAuth(app: FastifyInstance) {
9
+ app.addHook("onRequest", async (request) => {
10
+ request.user = {
11
+ id: "test-user-id" as any,
12
+ email: "test@example.com",
13
+ name: "Test User",
14
+ permissions: [
15
+ "partition.create", "partition.update", "partition.delete", "partition.view",
16
+ "deployment.view",
17
+ ],
18
+ };
19
+ });
20
+ }
21
+
22
+ describe("Partition deletion guard", () => {
23
+ let app: FastifyInstance;
24
+ let partitions: PartitionStore;
25
+ let deployments: InMemoryDeploymentStore;
26
+ let debrief: DecisionDebrief;
27
+ let telemetry: TelemetryStore;
28
+
29
+ beforeEach(async () => {
30
+ app = Fastify();
31
+ addMockAuth(app);
32
+ partitions = new PartitionStore();
33
+ deployments = new InMemoryDeploymentStore();
34
+ debrief = new DecisionDebrief();
35
+ telemetry = new TelemetryStore();
36
+ registerPartitionRoutes(app, partitions, deployments, debrief, telemetry);
37
+ await app.ready();
38
+ });
39
+
40
+ afterAll(async () => {
41
+ await app?.close();
42
+ });
43
+
44
+ it("deletes partition with no links", async () => {
45
+ const partition = partitions.create("test", {});
46
+
47
+ const res = await app.inject({
48
+ method: "DELETE",
49
+ url: `/api/partitions/${partition.id}`,
50
+ });
51
+
52
+ expect(res.statusCode).toBe(200);
53
+ expect(res.json().deleted).toBe(true);
54
+ });
55
+
56
+ it("blocks deletion when partition has deployments (409)", async () => {
57
+ const partition = partitions.create("test", {});
58
+ deployments.save({
59
+ id: "dep-1",
60
+ artifactId: "artifact-1",
61
+ partitionId: partition.id,
62
+ environmentId: "env-1",
63
+ version: "1.0",
64
+ status: "succeeded",
65
+ variables: {},
66
+ debriefEntryIds: [],
67
+ createdAt: new Date(),
68
+ completedAt: null,
69
+ failureReason: null,
70
+ });
71
+
72
+ const res = await app.inject({
73
+ method: "DELETE",
74
+ url: `/api/partitions/${partition.id}`,
75
+ });
76
+
77
+ expect(res.statusCode).toBe(409);
78
+ expect(res.json().deployments).toBe(1);
79
+ expect(res.json().hint).toContain("cascade");
80
+ });
81
+
82
+ it("allows cascade deletion with ?cascade=true", async () => {
83
+ const partition = partitions.create("test", {});
84
+ deployments.save({
85
+ id: "dep-1",
86
+ artifactId: "artifact-1",
87
+ partitionId: partition.id,
88
+ environmentId: "env-1",
89
+ version: "1.0",
90
+ status: "succeeded",
91
+ variables: {},
92
+ debriefEntryIds: [],
93
+ createdAt: new Date(),
94
+ completedAt: null,
95
+ failureReason: null,
96
+ });
97
+
98
+ const res = await app.inject({
99
+ method: "DELETE",
100
+ url: `/api/partitions/${partition.id}?cascade=true`,
101
+ });
102
+
103
+ expect(res.statusCode).toBe(200);
104
+ expect(res.json().deleted).toBe(true);
105
+ expect(res.json().cascade).toBe(true);
106
+ });
107
+
108
+ it("logs cascade deletion to Decision Diary", async () => {
109
+ const partition = partitions.create("test-partition", {});
110
+ deployments.save({
111
+ id: "dep-1",
112
+ artifactId: "artifact-1",
113
+ partitionId: partition.id,
114
+ environmentId: "env-1",
115
+ version: "1.0",
116
+ status: "succeeded",
117
+ variables: {},
118
+ debriefEntryIds: [],
119
+ createdAt: new Date(),
120
+ completedAt: null,
121
+ failureReason: null,
122
+ });
123
+
124
+ await app.inject({
125
+ method: "DELETE",
126
+ url: `/api/partitions/${partition.id}?cascade=true`,
127
+ });
128
+
129
+ const entries = debrief.getRecent(1);
130
+ expect(entries.length).toBe(1);
131
+ expect(entries[0].decision).toContain("Cascade-deleted");
132
+ expect(entries[0].decision).toContain("test-partition");
133
+ });
134
+
135
+ it("returns 404 for non-existent partition", async () => {
136
+ const res = await app.inject({
137
+ method: "DELETE",
138
+ url: "/api/partitions/nonexistent",
139
+ });
140
+
141
+ expect(res.statusCode).toBe(404);
142
+ });
143
+ });