@synth-deploy/server 0.1.0 → 1.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 (96) hide show
  1. package/dist/agent/envoy-client.d.ts +62 -7
  2. package/dist/agent/envoy-client.d.ts.map +1 -1
  3. package/dist/agent/envoy-client.js +56 -6
  4. package/dist/agent/envoy-client.js.map +1 -1
  5. package/dist/agent/stale-deployment-detector.js +1 -1
  6. package/dist/agent/stale-deployment-detector.js.map +1 -1
  7. package/dist/agent/synth-agent.d.ts +7 -5
  8. package/dist/agent/synth-agent.d.ts.map +1 -1
  9. package/dist/agent/synth-agent.js +42 -39
  10. package/dist/agent/synth-agent.js.map +1 -1
  11. package/dist/alert-webhooks/alert-parsers.d.ts +21 -0
  12. package/dist/alert-webhooks/alert-parsers.d.ts.map +1 -0
  13. package/dist/alert-webhooks/alert-parsers.js +184 -0
  14. package/dist/alert-webhooks/alert-parsers.js.map +1 -0
  15. package/dist/api/agent.d.ts +0 -6
  16. package/dist/api/agent.d.ts.map +1 -1
  17. package/dist/api/agent.js +6 -459
  18. package/dist/api/agent.js.map +1 -1
  19. package/dist/api/alert-webhooks.d.ts +13 -0
  20. package/dist/api/alert-webhooks.d.ts.map +1 -0
  21. package/dist/api/alert-webhooks.js +279 -0
  22. package/dist/api/alert-webhooks.js.map +1 -0
  23. package/dist/api/envoy-reports.js +2 -2
  24. package/dist/api/envoy-reports.js.map +1 -1
  25. package/dist/api/envoys.js +1 -1
  26. package/dist/api/envoys.js.map +1 -1
  27. package/dist/api/fleet.d.ts.map +1 -1
  28. package/dist/api/fleet.js +14 -15
  29. package/dist/api/fleet.js.map +1 -1
  30. package/dist/api/graph.js +3 -3
  31. package/dist/api/graph.js.map +1 -1
  32. package/dist/api/operations.d.ts +7 -0
  33. package/dist/api/operations.d.ts.map +1 -0
  34. package/dist/api/operations.js +1883 -0
  35. package/dist/api/operations.js.map +1 -0
  36. package/dist/api/partitions.js +1 -1
  37. package/dist/api/partitions.js.map +1 -1
  38. package/dist/api/schemas.d.ts +194 -10
  39. package/dist/api/schemas.d.ts.map +1 -1
  40. package/dist/api/schemas.js +38 -5
  41. package/dist/api/schemas.js.map +1 -1
  42. package/dist/api/system.d.ts.map +1 -1
  43. package/dist/api/system.js +22 -21
  44. package/dist/api/system.js.map +1 -1
  45. package/dist/artifact-analyzer.js +2 -2
  46. package/dist/artifact-analyzer.js.map +1 -1
  47. package/dist/fleet/fleet-executor.js +1 -1
  48. package/dist/fleet/fleet-executor.js.map +1 -1
  49. package/dist/graph/graph-executor.js +2 -2
  50. package/dist/graph/graph-executor.js.map +1 -1
  51. package/dist/index.js +44 -40
  52. package/dist/index.js.map +1 -1
  53. package/dist/mcp/resources.js +3 -3
  54. package/dist/mcp/resources.js.map +1 -1
  55. package/dist/mcp/tools.d.ts.map +1 -1
  56. package/dist/mcp/tools.js +2 -9
  57. package/dist/mcp/tools.js.map +1 -1
  58. package/dist/middleware/auth.js +1 -1
  59. package/dist/middleware/auth.js.map +1 -1
  60. package/package.json +1 -1
  61. package/src/agent/envoy-client.ts +107 -15
  62. package/src/agent/stale-deployment-detector.ts +1 -1
  63. package/src/agent/synth-agent.ts +59 -45
  64. package/src/alert-webhooks/alert-parsers.ts +291 -0
  65. package/src/api/agent.ts +9 -528
  66. package/src/api/alert-webhooks.ts +354 -0
  67. package/src/api/envoy-reports.ts +2 -2
  68. package/src/api/envoys.ts +1 -1
  69. package/src/api/fleet.ts +14 -15
  70. package/src/api/graph.ts +3 -3
  71. package/src/api/operations.ts +2240 -0
  72. package/src/api/partitions.ts +1 -1
  73. package/src/api/schemas.ts +43 -7
  74. package/src/api/system.ts +23 -21
  75. package/src/artifact-analyzer.ts +2 -2
  76. package/src/fleet/fleet-executor.ts +1 -1
  77. package/src/graph/graph-executor.ts +2 -2
  78. package/src/index.ts +46 -40
  79. package/src/mcp/resources.ts +3 -3
  80. package/src/mcp/tools.ts +5 -9
  81. package/src/middleware/auth.ts +1 -1
  82. package/tests/agent-mode.test.ts +5 -376
  83. package/tests/api-handlers.test.ts +27 -27
  84. package/tests/composite-operations.test.ts +557 -0
  85. package/tests/decision-diary.test.ts +62 -63
  86. package/tests/diary-reader.test.ts +14 -18
  87. package/tests/mcp-tools.test.ts +1 -1
  88. package/tests/orchestration.test.ts +34 -30
  89. package/tests/partition-isolation.test.ts +4 -9
  90. package/tests/rbac-enforcement.test.ts +8 -8
  91. package/tests/ui-journey.test.ts +9 -9
  92. package/dist/api/deployments.d.ts +0 -11
  93. package/dist/api/deployments.d.ts.map +0 -1
  94. package/dist/api/deployments.js +0 -1098
  95. package/dist/api/deployments.js.map +0 -1
  96. package/src/api/deployments.ts +0 -1347
@@ -1,1347 +0,0 @@
1
- import type { FastifyInstance } from "fastify";
2
- import { generatePostmortem, generatePostmortemAsync } from "@synth-deploy/core";
3
- import type { LlmClient, IPartitionStore, IEnvironmentStore, IArtifactStore, ISettingsStore, IDeploymentStore, ITelemetryStore, DebriefWriter, DebriefReader, DeploymentEnrichment, RecommendationVerdict } from "@synth-deploy/core";
4
- import { requirePermission } from "../middleware/permissions.js";
5
- import {
6
- CreateDeploymentSchema,
7
- ApproveDeploymentSchema,
8
- RejectDeploymentSchema,
9
- ModifyDeploymentPlanSchema,
10
- SubmitPlanSchema,
11
- DeploymentListQuerySchema,
12
- DebriefQuerySchema,
13
- ProgressEventSchema,
14
- ReplanDeploymentSchema,
15
- } from "./schemas.js";
16
- import type { ProgressEventStore } from "./progress-event-store.js";
17
- import { EnvoyClient } from "../agent/envoy-client.js";
18
- import type { EnvoyRegistry } from "../agent/envoy-registry.js";
19
-
20
- /**
21
- * REST API routes for deployments. These are the traditional (non-MCP) interface
22
- * for the web UI and integrations.
23
- */
24
- export function registerDeploymentRoutes(
25
- app: FastifyInstance,
26
- deployments: IDeploymentStore,
27
- debrief: DebriefWriter & DebriefReader,
28
- partitions: IPartitionStore,
29
- environments: IEnvironmentStore,
30
- artifactStore: IArtifactStore,
31
- settings: ISettingsStore,
32
- telemetry: ITelemetryStore,
33
- progressStore?: ProgressEventStore,
34
- envoyClient?: EnvoyClient,
35
- envoyRegistry?: EnvoyRegistry,
36
- llm?: LlmClient,
37
- ): void {
38
- // Create a deployment (plan phase)
39
- app.post("/api/deployments", { preHandler: [requirePermission("deployment.create")] }, async (request, reply) => {
40
- const parsed = CreateDeploymentSchema.safeParse(request.body);
41
- if (!parsed.success) {
42
- return reply.status(400).send({ error: parsed.error.message });
43
- }
44
-
45
- const { artifactId, environmentId, partitionId, envoyId, version } = parsed.data;
46
-
47
- // Validate artifact exists
48
- const artifact = artifactStore.get(artifactId);
49
- if (!artifact) {
50
- return reply.status(404).send({ error: `Artifact not found: ${artifactId}` });
51
- }
52
-
53
- // Validate environment exists (optional when targeting a partition or envoy)
54
- const environment = environmentId ? environments.get(environmentId) : undefined;
55
- if (environmentId && !environment) {
56
- return reply.status(404).send({ error: `Environment not found: ${environmentId}` });
57
- }
58
-
59
- // Validate partition if provided
60
- const partition = partitionId ? partitions.get(partitionId) : undefined;
61
- if (partitionId && !partition) {
62
- return reply.status(404).send({ error: `Partition not found: ${partitionId}` });
63
- }
64
-
65
- // Validate envoy if provided
66
- const targetEnvoy = envoyId ? envoyRegistry?.get(envoyId) : undefined;
67
- if (envoyId && !targetEnvoy) {
68
- return reply.status(404).send({ error: `Envoy not found: ${envoyId}` });
69
- }
70
-
71
- // Resolve variables — partition vars are base, environment vars take precedence if present
72
- const envVars = environment ? environment.variables : {};
73
- const partitionVars = partition?.variables ?? {};
74
- const resolved: Record<string, string> = { ...partitionVars, ...envVars };
75
-
76
- const deployment = {
77
- id: crypto.randomUUID(),
78
- artifactId,
79
- environmentId,
80
- partitionId,
81
- envoyId: targetEnvoy?.id,
82
- version: version ?? "",
83
- status: "pending" as const,
84
- variables: resolved,
85
- debriefEntryIds: [] as string[],
86
- createdAt: new Date(),
87
- };
88
-
89
- deployments.save(deployment);
90
- telemetry.record({ actor: (request.user?.email) ?? "anonymous", action: "deployment.created", target: { type: "deployment", id: deployment.id }, details: { artifactId, environmentId, partitionId, envoyId } });
91
-
92
- // Dispatch planning to the appropriate envoy asynchronously.
93
- // The envoy reasons about the deployment (read-only) and POSTs back a plan,
94
- // which transitions the deployment to awaiting_approval.
95
- if (envoyRegistry) {
96
- // Find the target envoy: explicit envoyId > environment-assigned > first available
97
- const planningEnvoy = targetEnvoy
98
- ?? (environment ? envoyRegistry.findForEnvironment(environment.name) : undefined)
99
- ?? envoyRegistry.list()[0];
100
-
101
- if (planningEnvoy) {
102
- const planningClient = new EnvoyClient(planningEnvoy.url);
103
- const environmentForPlanning = environment
104
- ? { id: environment.id, name: environment.name, variables: environment.variables }
105
- : { id: `direct:${planningEnvoy.id}`, name: planningEnvoy.name, variables: {} };
106
-
107
- planningClient.requestPlan({
108
- deploymentId: deployment.id,
109
- artifact: {
110
- id: artifact.id,
111
- name: artifact.name,
112
- type: artifact.type,
113
- analysis: {
114
- summary: artifact.analysis.summary,
115
- dependencies: artifact.analysis.dependencies,
116
- configurationExpectations: artifact.analysis.configurationExpectations,
117
- deploymentIntent: artifact.analysis.deploymentIntent,
118
- confidence: artifact.analysis.confidence,
119
- },
120
- },
121
- environment: environmentForPlanning,
122
- partition: partition
123
- ? { id: partition.id, name: partition.name, variables: partition.variables }
124
- : undefined,
125
- version: deployment.version,
126
- resolvedVariables: resolved,
127
- }).then((result) => {
128
- const dep = deployments.get(deployment.id);
129
- if (!dep || dep.status !== "pending") return;
130
-
131
- dep.plan = result.plan;
132
- dep.rollbackPlan = result.rollbackPlan;
133
- dep.envoyId = planningEnvoy.id;
134
-
135
- if (result.blocked) {
136
- // Unrecoverable precondition failures — block execution, do not present for approval
137
- dep.status = "failed" as typeof dep.status;
138
- dep.failureReason = result.blockReason ?? "Plan blocked due to unrecoverable precondition failures";
139
- deployments.save(dep);
140
-
141
- debrief.record({
142
- partitionId: dep.partitionId ?? null,
143
- deploymentId: dep.id,
144
- agent: "envoy",
145
- decisionType: "plan-generation" as Parameters<typeof debrief.record>[0]["decisionType"],
146
- decision: `Deployment plan blocked — infrastructure prerequisites not met`,
147
- reasoning: result.blockReason ?? result.plan.reasoning,
148
- context: { stepCount: result.plan.steps.length, envoyId: planningEnvoy.id, blocked: true },
149
- });
150
- } else {
151
- // Plan is valid — transition to awaiting_approval
152
- dep.status = "awaiting_approval" as typeof dep.status;
153
- dep.recommendation = computeRecommendation(dep, deployments, result.assessmentSummary);
154
- deployments.save(dep);
155
-
156
- debrief.record({
157
- partitionId: dep.partitionId ?? null,
158
- deploymentId: dep.id,
159
- agent: "envoy",
160
- decisionType: "plan-generation" as Parameters<typeof debrief.record>[0]["decisionType"],
161
- decision: `Deployment plan generated with ${result.plan.steps.length} steps`,
162
- reasoning: result.plan.reasoning,
163
- context: { stepCount: result.plan.steps.length, envoyId: planningEnvoy.id, delta: result.delta },
164
- });
165
- }
166
- }).catch((err) => {
167
- // Planning failed — mark deployment failed so UI doesn't wait forever
168
- const dep = deployments.get(deployment.id);
169
- if (!dep || dep.status !== "pending") return;
170
-
171
- dep.status = "failed" as typeof dep.status;
172
- dep.failureReason = err instanceof Error ? err.message : "Planning failed";
173
- deployments.save(dep);
174
-
175
- debrief.record({
176
- partitionId: dep.partitionId ?? null,
177
- deploymentId: dep.id,
178
- agent: "server",
179
- decisionType: "deployment-failure" as Parameters<typeof debrief.record>[0]["decisionType"],
180
- decision: "Envoy planning failed",
181
- reasoning: dep.failureReason!,
182
- context: { error: dep.failureReason, envoyId: planningEnvoy.id },
183
- });
184
- });
185
- }
186
- }
187
-
188
- return reply.status(201).send({ deployment });
189
- });
190
-
191
- // Get deployment by ID
192
- app.get<{ Params: { id: string } }>("/api/deployments/:id", { preHandler: [requirePermission("deployment.view")] }, async (request, reply) => {
193
- const deployment = deployments.get(request.params.id);
194
- if (!deployment) {
195
- return reply.status(404).send({ error: "Deployment not found" });
196
- }
197
-
198
- return {
199
- deployment,
200
- debrief: debrief.getByDeployment(deployment.id),
201
- };
202
- });
203
-
204
- // What's New — compare deployed artifact version against catalog latest
205
- app.get<{ Params: { id: string } }>("/api/deployments/:id/whats-new", { preHandler: [requirePermission("deployment.view")] }, async (request, reply) => {
206
- const deployment = deployments.get(request.params.id);
207
- if (!deployment) {
208
- return reply.status(404).send({ error: "Deployment not found" });
209
- }
210
-
211
- const versions = artifactStore.getVersions(deployment.artifactId);
212
- const sorted = versions.slice().sort(
213
- (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
214
- );
215
- const latest = sorted[0] ?? null;
216
- const deployedVersion = deployment.version;
217
- const latestVersion = latest?.version ?? null;
218
- const isLatest = latestVersion === null || latestVersion === deployedVersion;
219
-
220
- return {
221
- deployedVersion,
222
- latestVersion,
223
- isLatest,
224
- latestCreatedAt: latest?.createdAt ? new Date(latest.createdAt).toISOString() : null,
225
- };
226
- });
227
-
228
- // List deployments (optionally filtered by partition, artifact, or envoy)
229
- app.get("/api/deployments", { preHandler: [requirePermission("deployment.view")] }, async (request) => {
230
- const qParsed = DeploymentListQuerySchema.safeParse(request.query);
231
- const { partitionId, artifactId, envoyId } = qParsed.success ? qParsed.data : {};
232
-
233
- let list;
234
- if (partitionId) {
235
- list = deployments.getByPartition(partitionId);
236
- } else if (artifactId) {
237
- list = deployments.getByArtifact(artifactId);
238
- } else {
239
- list = deployments.list();
240
- }
241
-
242
- if (envoyId) {
243
- list = list.filter((d) => d.envoyId === envoyId);
244
- }
245
-
246
- return { deployments: list };
247
- });
248
-
249
- // Submit a plan from envoy — transitions deployment to awaiting_approval
250
- app.post<{ Params: { id: string } }>(
251
- "/api/deployments/:id/plan",
252
- { preHandler: [requirePermission("deployment.create")] },
253
- async (request, reply) => {
254
- const deployment = deployments.get(request.params.id);
255
- if (!deployment) {
256
- return reply.status(404).send({ error: "Deployment not found" });
257
- }
258
-
259
- const parsed = SubmitPlanSchema.safeParse(request.body);
260
- if (!parsed.success) {
261
- return reply.status(400).send({ error: "Invalid plan submission", details: parsed.error.format() });
262
- }
263
-
264
- if ((deployment.status as string) !== "pending" && (deployment.status as string) !== "planning") {
265
- return reply.status(409).send({ error: `Cannot submit plan for deployment in "${deployment.status}" status` });
266
- }
267
-
268
- deployment.plan = parsed.data.plan;
269
- deployment.rollbackPlan = parsed.data.rollbackPlan;
270
- deployment.status = "awaiting_approval" as typeof deployment.status;
271
-
272
- // Generate recommendation from enrichment context
273
- deployment.recommendation = computeRecommendation(deployment, deployments);
274
-
275
- deployments.save(deployment);
276
-
277
- debrief.record({
278
- partitionId: deployment.partitionId ?? null,
279
- deploymentId: deployment.id,
280
- agent: "envoy",
281
- decisionType: "plan-generation" as Parameters<typeof debrief.record>[0]["decisionType"],
282
- decision: `Deployment plan submitted with ${parsed.data.plan.steps.length} steps`,
283
- reasoning: parsed.data.plan.reasoning,
284
- context: { stepCount: parsed.data.plan.steps.length },
285
- });
286
-
287
- return reply.status(200).send({ deployment });
288
- },
289
- );
290
-
291
- // Approve a deployment plan
292
- app.post<{ Params: { id: string } }>(
293
- "/api/deployments/:id/approve",
294
- { preHandler: [requirePermission("deployment.approve")] },
295
- async (request, reply) => {
296
- const deployment = deployments.get(request.params.id);
297
- if (!deployment) {
298
- return reply.status(404).send({ error: "Deployment not found" });
299
- }
300
-
301
- const parsed = ApproveDeploymentSchema.safeParse(request.body);
302
- if (!parsed.success) {
303
- return reply.status(400).send({ error: parsed.error.message });
304
- }
305
-
306
- if ((deployment.status as string) !== "awaiting_approval") {
307
- return reply.status(409).send({ error: `Cannot approve deployment in "${deployment.status}" status — must be "awaiting_approval"` });
308
- }
309
-
310
- // Transition deployment status
311
- deployment.approvedBy = parsed.data.approvedBy;
312
- deployment.approvedAt = new Date();
313
- deployment.status = "approved" as typeof deployment.status;
314
- deployments.save(deployment);
315
-
316
- const actor = (request.user?.email) ?? parsed.data.approvedBy;
317
-
318
- // Record approval in debrief
319
- debrief.record({
320
- partitionId: deployment.partitionId ?? null,
321
- deploymentId: deployment.id,
322
- agent: "server",
323
- decisionType: "system",
324
- decision: `Deployment approved by ${actor}`,
325
- reasoning: parsed.data.modifications
326
- ? `Approved with modifications: ${parsed.data.modifications}`
327
- : "Approved without modifications",
328
- context: { approvedBy: actor },
329
- actor: request.user?.email,
330
- });
331
- telemetry.record({ actor, action: "deployment.approved", target: { type: "deployment", id: deployment.id }, details: { modifications: parsed.data.modifications } });
332
- telemetry.record({
333
- actor,
334
- action: parsed.data.modifications ? "agent.recommendation.overridden" : "agent.recommendation.followed",
335
- target: { type: "deployment", id: deployment.id },
336
- details: parsed.data.modifications
337
- ? { modifications: parsed.data.modifications }
338
- : { planStepCount: deployment.plan?.steps.length ?? 0 },
339
- });
340
-
341
- // Dispatch approved plan to envoy for execution
342
- if (envoyClient && deployment.plan && deployment.rollbackPlan) {
343
- const artifact = artifactStore.get(deployment.artifactId);
344
- const serverPort = process.env.PORT ?? "9410";
345
- const serverUrl = process.env.SYNTH_SERVER_URL ?? `http://localhost:${serverPort}`;
346
- const progressCallbackUrl = `${serverUrl}/api/deployments/${deployment.id}/progress`;
347
- const callbackToken = envoyRegistry?.list().find(r => r.url === envoyClient.url)?.token;
348
-
349
- deployment.status = "running" as typeof deployment.status;
350
- deployments.save(deployment);
351
-
352
- // Fire-and-forget: execution runs async, progress comes via callback
353
- envoyClient.executeApprovedPlan({
354
- deploymentId: deployment.id,
355
- plan: deployment.plan,
356
- rollbackPlan: deployment.rollbackPlan,
357
- artifactType: artifact?.type ?? "unknown",
358
- artifactName: artifact?.name ?? "unknown",
359
- environmentId: deployment.environmentId ?? "",
360
- progressCallbackUrl,
361
- callbackToken,
362
- }).catch((err) => {
363
- // Execution dispatch failed — record failure
364
- deployment.status = "failed" as typeof deployment.status;
365
- deployment.failureReason = err instanceof Error ? err.message : "Execution dispatch failed";
366
- deployments.save(deployment);
367
-
368
- debrief.record({
369
- partitionId: deployment.partitionId ?? null,
370
- deploymentId: deployment.id,
371
- agent: "server",
372
- decisionType: "deployment-failure" as Parameters<typeof debrief.record>[0]["decisionType"],
373
- decision: "Failed to dispatch approved plan to envoy",
374
- reasoning: deployment.failureReason!,
375
- context: { error: deployment.failureReason },
376
- });
377
- });
378
- }
379
-
380
- return { deployment, approved: true };
381
- },
382
- );
383
-
384
- // Reject a deployment plan
385
- app.post<{ Params: { id: string } }>(
386
- "/api/deployments/:id/reject",
387
- { preHandler: [requirePermission("deployment.reject")] },
388
- async (request, reply) => {
389
- const deployment = deployments.get(request.params.id);
390
- if (!deployment) {
391
- return reply.status(404).send({ error: "Deployment not found" });
392
- }
393
-
394
- const parsed = RejectDeploymentSchema.safeParse(request.body);
395
- if (!parsed.success) {
396
- return reply.status(400).send({ error: parsed.error.message });
397
- }
398
-
399
- if ((deployment.status as string) !== "awaiting_approval") {
400
- return reply.status(409).send({ error: `Cannot reject deployment in "${deployment.status}" status — must be "awaiting_approval"` });
401
- }
402
-
403
- // Transition deployment status and store rejection reason
404
- deployment.status = "rejected" as typeof deployment.status;
405
- deployment.rejectionReason = parsed.data.reason;
406
- deployments.save(deployment);
407
-
408
- const actor = (request.user?.email) ?? "anonymous";
409
-
410
- // Record rejection in debrief
411
- debrief.record({
412
- partitionId: deployment.partitionId ?? null,
413
- deploymentId: deployment.id,
414
- agent: "server",
415
- decisionType: "system",
416
- decision: "Deployment plan rejected",
417
- reasoning: parsed.data.reason,
418
- context: { reason: parsed.data.reason },
419
- actor: request.user?.email,
420
- });
421
- telemetry.record({ actor, action: "deployment.rejected", target: { type: "deployment", id: deployment.id }, details: { reason: parsed.data.reason } });
422
-
423
- return { deployment, rejected: true };
424
- },
425
- );
426
-
427
- // Modify a deployment plan (user edits steps before approval)
428
- app.post<{ Params: { id: string } }>(
429
- "/api/deployments/:id/modify",
430
- { preHandler: [requirePermission("deployment.approve")] },
431
- async (request, reply) => {
432
- const deployment = deployments.get(request.params.id);
433
- if (!deployment) {
434
- return reply.status(404).send({ error: "Deployment not found" });
435
- }
436
-
437
- const parsed = ModifyDeploymentPlanSchema.safeParse(request.body);
438
- if (!parsed.success) {
439
- return reply.status(400).send({ error: parsed.error.message });
440
- }
441
-
442
- if ((deployment.status as string) !== "awaiting_approval") {
443
- return reply.status(409).send({ error: `Cannot modify deployment in "${deployment.status}" status — must be "awaiting_approval"` });
444
- }
445
-
446
- if (!deployment.plan) {
447
- return reply.status(409).send({ error: "Deployment has no plan to modify" });
448
- }
449
-
450
- // Validate modified plan with envoy if available
451
- if (envoyClient) {
452
- try {
453
- const validation = await envoyClient.validatePlan(parsed.data.steps);
454
- if (!validation.valid) {
455
- return reply.status(422).send({
456
- error: "Modified plan failed envoy validation",
457
- violations: validation.violations,
458
- });
459
- }
460
- } catch {
461
- // Envoy unreachable — proceed without validation but note it
462
- }
463
- }
464
-
465
- // Build structured diff: what changed between old and new steps
466
- const oldSteps = deployment.plan.steps;
467
- const newSteps = parsed.data.steps;
468
- const diffLines: string[] = [];
469
- const maxLen = Math.max(oldSteps.length, newSteps.length);
470
- for (let i = 0; i < maxLen; i++) {
471
- const old = oldSteps[i];
472
- const cur = newSteps[i];
473
- if (!old) {
474
- diffLines.push(`+ Step ${i + 1} (added): ${cur.action} ${cur.target} — ${cur.description}`);
475
- } else if (!cur) {
476
- diffLines.push(`- Step ${i + 1} (removed): ${old.action} ${old.target} — ${old.description}`);
477
- } else if (old.action !== cur.action || old.target !== cur.target || old.description !== cur.description) {
478
- diffLines.push(`~ Step ${i + 1} (changed): ${old.action} ${old.target} → ${cur.action} ${cur.target}`);
479
- if (old.description !== cur.description) {
480
- diffLines.push(` was: ${old.description}`);
481
- diffLines.push(` now: ${cur.description}`);
482
- }
483
- }
484
- }
485
- const diffFromPreviousPlan = diffLines.length > 0
486
- ? diffLines.join("\n")
487
- : "Steps reordered or metadata changed (actions and targets unchanged)";
488
-
489
- // Apply modifications
490
- deployment.plan = {
491
- ...deployment.plan,
492
- steps: parsed.data.steps,
493
- diffFromPreviousPlan,
494
- };
495
- deployments.save(deployment);
496
-
497
- const actor = (request.user?.email) ?? "anonymous";
498
-
499
- // Record modification in debrief
500
- debrief.record({
501
- partitionId: deployment.partitionId ?? null,
502
- deploymentId: deployment.id,
503
- agent: "server",
504
- decisionType: "plan-modification" as Parameters<typeof debrief.record>[0]["decisionType"],
505
- decision: `Deployment plan modified by ${actor}`,
506
- reasoning: parsed.data.reason,
507
- context: {
508
- modifiedBy: actor,
509
- stepCount: parsed.data.steps.length,
510
- reason: parsed.data.reason,
511
- },
512
- actor: request.user?.email,
513
- });
514
- telemetry.record({
515
- actor,
516
- action: "deployment.modified" as Parameters<typeof telemetry.record>[0]["action"],
517
- target: { type: "deployment", id: deployment.id },
518
- details: { reason: parsed.data.reason, stepCount: parsed.data.steps.length },
519
- });
520
- telemetry.record({
521
- actor,
522
- action: "agent.recommendation.overridden",
523
- target: { type: "deployment", id: deployment.id },
524
- details: { reason: parsed.data.reason, stepCount: parsed.data.steps.length, diff: diffFromPreviousPlan },
525
- });
526
-
527
- return { deployment, modified: true };
528
- },
529
- );
530
-
531
- // Replan a deployment with user feedback — triggers a new LLM planning pass
532
- app.post<{ Params: { id: string } }>(
533
- "/api/deployments/:id/replan",
534
- { preHandler: [requirePermission("deployment.approve")] },
535
- async (request, reply) => {
536
- const deploymentId = request.params.id;
537
- const deployment = deployments.get(deploymentId);
538
- if (!deployment) {
539
- return reply.status(404).send({ error: "Deployment not found" });
540
- }
541
-
542
- if ((deployment.status as string) !== "awaiting_approval") {
543
- return reply.status(409).send({ error: `Cannot replan deployment in "${deployment.status}" status — must be "awaiting_approval"` });
544
- }
545
-
546
- const parsed = ReplanDeploymentSchema.safeParse(request.body);
547
- if (!parsed.success) {
548
- return reply.status(400).send({ error: parsed.error.message });
549
- }
550
-
551
- const artifact = artifactStore.get(deployment.artifactId);
552
- if (!artifact) {
553
- return reply.status(404).send({ error: `Artifact not found: ${deployment.artifactId}` });
554
- }
555
-
556
- const environment = deployment.environmentId ? environments.get(deployment.environmentId) : undefined;
557
- const partition = deployment.partitionId ? partitions.get(deployment.partitionId) : undefined;
558
-
559
- const planningEnvoy = deployment.envoyId ? envoyRegistry?.get(deployment.envoyId) : envoyRegistry?.list()[0];
560
- if (!planningEnvoy) {
561
- return reply.status(422).send({ error: "No envoy available for replanning" });
562
- }
563
-
564
- // Validate feedback with LLM before triggering expensive replan
565
- const planningClientForValidation = new EnvoyClient(planningEnvoy.url);
566
- try {
567
- const validation = await planningClientForValidation.validateRefinementFeedback({
568
- feedback: parsed.data.feedback,
569
- currentPlanSteps: (deployment.plan?.steps ?? []).map((s) => ({
570
- description: s.description,
571
- action: s.action,
572
- target: s.target,
573
- })),
574
- artifactName: artifact?.name ?? "unknown",
575
- environmentName: environment?.name ?? "unknown",
576
- });
577
- if (validation.mode === "rejection") {
578
- return reply.status(422).send({ error: validation.message, mode: "rejection" });
579
- }
580
- if (validation.mode === "response") {
581
- return reply.status(200).send({ mode: "response", message: validation.message });
582
- }
583
- // mode === "replan" — fall through to full replan
584
- } catch {
585
- // Validation call failed — proceed with replan rather than blocking the user
586
- }
587
-
588
- deployment.status = "planning" as typeof deployment.status;
589
- deployments.save(deployment);
590
-
591
- const planningClient = new EnvoyClient(planningEnvoy.url);
592
- const environmentForPlanning = environment
593
- ? { id: environment.id, name: environment.name, variables: environment.variables }
594
- : { id: `direct:${planningEnvoy.id}`, name: planningEnvoy.name, variables: {} };
595
-
596
- let result: Awaited<ReturnType<typeof planningClient.requestPlan>>;
597
- try {
598
- result = await planningClient.requestPlan({
599
- deploymentId,
600
- artifact: {
601
- id: artifact.id,
602
- name: artifact.name,
603
- type: artifact.type,
604
- analysis: {
605
- summary: artifact.analysis.summary,
606
- dependencies: artifact.analysis.dependencies,
607
- configurationExpectations: artifact.analysis.configurationExpectations,
608
- deploymentIntent: artifact.analysis.deploymentIntent,
609
- confidence: artifact.analysis.confidence,
610
- },
611
- },
612
- environment: environmentForPlanning,
613
- partition: partition
614
- ? { id: partition.id, name: partition.name, variables: partition.variables }
615
- : undefined,
616
- version: deployment.version,
617
- resolvedVariables: deployment.variables,
618
- refinementFeedback: parsed.data.feedback,
619
- });
620
- } catch (err) {
621
- const dep = deployments.get(deploymentId);
622
- if (dep) {
623
- dep.status = "awaiting_approval" as typeof dep.status;
624
- deployments.save(dep);
625
- }
626
- return reply.status(500).send({ error: err instanceof Error ? err.message : "Replanning failed" });
627
- }
628
-
629
- const dep = deployments.get(deploymentId);
630
- if (!dep) {
631
- return reply.status(404).send({ error: "Deployment not found after replanning" });
632
- }
633
-
634
- dep.plan = result.plan;
635
- dep.rollbackPlan = result.rollbackPlan;
636
- dep.recommendation = computeRecommendation(dep, deployments, result.assessmentSummary);
637
- dep.status = "awaiting_approval" as typeof dep.status;
638
- deployments.save(dep);
639
-
640
- debrief.record({
641
- partitionId: dep.partitionId ?? null,
642
- deploymentId: dep.id,
643
- agent: "envoy",
644
- decisionType: "plan-generation" as Parameters<typeof debrief.record>[0]["decisionType"],
645
- decision: `Plan regenerated with user feedback (${result.plan.steps.length} steps)`,
646
- reasoning: result.plan.reasoning,
647
- context: { stepCount: result.plan.steps.length, envoyId: planningEnvoy.id, refinementFeedback: parsed.data.feedback },
648
- });
649
-
650
- return { deployment: dep, replanned: true };
651
- },
652
- );
653
-
654
- // Get cross-system enrichment context for a deployment
655
- app.get<{ Params: { id: string } }>(
656
- "/api/deployments/:id/context",
657
- { preHandler: [requirePermission("deployment.view")] },
658
- async (request, reply) => {
659
- const deployment = deployments.get(request.params.id);
660
- if (!deployment) {
661
- return reply.status(404).send({ error: "Deployment not found" });
662
- }
663
-
664
- const now = new Date();
665
- const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
666
-
667
- // Count recent deployments to the same environment (only meaningful when environmentId is set)
668
- const recentDeploymentsToEnv = deployment.environmentId
669
- ? deployments.countByEnvironment(deployment.environmentId, twentyFourHoursAgo)
670
- : 0;
671
-
672
- // Check if the same artifact version was previously rolled back
673
- const previouslyRolledBack = deployment.version
674
- ? deployments.findByArtifactVersion(
675
- deployment.artifactId,
676
- deployment.version,
677
- "rolled_back",
678
- ).length > 0
679
- : false;
680
-
681
- // Check for other in-progress deployments to the same environment
682
- const conflictingDeployments = deployment.environmentId
683
- ? deployments.list()
684
- .filter(
685
- (d) =>
686
- d.environmentId === deployment.environmentId &&
687
- d.id !== deployment.id &&
688
- ((d.status as string) === "running" || (d.status as string) === "approved" || (d.status as string) === "awaiting_approval"),
689
- )
690
- .map((d) => d.id)
691
- : [];
692
-
693
- // Find last deployment to the same environment
694
- const lastDeploy = deployment.environmentId
695
- ? deployments.findLatestByEnvironment(deployment.environmentId)
696
- : undefined;
697
- const lastDeploymentToEnv = lastDeploy && lastDeploy.id !== deployment.id
698
- ? {
699
- id: lastDeploy.id,
700
- status: lastDeploy.status,
701
- version: lastDeploy.version,
702
- completedAt: lastDeploy.completedAt,
703
- }
704
- : undefined;
705
-
706
- const enrichment: DeploymentEnrichment = {
707
- recentDeploymentsToEnv,
708
- previouslyRolledBack,
709
- conflictingDeployments,
710
- lastDeploymentToEnv,
711
- };
712
-
713
- return {
714
- enrichment,
715
- recommendation: deployment.recommendation ?? computeRecommendation(deployment, deployments),
716
- };
717
- },
718
- );
719
-
720
- // Request a post-hoc rollback plan — asks the envoy to reason about
721
- // what actually ran and produce a targeted rollback plan
722
- app.post<{ Params: { id: string } }>(
723
- "/api/deployments/:id/request-rollback-plan",
724
- { preHandler: [requirePermission("deployment.approve")] },
725
- async (request, reply) => {
726
- const deployment = deployments.get(request.params.id);
727
- if (!deployment) {
728
- return reply.status(404).send({ error: "Deployment not found" });
729
- }
730
-
731
- const finishedStatuses = new Set(["succeeded", "failed", "rolled_back"]);
732
- if (!finishedStatuses.has(deployment.status as string)) {
733
- return reply.status(409).send({
734
- error: `Cannot request rollback plan for deployment in "${deployment.status}" status — deployment must be finished`,
735
- });
736
- }
737
-
738
- const artifact = artifactStore.get(deployment.artifactId);
739
- if (!artifact) {
740
- return reply.status(404).send({ error: "Artifact not found" });
741
- }
742
-
743
- // Determine which envoy to ask
744
- const targetEnvoy = deployment.envoyId
745
- ? envoyRegistry?.get(deployment.envoyId)
746
- : envoyRegistry?.list()[0];
747
-
748
- if (!targetEnvoy) {
749
- return reply.status(503).send({ error: "No envoy available to generate rollback plan" });
750
- }
751
-
752
- const environment = deployment.environmentId ? environments.get(deployment.environmentId) : undefined;
753
-
754
- // Build the list of completed steps from execution record (or plan as fallback)
755
- const completedSteps: Array<{
756
- description: string;
757
- action: string;
758
- target: string;
759
- status: "completed" | "failed" | "rolled_back";
760
- output?: string;
761
- }> = deployment.executionRecord?.steps.map((s) => ({
762
- description: s.description,
763
- action: deployment.plan?.steps.find((p) => p.description === s.description)?.action ?? "unknown",
764
- target: deployment.plan?.steps.find((p) => p.description === s.description)?.target ?? "",
765
- status: s.status,
766
- output: s.output ?? s.error,
767
- })) ?? deployment.plan?.steps.map((s) => ({
768
- description: s.description,
769
- action: s.action,
770
- target: s.target,
771
- status: "completed" as const,
772
- })) ?? [];
773
-
774
- const rollbackClient = new EnvoyClient(targetEnvoy.url);
775
-
776
- try {
777
- const rollbackPlan = await rollbackClient.requestRollbackPlan({
778
- deploymentId: deployment.id,
779
- artifact: {
780
- name: artifact.name,
781
- type: artifact.type,
782
- analysis: {
783
- summary: artifact.analysis.summary,
784
- dependencies: artifact.analysis.dependencies,
785
- configurationExpectations: artifact.analysis.configurationExpectations,
786
- deploymentIntent: artifact.analysis.deploymentIntent,
787
- confidence: artifact.analysis.confidence,
788
- },
789
- },
790
- environment: {
791
- id: deployment.environmentId ?? "",
792
- name: environment?.name ?? deployment.environmentId ?? "unknown",
793
- },
794
- completedSteps,
795
- deployedVariables: deployment.variables,
796
- version: deployment.version,
797
- failureReason: deployment.failureReason ?? undefined,
798
- });
799
-
800
- // Store the generated rollback plan on the deployment
801
- deployment.rollbackPlan = rollbackPlan;
802
- deployments.save(deployment);
803
-
804
- const actor = (request.user?.email) ?? "anonymous";
805
-
806
- debrief.record({
807
- partitionId: deployment.partitionId ?? null,
808
- deploymentId: deployment.id,
809
- agent: "server",
810
- decisionType: "plan-generation" as Parameters<typeof debrief.record>[0]["decisionType"],
811
- decision: `Rollback plan requested and generated for ${artifact.name} v${deployment.version}`,
812
- reasoning: rollbackPlan.reasoning,
813
- context: {
814
- requestedBy: actor,
815
- stepCount: rollbackPlan.steps.length,
816
- envoyId: targetEnvoy.id,
817
- deploymentStatus: deployment.status,
818
- },
819
- actor: request.user?.email,
820
- });
821
- telemetry.record({
822
- actor,
823
- action: "deployment.rollback-plan-requested" as Parameters<typeof telemetry.record>[0]["action"],
824
- target: { type: "deployment", id: deployment.id },
825
- details: { stepCount: rollbackPlan.steps.length },
826
- });
827
-
828
- return reply.status(200).send({ deployment, rollbackPlan });
829
- } catch (err) {
830
- return reply.status(500).send({
831
- error: "Failed to generate rollback plan",
832
- details: err instanceof Error ? err.message : String(err),
833
- });
834
- }
835
- },
836
- );
837
-
838
- // Execute rollback — runs the stored rollback plan against the envoy
839
- app.post<{ Params: { id: string } }>(
840
- "/api/deployments/:id/execute-rollback",
841
- { preHandler: [requirePermission("deployment.approve")] },
842
- async (request, reply) => {
843
- const deployment = deployments.get(request.params.id);
844
- if (!deployment) {
845
- return reply.status(404).send({ error: "Deployment not found" });
846
- }
847
-
848
- if (!deployment.rollbackPlan) {
849
- return reply.status(409).send({ error: "No rollback plan available — request one first" });
850
- }
851
-
852
- const finishedStatuses = new Set(["succeeded", "failed"]);
853
- if (!finishedStatuses.has(deployment.status as string)) {
854
- return reply.status(409).send({
855
- error: `Cannot execute rollback for deployment in "${deployment.status}" status`,
856
- });
857
- }
858
-
859
- const artifact = artifactStore.get(deployment.artifactId);
860
- const targetEnvoy = deployment.envoyId
861
- ? envoyRegistry?.get(deployment.envoyId)
862
- : envoyRegistry?.list()[0];
863
-
864
- if (!targetEnvoy) {
865
- return reply.status(503).send({ error: "No envoy available to execute rollback" });
866
- }
867
-
868
- const actor = (request.user?.email) ?? "anonymous";
869
- const serverPort = process.env.PORT ?? "9410";
870
- const serverUrl = process.env.SYNTH_SERVER_URL ?? `http://localhost:${serverPort}`;
871
- const progressCallbackUrl = `${serverUrl}/api/deployments/${deployment.id}/progress`;
872
-
873
- deployment.status = "running" as typeof deployment.status;
874
- deployments.save(deployment);
875
-
876
- debrief.record({
877
- partitionId: deployment.partitionId ?? null,
878
- deploymentId: deployment.id,
879
- agent: "server",
880
- decisionType: "rollback-execution" as Parameters<typeof debrief.record>[0]["decisionType"],
881
- decision: `Rollback execution initiated for ${artifact?.name ?? deployment.artifactId} v${deployment.version}`,
882
- reasoning: `Rollback requested by ${actor}. Executing ${deployment.rollbackPlan.steps.length} rollback step(s).`,
883
- context: { initiatedBy: actor, stepCount: deployment.rollbackPlan.steps.length },
884
- actor: request.user?.email,
885
- });
886
- telemetry.record({
887
- actor,
888
- action: "deployment.rollback-executed" as Parameters<typeof telemetry.record>[0]["action"],
889
- target: { type: "deployment", id: deployment.id },
890
- details: { stepCount: deployment.rollbackPlan.steps.length },
891
- });
892
-
893
- const rollbackClient = new EnvoyClient(targetEnvoy.url);
894
-
895
- // Execute the rollback plan as if it were a forward plan — it IS a forward plan
896
- // (just in the reverse direction). Use an empty no-op plan as the "rollback of rollback".
897
- const emptyPlan = { steps: [], reasoning: "No rollback of rollback." };
898
-
899
- rollbackClient.executeApprovedPlan({
900
- deploymentId: deployment.id,
901
- plan: deployment.rollbackPlan,
902
- rollbackPlan: emptyPlan,
903
- artifactType: artifact?.type ?? "unknown",
904
- artifactName: artifact?.name ?? "unknown",
905
- environmentId: deployment.environmentId ?? "",
906
- progressCallbackUrl,
907
- callbackToken: targetEnvoy.token,
908
- }).then((result) => {
909
- const dep = deployments.get(deployment.id);
910
- if (!dep) return;
911
-
912
- dep.status = result.success ? "rolled_back" as typeof dep.status : "failed" as typeof dep.status;
913
- if (!result.success) {
914
- dep.failureReason = result.failureReason ?? "Rollback execution failed";
915
- }
916
- dep.completedAt = new Date();
917
- deployments.save(dep);
918
-
919
- debrief.record({
920
- partitionId: dep.partitionId ?? null,
921
- deploymentId: dep.id,
922
- agent: "server",
923
- decisionType: "rollback-execution" as Parameters<typeof debrief.record>[0]["decisionType"],
924
- decision: result.success
925
- ? `Rollback completed successfully for ${artifact?.name ?? dep.artifactId} v${dep.version}`
926
- : `Rollback failed for ${artifact?.name ?? dep.artifactId} v${dep.version}`,
927
- reasoning: result.success
928
- ? `All rollback steps executed successfully.`
929
- : `Rollback failed: ${result.failureReason}`,
930
- context: { success: result.success, failureReason: result.failureReason },
931
- });
932
- }).catch((err) => {
933
- const dep = deployments.get(deployment.id);
934
- if (!dep) return;
935
-
936
- dep.status = "failed" as typeof dep.status;
937
- dep.failureReason = err instanceof Error ? err.message : "Rollback execution dispatch failed";
938
- deployments.save(dep);
939
- });
940
-
941
- return reply.status(202).send({ deployment, accepted: true });
942
- },
943
- );
944
-
945
- // Retry (redeploy) — create a new deployment with the same parameters as the source
946
- app.post<{ Params: { id: string } }>(
947
- "/api/deployments/:id/retry",
948
- { preHandler: [requirePermission("deployment.create")] },
949
- async (request, reply) => {
950
- const source = deployments.get(request.params.id);
951
- if (!source) {
952
- return reply.status(404).send({ error: "Deployment not found" });
953
- }
954
-
955
- // Calculate attempt number by following the retryOf chain
956
- let attemptNumber = 1;
957
- let cursor: typeof source | undefined = source;
958
- while (cursor?.retryOf) {
959
- attemptNumber++;
960
- cursor = deployments.get(cursor.retryOf);
961
- }
962
- attemptNumber++; // this new deployment is one more
963
-
964
- // Validate artifact still exists
965
- const artifact = artifactStore.get(source.artifactId);
966
- if (!artifact) {
967
- return reply.status(404).send({ error: `Artifact not found: ${source.artifactId}` });
968
- }
969
-
970
- // Validate environment still exists (if present on source)
971
- const environment = source.environmentId ? environments.get(source.environmentId) : undefined;
972
- if (source.environmentId && !environment) {
973
- return reply.status(404).send({ error: `Environment not found: ${source.environmentId}` });
974
- }
975
-
976
- // Validate partition still exists (if present on source)
977
- const partition = source.partitionId ? partitions.get(source.partitionId) : undefined;
978
- if (source.partitionId && !partition) {
979
- return reply.status(404).send({ error: `Partition not found: ${source.partitionId}` });
980
- }
981
-
982
- // Validate envoy still exists (if present on source)
983
- const targetEnvoy = source.envoyId ? envoyRegistry?.get(source.envoyId) : undefined;
984
- if (source.envoyId && !targetEnvoy) {
985
- return reply.status(404).send({ error: `Envoy not found: ${source.envoyId}` });
986
- }
987
-
988
- // Resolve variables — same logic as POST /api/deployments
989
- const envVars = environment ? environment.variables : {};
990
- const partitionVars = partition?.variables ?? {};
991
- const resolved: Record<string, string> = { ...partitionVars, ...envVars };
992
-
993
- const deployment = {
994
- id: crypto.randomUUID(),
995
- artifactId: source.artifactId,
996
- environmentId: source.environmentId,
997
- partitionId: source.partitionId,
998
- envoyId: targetEnvoy?.id,
999
- version: source.version ?? "",
1000
- status: "pending" as const,
1001
- variables: resolved,
1002
- retryOf: source.id,
1003
- debriefEntryIds: [] as string[],
1004
- createdAt: new Date(),
1005
- };
1006
-
1007
- deployments.save(deployment);
1008
-
1009
- const actor = (request.user?.email) ?? "anonymous";
1010
- telemetry.record({ actor, action: "deployment.created", target: { type: "deployment", id: deployment.id }, details: { artifactId: source.artifactId, environmentId: source.environmentId, partitionId: source.partitionId, envoyId: source.envoyId, retryOf: source.id } });
1011
-
1012
- // Record retry debrief entry
1013
- debrief.record({
1014
- partitionId: deployment.partitionId ?? null,
1015
- deploymentId: deployment.id,
1016
- agent: "server",
1017
- decisionType: "system",
1018
- decision: `Retry of deployment ${source.id} (attempt #${attemptNumber})`,
1019
- reasoning: `User initiated retry of deployment ${source.id}. Same artifact, version, environment, and partition.`,
1020
- context: { retryOf: source.id, attemptNumber, actor },
1021
- actor: request.user?.email,
1022
- });
1023
-
1024
- // Dispatch planning — same logic as POST /api/deployments
1025
- if (envoyRegistry) {
1026
- const planningEnvoy = targetEnvoy
1027
- ?? (environment ? envoyRegistry.findForEnvironment(environment.name) : undefined)
1028
- ?? envoyRegistry.list()[0];
1029
-
1030
- if (planningEnvoy) {
1031
- const planningClient = new EnvoyClient(planningEnvoy.url);
1032
- const environmentForPlanning = environment
1033
- ? { id: environment.id, name: environment.name, variables: environment.variables }
1034
- : { id: `direct:${planningEnvoy.id}`, name: planningEnvoy.name, variables: {} };
1035
-
1036
- planningClient.requestPlan({
1037
- deploymentId: deployment.id,
1038
- artifact: {
1039
- id: artifact.id,
1040
- name: artifact.name,
1041
- type: artifact.type,
1042
- analysis: {
1043
- summary: artifact.analysis.summary,
1044
- dependencies: artifact.analysis.dependencies,
1045
- configurationExpectations: artifact.analysis.configurationExpectations,
1046
- deploymentIntent: artifact.analysis.deploymentIntent,
1047
- confidence: artifact.analysis.confidence,
1048
- },
1049
- },
1050
- environment: environmentForPlanning,
1051
- partition: partition
1052
- ? { id: partition.id, name: partition.name, variables: partition.variables }
1053
- : undefined,
1054
- version: deployment.version,
1055
- resolvedVariables: resolved,
1056
- }).then((result) => {
1057
- const dep = deployments.get(deployment.id);
1058
- if (!dep || dep.status !== "pending") return;
1059
-
1060
- dep.plan = result.plan;
1061
- dep.rollbackPlan = result.rollbackPlan;
1062
- dep.envoyId = planningEnvoy.id;
1063
-
1064
- if (result.blocked) {
1065
- dep.status = "failed" as typeof dep.status;
1066
- dep.failureReason = result.blockReason ?? "Plan blocked due to unrecoverable precondition failures";
1067
- deployments.save(dep);
1068
-
1069
- debrief.record({
1070
- partitionId: dep.partitionId ?? null,
1071
- deploymentId: dep.id,
1072
- agent: "envoy",
1073
- decisionType: "plan-generation" as Parameters<typeof debrief.record>[0]["decisionType"],
1074
- decision: `Deployment plan blocked — infrastructure prerequisites not met`,
1075
- reasoning: result.blockReason ?? result.plan.reasoning,
1076
- context: { stepCount: result.plan.steps.length, envoyId: planningEnvoy.id, blocked: true },
1077
- });
1078
- } else {
1079
- dep.status = "awaiting_approval" as typeof dep.status;
1080
- dep.recommendation = computeRecommendation(dep, deployments, result.assessmentSummary);
1081
- deployments.save(dep);
1082
-
1083
- debrief.record({
1084
- partitionId: dep.partitionId ?? null,
1085
- deploymentId: dep.id,
1086
- agent: "envoy",
1087
- decisionType: "plan-generation" as Parameters<typeof debrief.record>[0]["decisionType"],
1088
- decision: `Deployment plan generated with ${result.plan.steps.length} steps`,
1089
- reasoning: result.plan.reasoning,
1090
- context: { stepCount: result.plan.steps.length, envoyId: planningEnvoy.id, delta: result.delta },
1091
- });
1092
- }
1093
- }).catch((err) => {
1094
- const dep = deployments.get(deployment.id);
1095
- if (!dep || dep.status !== "pending") return;
1096
-
1097
- dep.status = "failed" as typeof dep.status;
1098
- dep.failureReason = err instanceof Error ? err.message : "Planning failed";
1099
- deployments.save(dep);
1100
-
1101
- debrief.record({
1102
- partitionId: dep.partitionId ?? null,
1103
- deploymentId: dep.id,
1104
- agent: "server",
1105
- decisionType: "deployment-failure" as Parameters<typeof debrief.record>[0]["decisionType"],
1106
- decision: "Envoy planning failed",
1107
- reasoning: dep.failureReason!,
1108
- context: { error: dep.failureReason, envoyId: planningEnvoy.id },
1109
- });
1110
- });
1111
- }
1112
- }
1113
-
1114
- return reply.status(201).send({ deployment, sourceDeploymentId: source.id, attemptNumber });
1115
- },
1116
- );
1117
-
1118
- // Get deployment postmortem
1119
- app.get<{ Params: { id: string } }>(
1120
- "/api/deployments/:id/postmortem",
1121
- { preHandler: [requirePermission("deployment.view")] },
1122
- async (request, reply) => {
1123
- const deployment = deployments.get(request.params.id);
1124
- if (!deployment) {
1125
- return reply.status(404).send({ error: "Deployment not found" });
1126
- }
1127
-
1128
- const entries = debrief.getByDeployment(deployment.id);
1129
- const postmortem = generatePostmortem(entries, deployment);
1130
- const llmResult = await generatePostmortemAsync(entries, deployment, llm);
1131
- return {
1132
- postmortem,
1133
- ...(llmResult.heuristicFallback ? {} : { llmPostmortem: llmResult.llmPostmortem }),
1134
- };
1135
- },
1136
- );
1137
-
1138
- // Get recent debrief entries (supports filtering by partition and decision type)
1139
- app.get("/api/debrief", { preHandler: [requirePermission("deployment.view")] }, async (request) => {
1140
- const qParsed = DebriefQuerySchema.safeParse(request.query);
1141
- const { limit, partitionId, decisionType } = qParsed.success ? qParsed.data : {};
1142
-
1143
- const max = limit ?? 50;
1144
-
1145
- // No filters — fast path
1146
- if (!partitionId && !decisionType) {
1147
- return { entries: debrief.getRecent(max) };
1148
- }
1149
-
1150
- // Start with the most selective filter, then narrow
1151
- let entries: ReturnType<typeof debrief.getByPartition>;
1152
- if (partitionId && decisionType) {
1153
- entries = debrief.getByPartition(partitionId).filter(
1154
- (e) => e.decisionType === decisionType,
1155
- );
1156
- } else if (partitionId) {
1157
- entries = debrief.getByPartition(partitionId);
1158
- } else {
1159
- entries = debrief.getByType(decisionType as Parameters<typeof debrief.getByType>[0]);
1160
- }
1161
-
1162
- entries.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
1163
- return { entries: entries.slice(0, max) };
1164
- });
1165
-
1166
- // ---------------------------------------------------------------------------
1167
- // Progress streaming — envoy callback and SSE endpoints
1168
- // ---------------------------------------------------------------------------
1169
-
1170
- // POST /api/deployments/:id/progress — receives progress events from envoy
1171
- app.post<{ Params: { id: string } }>(
1172
- "/api/deployments/:id/progress",
1173
- async (request, reply) => {
1174
- if (!progressStore) {
1175
- return reply.status(501).send({ error: "Progress streaming not configured" });
1176
- }
1177
-
1178
- // Validate envoy token — this route is exempt from JWT auth
1179
- if (envoyRegistry) {
1180
- const authHeader = (request.headers.authorization ?? "") as string;
1181
- const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null;
1182
- if (!token || !envoyRegistry.validateToken(token)) {
1183
- return reply.status(401).send({ error: "Invalid or missing envoy token" });
1184
- }
1185
- }
1186
-
1187
- const parsed = ProgressEventSchema.safeParse(request.body);
1188
- if (!parsed.success) {
1189
- return reply.status(400).send({ error: "Invalid progress event", details: parsed.error.format() });
1190
- }
1191
-
1192
- const event = parsed.data;
1193
-
1194
- // Validate the deploymentId in the URL matches the body
1195
- if (event.deploymentId !== request.params.id) {
1196
- return reply.status(400).send({ error: "Deployment ID in URL does not match event body" });
1197
- }
1198
-
1199
- progressStore.push(event);
1200
- return reply.status(200).send({ received: true });
1201
- },
1202
- );
1203
-
1204
- // GET /api/deployments/:id/stream — SSE endpoint for live progress
1205
- // Auth is via ?token= query param since EventSource cannot send headers
1206
- app.get<{ Params: { id: string } }>(
1207
- "/api/deployments/:id/stream",
1208
- { preHandler: [requirePermission("deployment.view")] },
1209
- (request, reply) => {
1210
- if (!progressStore) {
1211
- reply.status(501).send({ error: "Progress streaming not configured" });
1212
- return;
1213
- }
1214
-
1215
- // Hijack the connection so Fastify does not finalize the response
1216
- reply.hijack();
1217
-
1218
- // Set SSE headers
1219
- reply.raw.writeHead(200, {
1220
- "Content-Type": "text/event-stream",
1221
- "Cache-Control": "no-cache",
1222
- "Connection": "keep-alive",
1223
- "X-Accel-Buffering": "no",
1224
- });
1225
-
1226
- const deploymentId = request.params.id;
1227
-
1228
- // Check for Last-Event-ID header (reconnection with replay)
1229
- const lastEventIdHeader = request.headers["last-event-id"];
1230
- const lastEventId = lastEventIdHeader ? parseInt(String(lastEventIdHeader), 10) : 0;
1231
-
1232
- // Send catch-up events — either all (fresh connect) or since last ID (reconnect)
1233
- const existing = lastEventId
1234
- ? progressStore.getEventsSince(deploymentId, lastEventId)
1235
- : progressStore.getEvents(deploymentId);
1236
- for (const event of existing) {
1237
- reply.raw.write(`id: ${event.id}\ndata: ${JSON.stringify(event)}\n\n`);
1238
- }
1239
-
1240
- // Check if deployment already completed — if so, close after catch-up
1241
- const lastEvent = existing[existing.length - 1];
1242
- if (lastEvent?.type === "deployment-completed") {
1243
- reply.raw.end();
1244
- return;
1245
- }
1246
-
1247
- // Subscribe to new events
1248
- const listener = (event: { id?: number; deploymentId: string; type: string }) => {
1249
- try {
1250
- reply.raw.write(`id: ${event.id}\ndata: ${JSON.stringify(event)}\n\n`);
1251
-
1252
- // Close the stream when deployment completes
1253
- if (event.type === "deployment-completed") {
1254
- reply.raw.end();
1255
- }
1256
- } catch {
1257
- // Client disconnected — clean up
1258
- progressStore!.removeListener(deploymentId, listener);
1259
- }
1260
- };
1261
-
1262
- progressStore.addListener(deploymentId, listener);
1263
-
1264
- // Clean up on client disconnect
1265
- request.raw.on("close", () => {
1266
- progressStore!.removeListener(deploymentId, listener);
1267
- });
1268
- },
1269
- );
1270
- }
1271
-
1272
- // ---------------------------------------------------------------------------
1273
- // Recommendation engine — synthesizes enrichment context into a verdict
1274
- // ---------------------------------------------------------------------------
1275
-
1276
- function computeRecommendation(
1277
- deployment: import("@synth-deploy/core").Deployment,
1278
- store: IDeploymentStore,
1279
- llmSummary?: string,
1280
- ): import("@synth-deploy/core").DeploymentRecommendation {
1281
- const factors: string[] = [];
1282
- let verdict: RecommendationVerdict = "proceed";
1283
-
1284
- const now = new Date();
1285
- const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
1286
-
1287
- // Check for previously rolled-back version
1288
- if (deployment.version) {
1289
- const rolledBack = store.findByArtifactVersion(
1290
- deployment.artifactId,
1291
- deployment.version,
1292
- "rolled_back",
1293
- );
1294
- if (rolledBack.length > 0) {
1295
- verdict = "caution";
1296
- factors.push("This artifact version was previously rolled back");
1297
- }
1298
- }
1299
-
1300
- // Check for conflicting deployments (only meaningful when environmentId is set)
1301
- if (deployment.environmentId) {
1302
- const conflicting = store.list().filter(
1303
- (d) =>
1304
- d.environmentId === deployment.environmentId &&
1305
- d.id !== deployment.id &&
1306
- ((d.status as string) === "running" || (d.status as string) === "approved"),
1307
- );
1308
- if (conflicting.length > 0) {
1309
- verdict = "hold";
1310
- factors.push(`${conflicting.length} other deployment(s) in progress for this environment`);
1311
- }
1312
- }
1313
-
1314
- // Check deployment frequency
1315
- const recentCount = deployment.environmentId
1316
- ? store.countByEnvironment(deployment.environmentId, twentyFourHoursAgo)
1317
- : 0;
1318
- if (recentCount > 5) {
1319
- if (verdict === "proceed") verdict = "caution";
1320
- factors.push(`High deployment frequency: ${recentCount} deployments in the last 24h`);
1321
- }
1322
-
1323
- // Check last deployment status
1324
- const lastDeploy = deployment.environmentId
1325
- ? store.findLatestByEnvironment(deployment.environmentId)
1326
- : undefined;
1327
- if (lastDeploy && lastDeploy.id !== deployment.id) {
1328
- if ((lastDeploy.status as string) === "failed" || (lastDeploy.status as string) === "rolled_back") {
1329
- if (verdict === "proceed") verdict = "caution";
1330
- factors.push(`Last deployment to this environment ${lastDeploy.status}`);
1331
- } else if ((lastDeploy.status as string) === "succeeded") {
1332
- factors.push("Last deployment to this environment succeeded");
1333
- }
1334
- }
1335
-
1336
- if (factors.length === 0) {
1337
- factors.push("No risk factors detected — target is stable");
1338
- }
1339
-
1340
- const summaryMap: Record<RecommendationVerdict, string> = {
1341
- proceed: "Proceed — no conflicting deployments, target environment is stable",
1342
- caution: "Proceed with caution — review risk factors before greenlighting",
1343
- hold: "Hold — resolve conflicting deployments before proceeding",
1344
- };
1345
-
1346
- return { verdict, summary: llmSummary ?? summaryMap[verdict], factors };
1347
- }