@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,510 @@
1
+ import crypto from "node:crypto";
2
+ import type { FastifyInstance } from "fastify";
3
+ import type { Deployment, FleetDeployment, FleetProgress, IDeploymentStore, DebriefWriter } from "@synth-deploy/core";
4
+ import type { EnvoyRegistry } from "../agent/envoy-registry.js";
5
+ import type { FleetDeploymentStore } from "../fleet/fleet-store.js";
6
+ import type { FleetExecutor, FleetProgressEvent } from "../fleet/fleet-executor.js";
7
+ import { selectRepresentatives } from "../fleet/representative-selector.js";
8
+ import { requirePermission, requireEdition } from "../middleware/permissions.js";
9
+
10
+ /**
11
+ * REST API routes for fleet (large-scale) deployments.
12
+ * Provides create, list, get, approve, execute, pause, and resume endpoints.
13
+ */
14
+ export function registerFleetRoutes(
15
+ app: FastifyInstance,
16
+ fleetStore: FleetDeploymentStore,
17
+ envoyRegistry: EnvoyRegistry,
18
+ deploymentStore: IDeploymentStore,
19
+ fleetExecutor: FleetExecutor,
20
+ debrief: DebriefWriter,
21
+ ): void {
22
+ // -----------------------------------------------------------------------
23
+ // POST /api/fleet-deployments — create a fleet deployment
24
+ // -----------------------------------------------------------------------
25
+ app.post(
26
+ "/api/fleet-deployments",
27
+ { preHandler: [requireEdition("fleet-deployments"), requirePermission("deployment.create")] },
28
+ async (request, reply) => {
29
+ const body = request.body as {
30
+ artifactId?: string;
31
+ artifactVersionId?: string;
32
+ environmentId?: string;
33
+ envoyFilter?: string[];
34
+ rolloutConfig?: {
35
+ strategy?: "all-at-once" | "batched" | "canary";
36
+ batchSize?: number;
37
+ batchPercent?: number;
38
+ pauseBetweenBatches?: boolean;
39
+ haltOnFailureCount?: number;
40
+ healthCheckWaitMs?: number;
41
+ };
42
+ };
43
+
44
+ if (!body.artifactId || !body.environmentId) {
45
+ return reply.status(400).send({ error: "artifactId and environmentId are required" });
46
+ }
47
+
48
+ // Identify target envoys for the environment
49
+ const allEnvoys = await envoyRegistry.probeAll();
50
+ const targetEnvoys = body.envoyFilter
51
+ ? allEnvoys.filter((e) => body.envoyFilter!.includes(e.id))
52
+ : allEnvoys.filter(
53
+ (e) =>
54
+ e.assignedEnvironments.length === 0 ||
55
+ e.assignedEnvironments.includes(body.environmentId!),
56
+ );
57
+
58
+ if (targetEnvoys.length === 0) {
59
+ return reply.status(422).send({ error: "No envoys found for the specified environment" });
60
+ }
61
+
62
+ // Select representative envoys for plan validation
63
+ const representativeIds = selectRepresentatives(targetEnvoys, body.artifactId);
64
+
65
+ const rolloutConfig = {
66
+ strategy: body.rolloutConfig?.strategy ?? "batched",
67
+ batchSize: body.rolloutConfig?.batchSize,
68
+ batchPercent: body.rolloutConfig?.batchPercent ?? 10,
69
+ pauseBetweenBatches: body.rolloutConfig?.pauseBetweenBatches ?? false,
70
+ haltOnFailureCount: body.rolloutConfig?.haltOnFailureCount ?? 1,
71
+ healthCheckWaitMs: body.rolloutConfig?.healthCheckWaitMs ?? 5000,
72
+ };
73
+
74
+ const now = new Date();
75
+ const fleetDeployment: FleetDeployment = {
76
+ id: crypto.randomUUID(),
77
+ artifactId: body.artifactId,
78
+ artifactVersionId: body.artifactVersionId ?? "",
79
+ environmentId: body.environmentId,
80
+ envoyFilter: body.envoyFilter,
81
+ rolloutConfig,
82
+ representativeEnvoyIds: representativeIds,
83
+ status: "selecting_representatives",
84
+ progress: {
85
+ totalEnvoys: targetEnvoys.length,
86
+ validated: 0,
87
+ executing: 0,
88
+ succeeded: 0,
89
+ failed: 0,
90
+ pending: targetEnvoys.length,
91
+ },
92
+ createdAt: now,
93
+ updatedAt: now,
94
+ };
95
+
96
+ fleetStore.create(fleetDeployment);
97
+
98
+ debrief.record({
99
+ partitionId: null,
100
+ deploymentId: fleetDeployment.id,
101
+ agent: "server",
102
+ decisionType: "system",
103
+ decision: `Fleet deployment created for ${targetEnvoys.length} envoys with ${rolloutConfig.strategy} strategy`,
104
+ reasoning: `Selected ${representativeIds.length} representative envoy(s) from ${targetEnvoys.length} total. Strategy: ${rolloutConfig.strategy}.`,
105
+ context: {
106
+ fleetId: fleetDeployment.id,
107
+ totalEnvoys: targetEnvoys.length,
108
+ representatives: representativeIds.length,
109
+ strategy: rolloutConfig.strategy,
110
+ },
111
+ });
112
+
113
+ return reply.status(201).send({ fleetDeployment });
114
+ },
115
+ );
116
+
117
+ // -----------------------------------------------------------------------
118
+ // POST /api/fleet-deployments/:id/plan — create representative plan
119
+ // -----------------------------------------------------------------------
120
+ app.post<{ Params: { id: string } }>(
121
+ "/api/fleet-deployments/:id/plan",
122
+ { preHandler: [requireEdition("fleet-deployments"), requirePermission("deployment.create")] },
123
+ async (request, reply) => {
124
+ const fleet = fleetStore.getById(request.params.id);
125
+ if (!fleet) {
126
+ return reply.status(404).send({ error: "Fleet deployment not found" });
127
+ }
128
+
129
+ if (fleet.status !== "selecting_representatives") {
130
+ return reply.status(409).send({
131
+ error: `Cannot create plan in "${fleet.status}" status — must be "selecting_representatives"`,
132
+ });
133
+ }
134
+
135
+ const body = request.body as { envoyId?: string } | undefined;
136
+ const targetEnvoyId = body?.envoyId ?? fleet.representativeEnvoyIds[0];
137
+
138
+ if (!targetEnvoyId || !fleet.representativeEnvoyIds.includes(targetEnvoyId)) {
139
+ return reply.status(422).send({
140
+ error: "Invalid or missing representative envoy ID",
141
+ });
142
+ }
143
+
144
+ const deployment: Deployment = {
145
+ id: crypto.randomUUID(),
146
+ artifactId: fleet.artifactId,
147
+ artifactVersionId: fleet.artifactVersionId,
148
+ envoyId: targetEnvoyId,
149
+ environmentId: fleet.environmentId,
150
+ version: "",
151
+ status: "pending",
152
+ variables: {},
153
+ debriefEntryIds: [],
154
+ createdAt: new Date(),
155
+ };
156
+
157
+ deploymentStore.save(deployment);
158
+
159
+ fleet.representativePlanId = deployment.id;
160
+ fleet.status = "planning";
161
+ fleetStore.update(fleet);
162
+
163
+ debrief.record({
164
+ partitionId: null,
165
+ deploymentId: fleet.id,
166
+ agent: "server",
167
+ decisionType: "system",
168
+ decision: `Representative plan created for envoy ${targetEnvoyId}`,
169
+ reasoning: `Created deployment ${deployment.id} as the representative plan for fleet ${fleet.id}. The plan can be reviewed and approved via the standard deployment surface.`,
170
+ context: { fleetId: fleet.id, deploymentId: deployment.id, envoyId: targetEnvoyId },
171
+ });
172
+
173
+ return reply.status(201).send({ deployment });
174
+ },
175
+ );
176
+
177
+ // -----------------------------------------------------------------------
178
+ // GET /api/fleet-deployments — list all fleet deployments
179
+ // -----------------------------------------------------------------------
180
+ app.get(
181
+ "/api/fleet-deployments",
182
+ { preHandler: [requireEdition("fleet-deployments"), requirePermission("deployment.view")] },
183
+ async () => {
184
+ return { fleetDeployments: fleetStore.list() };
185
+ },
186
+ );
187
+
188
+ // -----------------------------------------------------------------------
189
+ // GET /api/fleet-deployments/:id — get fleet deployment with progress
190
+ // -----------------------------------------------------------------------
191
+ app.get<{ Params: { id: string } }>(
192
+ "/api/fleet-deployments/:id",
193
+ { preHandler: [requireEdition("fleet-deployments"), requirePermission("deployment.view")] },
194
+ async (request, reply) => {
195
+ const fleet = fleetStore.getById(request.params.id);
196
+ if (!fleet) {
197
+ return reply.status(404).send({ error: "Fleet deployment not found" });
198
+ }
199
+ return { fleetDeployment: fleet };
200
+ },
201
+ );
202
+
203
+ // -----------------------------------------------------------------------
204
+ // POST /api/fleet-deployments/:id/approve — approve plan, trigger validation
205
+ // -----------------------------------------------------------------------
206
+ app.post<{ Params: { id: string } }>(
207
+ "/api/fleet-deployments/:id/approve",
208
+ { preHandler: [requireEdition("fleet-deployments"), requirePermission("deployment.approve")] },
209
+ async (request, reply) => {
210
+ const fleet = fleetStore.getById(request.params.id);
211
+ if (!fleet) {
212
+ return reply.status(404).send({ error: "Fleet deployment not found" });
213
+ }
214
+
215
+ if (
216
+ fleet.status !== "selecting_representatives" &&
217
+ fleet.status !== "awaiting_approval"
218
+ ) {
219
+ return reply.status(409).send({
220
+ error: `Cannot approve fleet deployment in "${fleet.status}" status`,
221
+ });
222
+ }
223
+
224
+ // Look up the representative plan from the linked deployment
225
+ const plan = fleet.representativePlanId
226
+ ? deploymentStore.get(fleet.representativePlanId)?.plan
227
+ : undefined;
228
+
229
+ if (!plan) {
230
+ return reply.status(422).send({
231
+ error: "No approved plan found. Submit a plan for a representative deployment first.",
232
+ });
233
+ }
234
+
235
+ // Transition to validating
236
+ fleet.status = "validating";
237
+ fleetStore.update(fleet);
238
+
239
+ const actor = (request.user?.email) ?? "anonymous";
240
+
241
+ debrief.record({
242
+ partitionId: null,
243
+ deploymentId: fleet.id,
244
+ agent: "server",
245
+ decisionType: "system",
246
+ decision: `Fleet deployment approved by ${actor}, starting fleet validation`,
247
+ reasoning: `Validating plan across ${fleet.progress.totalEnvoys} envoys before execution.`,
248
+ context: { approvedBy: actor, fleetId: fleet.id },
249
+ actor: request.user?.email,
250
+ });
251
+
252
+ // Run validation asynchronously
253
+ fleetExecutor
254
+ .validateFleet(fleet, plan)
255
+ .then((validationResult) => {
256
+ fleet.validationResult = validationResult;
257
+ fleet.progress.validated = validationResult.validated;
258
+ fleet.status = validationResult.failed > 0
259
+ ? "validation_failed"
260
+ : "validated";
261
+ fleetStore.update(fleet);
262
+
263
+ debrief.record({
264
+ partitionId: null,
265
+ deploymentId: fleet.id,
266
+ agent: "server",
267
+ decisionType: "system",
268
+ decision: `Fleet validation complete: ${validationResult.validated}/${validationResult.total} envoys passed`,
269
+ reasoning: validationResult.failed > 0
270
+ ? `${validationResult.failed} envoy(s) failed validation. Review issues before proceeding.`
271
+ : "All envoys passed validation. Ready for execution.",
272
+ context: { validated: validationResult.validated, failed: validationResult.failed, total: validationResult.total },
273
+ });
274
+ })
275
+ .catch((err) => {
276
+ fleet.status = "failed";
277
+ fleetStore.update(fleet);
278
+
279
+ debrief.record({
280
+ partitionId: null,
281
+ deploymentId: fleet.id,
282
+ agent: "server",
283
+ decisionType: "deployment-failure",
284
+ decision: "Fleet validation failed unexpectedly",
285
+ reasoning: err instanceof Error ? err.message : String(err),
286
+ context: { error: err instanceof Error ? err.message : String(err) },
287
+ });
288
+ });
289
+
290
+ return { fleetDeployment: fleet, validating: true };
291
+ },
292
+ );
293
+
294
+ // -----------------------------------------------------------------------
295
+ // POST /api/fleet-deployments/:id/execute — start rollout execution
296
+ // -----------------------------------------------------------------------
297
+ app.post<{ Params: { id: string } }>(
298
+ "/api/fleet-deployments/:id/execute",
299
+ { preHandler: [requireEdition("fleet-deployments"), requirePermission("deployment.approve")] },
300
+ async (request, reply) => {
301
+ const fleet = fleetStore.getById(request.params.id);
302
+ if (!fleet) {
303
+ return reply.status(404).send({ error: "Fleet deployment not found" });
304
+ }
305
+
306
+ if (fleet.status !== "validated" && fleet.status !== "validation_failed" && fleet.status !== "paused") {
307
+ return reply.status(409).send({
308
+ error: `Cannot execute fleet deployment in "${fleet.status}" status — must be "validated", "validation_failed", or "paused"`,
309
+ });
310
+ }
311
+
312
+ const plan = fleet.representativePlanId
313
+ ? deploymentStore.get(fleet.representativePlanId)?.plan
314
+ : undefined;
315
+
316
+ if (!plan) {
317
+ return reply.status(422).send({ error: "No plan found for execution" });
318
+ }
319
+
320
+ const rollbackPlan = fleet.representativePlanId
321
+ ? deploymentStore.get(fleet.representativePlanId)?.rollbackPlan
322
+ : undefined;
323
+
324
+ fleet.status = "executing";
325
+ fleetStore.update(fleet);
326
+
327
+ const actor = (request.user?.email) ?? "anonymous";
328
+
329
+ debrief.record({
330
+ partitionId: null,
331
+ deploymentId: fleet.id,
332
+ agent: "server",
333
+ decisionType: "system",
334
+ decision: `Fleet rollout started by ${actor}`,
335
+ reasoning: `Executing ${fleet.rolloutConfig.strategy} rollout across ${fleet.progress.totalEnvoys} envoys.`,
336
+ context: { strategy: fleet.rolloutConfig.strategy, totalEnvoys: fleet.progress.totalEnvoys },
337
+ actor: request.user?.email,
338
+ });
339
+
340
+ // Execute rollout asynchronously
341
+ (async () => {
342
+ const startTime = Date.now();
343
+ try {
344
+ for await (const event of fleetExecutor.executeRollout(fleet, plan, rollbackPlan)) {
345
+ // Update fleet progress from each event
346
+ fleet.progress = event.progress;
347
+
348
+ if (event.type === "fleet-completed") {
349
+ fleet.status = "completed";
350
+ } else if (event.type === "fleet-failed") {
351
+ fleet.status = "failed";
352
+ } else if (event.type === "fleet-paused") {
353
+ fleet.status = "paused";
354
+ }
355
+
356
+ fleetStore.update(fleet);
357
+
358
+ // Record significant events in debrief
359
+ if (event.type === "envoy-failed") {
360
+ debrief.record({
361
+ partitionId: null,
362
+ deploymentId: fleet.id,
363
+ agent: "server",
364
+ decisionType: "deployment-failure",
365
+ decision: `Envoy ${event.envoyName ?? event.envoyId} failed during fleet rollout`,
366
+ reasoning: event.error ?? "Unknown error",
367
+ context: { envoyId: event.envoyId, batchIndex: event.batchIndex, error: event.error },
368
+ });
369
+ } else if (event.type === "fleet-completed") {
370
+ debrief.record({
371
+ partitionId: null,
372
+ deploymentId: fleet.id,
373
+ agent: "server",
374
+ decisionType: "deployment-completion",
375
+ decision: `Fleet rollout completed: ${event.progress.succeeded}/${event.progress.totalEnvoys} succeeded`,
376
+ reasoning: `Rollout finished with ${event.progress.failed} failure(s).`,
377
+ context: { succeeded: event.progress.succeeded, failed: event.progress.failed, total: event.progress.totalEnvoys },
378
+ });
379
+ } else if (event.type === "fleet-failed") {
380
+ debrief.record({
381
+ partitionId: null,
382
+ deploymentId: fleet.id,
383
+ agent: "server",
384
+ decisionType: "deployment-failure",
385
+ decision: `Fleet rollout halted: failure threshold reached`,
386
+ reasoning: event.error ?? "Failure count exceeded haltOnFailureCount",
387
+ context: { succeeded: event.progress.succeeded, failed: event.progress.failed },
388
+ });
389
+ }
390
+ }
391
+
392
+ // Fleet-level summary debrief entry
393
+ const durationMs = Date.now() - startTime;
394
+ const durationSec = Math.round(durationMs / 1000);
395
+ debrief.record({
396
+ partitionId: null,
397
+ deploymentId: fleet.id,
398
+ agent: "server",
399
+ decisionType: "deployment-completion",
400
+ decision: `Fleet deployment ${fleet.status}: ${fleet.progress.succeeded}/${fleet.progress.totalEnvoys} envoys succeeded, ${fleet.progress.failed} failed`,
401
+ reasoning: `Strategy: ${fleet.rolloutConfig.strategy}. Total duration: ${durationSec}s.`,
402
+ context: {
403
+ succeeded: fleet.progress.succeeded,
404
+ failed: fleet.progress.failed,
405
+ total: fleet.progress.totalEnvoys,
406
+ strategy: fleet.rolloutConfig.strategy,
407
+ durationMs,
408
+ finalStatus: fleet.status,
409
+ },
410
+ });
411
+ } catch (err) {
412
+ const durationMs = Date.now() - startTime;
413
+ const durationSec = Math.round(durationMs / 1000);
414
+
415
+ fleet.status = "failed";
416
+ fleetStore.update(fleet);
417
+
418
+ debrief.record({
419
+ partitionId: null,
420
+ deploymentId: fleet.id,
421
+ agent: "server",
422
+ decisionType: "deployment-failure",
423
+ decision: `Fleet rollout failed with unexpected error after ${durationSec}s`,
424
+ reasoning: err instanceof Error ? err.message : String(err),
425
+ context: { error: err instanceof Error ? err.message : String(err), durationMs },
426
+ });
427
+ }
428
+ })();
429
+
430
+ return { fleetDeployment: fleet, executing: true };
431
+ },
432
+ );
433
+
434
+ // -----------------------------------------------------------------------
435
+ // POST /api/fleet-deployments/:id/pause — pause rollout
436
+ // -----------------------------------------------------------------------
437
+ app.post<{ Params: { id: string } }>(
438
+ "/api/fleet-deployments/:id/pause",
439
+ { preHandler: [requireEdition("fleet-deployments"), requirePermission("deployment.approve")] },
440
+ async (request, reply) => {
441
+ const fleet = fleetStore.getById(request.params.id);
442
+ if (!fleet) {
443
+ return reply.status(404).send({ error: "Fleet deployment not found" });
444
+ }
445
+
446
+ if (fleet.status !== "executing") {
447
+ return reply.status(409).send({
448
+ error: `Cannot pause fleet deployment in "${fleet.status}" status — must be "executing"`,
449
+ });
450
+ }
451
+
452
+ fleet.status = "paused";
453
+ fleetStore.update(fleet);
454
+
455
+ const actor = (request.user?.email) ?? "anonymous";
456
+
457
+ debrief.record({
458
+ partitionId: null,
459
+ deploymentId: fleet.id,
460
+ agent: "server",
461
+ decisionType: "system",
462
+ decision: `Fleet rollout paused by ${actor}`,
463
+ reasoning: `Paused at batch ${(fleet.progress.currentBatch ?? 0) + 1}/${fleet.progress.totalBatches ?? "?"}. ${fleet.progress.succeeded} succeeded, ${fleet.progress.failed} failed so far.`,
464
+ context: { pausedBy: actor, progress: fleet.progress },
465
+ actor: request.user?.email,
466
+ });
467
+
468
+ return { fleetDeployment: fleet, paused: true };
469
+ },
470
+ );
471
+
472
+ // -----------------------------------------------------------------------
473
+ // POST /api/fleet-deployments/:id/resume — resume paused rollout
474
+ // -----------------------------------------------------------------------
475
+ app.post<{ Params: { id: string } }>(
476
+ "/api/fleet-deployments/:id/resume",
477
+ { preHandler: [requireEdition("fleet-deployments"), requirePermission("deployment.approve")] },
478
+ async (request, reply) => {
479
+ const fleet = fleetStore.getById(request.params.id);
480
+ if (!fleet) {
481
+ return reply.status(404).send({ error: "Fleet deployment not found" });
482
+ }
483
+
484
+ if (fleet.status !== "paused") {
485
+ return reply.status(409).send({
486
+ error: `Cannot resume fleet deployment in "${fleet.status}" status — must be "paused"`,
487
+ });
488
+ }
489
+
490
+ // Transition back to validated so the execute endpoint can be called again
491
+ fleet.status = "validated";
492
+ fleetStore.update(fleet);
493
+
494
+ const actor = (request.user?.email) ?? "anonymous";
495
+
496
+ debrief.record({
497
+ partitionId: null,
498
+ deploymentId: fleet.id,
499
+ agent: "server",
500
+ decisionType: "system",
501
+ decision: `Fleet rollout resumed by ${actor}`,
502
+ reasoning: "Fleet deployment transitioned back to validated for re-execution.",
503
+ context: { resumedBy: actor },
504
+ actor: request.user?.email,
505
+ });
506
+
507
+ return { fleetDeployment: fleet, resumed: true };
508
+ },
509
+ );
510
+ }