@synth-deploy/server 1.1.0 → 1.2.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.
@@ -183,17 +183,13 @@ export function registerOperationRoutes(
183
183
 
184
184
  // Trigger operations: construct MonitoringDirective from plan, present for approval
185
185
  if (dep.input.type === "trigger" && !result.blocked) {
186
- const triggerInput = dep.input as { type: "trigger"; condition: string; responseIntent: string; parameters?: Record<string, unknown> };
187
- // Convert plan steps to monitoring probes
188
- const probes = result.plan.steps.map((step) => ({
189
- command: step.action,
190
- label: step.description,
191
- parseAs: (step.params?.parseAs === "exitCode" ? "exitCode" : "numeric") as "numeric" | "exitCode",
192
- }));
186
+ const triggerInput = dep.input as { type: "trigger"; condition: string; responseIntent: string };
187
+ // Use probes from the envoy's trigger planning response (embedded in scriptedPlan reasoning),
188
+ // or fall back to a default probe. The envoy's planTrigger generates these.
193
189
  const directive: import("@synth-deploy/core").MonitoringDirective = {
194
190
  id: dep.id,
195
191
  operationId: dep.id,
196
- probes: probes.length > 0 ? probes : [{
192
+ probes: [{
197
193
  command: "echo 0",
198
194
  label: "default-probe",
199
195
  parseAs: "numeric" as const,
@@ -203,7 +199,6 @@ export function registerOperationRoutes(
203
199
  condition: triggerInput.condition,
204
200
  responseIntent: triggerInput.responseIntent,
205
201
  responseType: "maintain",
206
- responseParameters: triggerInput.parameters,
207
202
  environmentId: dep.environmentId,
208
203
  partitionId: dep.partitionId,
209
204
  status: "active",
@@ -266,7 +261,7 @@ export function registerOperationRoutes(
266
261
  decisionType: "plan-generation",
267
262
  decision: `Operation plan blocked — infrastructure prerequisites not met`,
268
263
  reasoning: result.blockReason ?? result.plan.reasoning,
269
- context: { stepCount: result.plan.steps.length, envoyId: planningEnvoy.id, blocked: true },
264
+ context: { stepCount: result.plan.scriptedPlan.stepSummary.length, envoyId: planningEnvoy.id, blocked: true },
270
265
  });
271
266
  } else {
272
267
  // Plan is valid — transition to awaiting_approval
@@ -279,9 +274,9 @@ export function registerOperationRoutes(
279
274
  operationId: dep.id,
280
275
  agent: "envoy",
281
276
  decisionType: "plan-generation",
282
- decision: `Operation plan generated with ${result.plan.steps.length} steps`,
277
+ decision: `Operation plan generated with ${result.plan.scriptedPlan.stepSummary.length} steps`,
283
278
  reasoning: result.plan.reasoning,
284
- context: { stepCount: result.plan.steps.length, envoyId: planningEnvoy.id, delta: result.delta },
279
+ context: { stepCount: result.plan.scriptedPlan.stepSummary.length, envoyId: planningEnvoy.id, delta: result.delta },
285
280
  });
286
281
  }
287
282
  }).catch((err) => {
@@ -400,9 +395,9 @@ export function registerOperationRoutes(
400
395
  operationId: deployment.id,
401
396
  agent: "envoy",
402
397
  decisionType: "plan-generation",
403
- decision: `Operation plan submitted with ${parsed.data.plan.steps.length} steps`,
398
+ decision: `Operation plan submitted with ${parsed.data.plan.scriptedPlan.stepSummary.length} steps`,
404
399
  reasoning: parsed.data.plan.reasoning,
405
- context: { stepCount: parsed.data.plan.steps.length },
400
+ context: { stepCount: parsed.data.plan.scriptedPlan.stepSummary.length },
406
401
  });
407
402
 
408
403
  return reply.status(200).send({ deployment });
@@ -456,7 +451,7 @@ export function registerOperationRoutes(
456
451
  target: { type: "deployment", id: deployment.id },
457
452
  details: parsed.data.modifications
458
453
  ? { modifications: parsed.data.modifications }
459
- : { planStepCount: deployment.plan?.steps.length ?? 0 },
454
+ : { planStepCount: deployment.plan?.scriptedPlan.stepSummary.length ?? 0 },
460
455
  });
461
456
 
462
457
  // Composite operations: execute children sequentially
@@ -644,9 +639,14 @@ export function registerOperationRoutes(
644
639
  }
645
640
 
646
641
  // Validate modified plan with envoy if available
647
- if (envoyClient) {
642
+ if (envoyClient && deployment.plan.scriptedPlan) {
648
643
  try {
649
- const validation = await envoyClient.validatePlan(parsed.data.steps);
644
+ const modifiedScript: import("@synth-deploy/core").ScriptedPlan = {
645
+ ...deployment.plan.scriptedPlan,
646
+ executionScript: parsed.data.executionScript,
647
+ ...(parsed.data.rollbackScript !== undefined ? { rollbackScript: parsed.data.rollbackScript } : {}),
648
+ };
649
+ const validation = await envoyClient.validatePlan(modifiedScript);
650
650
  if (!validation.valid) {
651
651
  return reply.status(422).send({
652
652
  error: "Modified plan failed envoy validation",
@@ -658,34 +658,21 @@ export function registerOperationRoutes(
658
658
  }
659
659
  }
660
660
 
661
- // Build structured diff: what changed between old and new steps
662
- const oldSteps = deployment.plan.steps;
663
- const newSteps = parsed.data.steps;
664
- const diffLines: string[] = [];
665
- const maxLen = Math.max(oldSteps.length, newSteps.length);
666
- for (let i = 0; i < maxLen; i++) {
667
- const old = oldSteps[i];
668
- const cur = newSteps[i];
669
- if (!old) {
670
- diffLines.push(`+ Step ${i + 1} (added): ${cur.action} ${cur.target} — ${cur.description}`);
671
- } else if (!cur) {
672
- diffLines.push(`- Step ${i + 1} (removed): ${old.action} ${old.target} — ${old.description}`);
673
- } else if (old.action !== cur.action || old.target !== cur.target || old.description !== cur.description) {
674
- diffLines.push(`~ Step ${i + 1} (changed): ${old.action} ${old.target} → ${cur.action} ${cur.target}`);
675
- if (old.description !== cur.description) {
676
- diffLines.push(` was: ${old.description}`);
677
- diffLines.push(` now: ${cur.description}`);
678
- }
679
- }
680
- }
681
- const diffFromPreviousPlan = diffLines.length > 0
682
- ? diffLines.join("\n")
683
- : "Steps reordered or metadata changed (actions and targets unchanged)";
661
+ // Compute diff description
662
+ const oldScript = deployment.plan.scriptedPlan?.executionScript ?? "";
663
+ const newScript = parsed.data.executionScript;
664
+ const diffFromPreviousPlan = oldScript !== newScript
665
+ ? "Execution script modified by user"
666
+ : "Plan metadata changed (script unchanged)";
684
667
 
685
668
  // Apply modifications
686
669
  deployment.plan = {
687
670
  ...deployment.plan,
688
- steps: parsed.data.steps,
671
+ scriptedPlan: {
672
+ ...deployment.plan.scriptedPlan,
673
+ executionScript: parsed.data.executionScript,
674
+ ...(parsed.data.rollbackScript !== undefined ? { rollbackScript: parsed.data.rollbackScript } : {}),
675
+ },
689
676
  diffFromPreviousPlan,
690
677
  };
691
678
  deployments.save(deployment);
@@ -702,7 +689,6 @@ export function registerOperationRoutes(
702
689
  reasoning: parsed.data.reason,
703
690
  context: {
704
691
  modifiedBy: actor,
705
- stepCount: parsed.data.steps.length,
706
692
  reason: parsed.data.reason,
707
693
  },
708
694
  actor: request.user?.email,
@@ -711,13 +697,13 @@ export function registerOperationRoutes(
711
697
  actor,
712
698
  action: "operation.modified" as Parameters<typeof telemetry.record>[0]["action"],
713
699
  target: { type: "deployment", id: deployment.id },
714
- details: { reason: parsed.data.reason, stepCount: parsed.data.steps.length },
700
+ details: { reason: parsed.data.reason },
715
701
  });
716
702
  telemetry.record({
717
703
  actor,
718
704
  action: "agent.recommendation.overridden",
719
705
  target: { type: "deployment", id: deployment.id },
720
- details: { reason: parsed.data.reason, stepCount: parsed.data.steps.length, diff: diffFromPreviousPlan },
706
+ details: { reason: parsed.data.reason, diff: diffFromPreviousPlan },
721
707
  });
722
708
 
723
709
  return { deployment, modified: true };
@@ -762,10 +748,9 @@ export function registerOperationRoutes(
762
748
  try {
763
749
  const validation = await planningClientForValidation.validateRefinementFeedback({
764
750
  feedback: parsed.data.feedback,
765
- currentPlanSteps: (deployment.plan?.steps ?? []).map((s) => ({
751
+ currentPlanSummary: (deployment.plan?.scriptedPlan?.stepSummary ?? []).map((s) => ({
766
752
  description: s.description,
767
- action: s.action,
768
- target: s.target,
753
+ reversible: s.reversible,
769
754
  })),
770
755
  artifactName: artifact?.name ?? "unknown",
771
756
  environmentName: environment?.name ?? "unknown",
@@ -838,9 +823,9 @@ export function registerOperationRoutes(
838
823
  operationId: dep.id,
839
824
  agent: "envoy",
840
825
  decisionType: "plan-generation",
841
- decision: `Plan regenerated with user feedback (${result.plan.steps.length} steps)`,
826
+ decision: `Plan regenerated with user feedback (${result.plan.scriptedPlan.stepSummary.length} steps)`,
842
827
  reasoning: result.plan.reasoning,
843
- context: { stepCount: result.plan.steps.length, envoyId: planningEnvoy.id, refinementFeedback: parsed.data.feedback },
828
+ context: { stepCount: result.plan.scriptedPlan.stepSummary.length, envoyId: planningEnvoy.id, refinementFeedback: parsed.data.feedback },
844
829
  });
845
830
 
846
831
  return { deployment: dep, replanned: true };
@@ -947,7 +932,7 @@ export function registerOperationRoutes(
947
932
 
948
933
  const environment = deployment.environmentId ? environments.get(deployment.environmentId) : undefined;
949
934
 
950
- // Build the list of completed steps from execution record (or plan as fallback)
935
+ // Build the list of completed steps from execution record (or plan step summaries as fallback)
951
936
  const completedSteps: Array<{
952
937
  description: string;
953
938
  action: string;
@@ -956,14 +941,14 @@ export function registerOperationRoutes(
956
941
  output?: string;
957
942
  }> = deployment.executionRecord?.steps.map((s) => ({
958
943
  description: s.description,
959
- action: deployment.plan?.steps.find((p) => p.description === s.description)?.action ?? "unknown",
960
- target: deployment.plan?.steps.find((p) => p.description === s.description)?.target ?? "",
944
+ action: "script-step",
945
+ target: "",
961
946
  status: s.status,
962
947
  output: s.output ?? s.error,
963
- })) ?? deployment.plan?.steps.map((s) => ({
948
+ })) ?? deployment.plan?.scriptedPlan?.stepSummary.map((s) => ({
964
949
  description: s.description,
965
- action: s.action,
966
- target: s.target,
950
+ action: "script-step",
951
+ target: "",
967
952
  status: "completed" as const,
968
953
  })) ?? [];
969
954
 
@@ -1008,7 +993,7 @@ export function registerOperationRoutes(
1008
993
  reasoning: rollbackPlan.reasoning,
1009
994
  context: {
1010
995
  requestedBy: actor,
1011
- stepCount: rollbackPlan.steps.length,
996
+ stepCount: rollbackPlan.scriptedPlan.stepSummary.length,
1012
997
  envoyId: targetEnvoy.id,
1013
998
  deploymentStatus: deployment.status,
1014
999
  },
@@ -1018,7 +1003,7 @@ export function registerOperationRoutes(
1018
1003
  actor,
1019
1004
  action: "deployment.rollback-plan-requested" as Parameters<typeof telemetry.record>[0]["action"],
1020
1005
  target: { type: "deployment", id: deployment.id },
1021
- details: { stepCount: rollbackPlan.steps.length },
1006
+ details: { stepCount: rollbackPlan.scriptedPlan.stepSummary.length },
1022
1007
  });
1023
1008
 
1024
1009
  return reply.status(200).send({ deployment, rollbackPlan });
@@ -1075,22 +1060,32 @@ export function registerOperationRoutes(
1075
1060
  agent: "server",
1076
1061
  decisionType: "rollback-execution",
1077
1062
  decision: `Rollback execution initiated for ${artifact?.name ?? getArtifactId(deployment)} v${deployment.version}`,
1078
- reasoning: `Rollback requested by ${actor}. Executing ${deployment.rollbackPlan.steps.length} rollback step(s).`,
1079
- context: { initiatedBy: actor, stepCount: deployment.rollbackPlan.steps.length },
1063
+ reasoning: `Rollback requested by ${actor}. Executing ${deployment.rollbackPlan.scriptedPlan.stepSummary.length} rollback step(s).`,
1064
+ context: { initiatedBy: actor, stepCount: deployment.rollbackPlan.scriptedPlan.stepSummary.length },
1080
1065
  actor: request.user?.email,
1081
1066
  });
1082
1067
  telemetry.record({
1083
1068
  actor,
1084
1069
  action: "deployment.rollback-executed" as Parameters<typeof telemetry.record>[0]["action"],
1085
1070
  target: { type: "deployment", id: deployment.id },
1086
- details: { stepCount: deployment.rollbackPlan.steps.length },
1071
+ details: { stepCount: deployment.rollbackPlan.scriptedPlan.stepSummary.length },
1087
1072
  });
1088
1073
 
1089
1074
  const rollbackClient = new EnvoyClient(targetEnvoy.url);
1090
1075
 
1091
1076
  // Execute the rollback plan as if it were a forward plan — it IS a forward plan
1092
1077
  // (just in the reverse direction). Use an empty no-op plan as the "rollback of rollback".
1093
- const emptyPlan = { steps: [], reasoning: "No rollback of rollback." };
1078
+ const emptyPlan: import("@synth-deploy/core").OperationPlan = {
1079
+ scriptedPlan: {
1080
+ platform: "bash",
1081
+ executionScript: "# No rollback of rollback",
1082
+ dryRunScript: null,
1083
+ rollbackScript: null,
1084
+ reasoning: "No rollback of rollback.",
1085
+ stepSummary: [],
1086
+ },
1087
+ reasoning: "No rollback of rollback.",
1088
+ };
1094
1089
 
1095
1090
  rollbackClient.executeApprovedPlan({
1096
1091
  operationId: deployment.id,
@@ -1269,7 +1264,7 @@ export function registerOperationRoutes(
1269
1264
  decisionType: "plan-generation",
1270
1265
  decision: `Operation plan blocked — infrastructure prerequisites not met`,
1271
1266
  reasoning: result.blockReason ?? result.plan.reasoning,
1272
- context: { stepCount: result.plan.steps.length, envoyId: planningEnvoy.id, blocked: true },
1267
+ context: { stepCount: result.plan.scriptedPlan.stepSummary.length, envoyId: planningEnvoy.id, blocked: true },
1273
1268
  });
1274
1269
  } else {
1275
1270
  dep.status = "awaiting_approval" as typeof dep.status;
@@ -1281,9 +1276,9 @@ export function registerOperationRoutes(
1281
1276
  operationId: dep.id,
1282
1277
  agent: "envoy",
1283
1278
  decisionType: "plan-generation",
1284
- decision: `Operation plan generated with ${result.plan.steps.length} steps`,
1279
+ decision: `Operation plan generated with ${result.plan.scriptedPlan.stepSummary.length} steps`,
1285
1280
  reasoning: result.plan.reasoning,
1286
- context: { stepCount: result.plan.steps.length, envoyId: planningEnvoy.id, delta: result.delta },
1281
+ context: { stepCount: result.plan.scriptedPlan.stepSummary.length, envoyId: planningEnvoy.id, delta: result.delta },
1287
1282
  });
1288
1283
  }
1289
1284
  }).catch((err) => {
@@ -1561,13 +1556,13 @@ export function registerOperationRoutes(
1561
1556
  }
1562
1557
 
1563
1558
  // Spawn child operation
1564
- const triggerInput = triggerOp.input as { type: "trigger"; condition: string; responseIntent: string; parameters?: Record<string, unknown> };
1559
+ const triggerInput = triggerOp.input as { type: "trigger"; condition: string; responseIntent: string };
1565
1560
  const responseType = triggerOp.monitoringDirective?.responseType ?? "maintain";
1566
1561
  const childOp = {
1567
1562
  id: crypto.randomUUID(),
1568
1563
  input: responseType === "deploy"
1569
1564
  ? { type: "deploy" as const, artifactId: "" }
1570
- : { type: "maintain" as const, intent: triggerInput.responseIntent, parameters: triggerInput.parameters },
1565
+ : { type: "maintain" as const, intent: triggerInput.responseIntent },
1571
1566
  intent: triggerInput.responseIntent,
1572
1567
  lineage: triggerOp.id,
1573
1568
  triggeredBy: "trigger" as const,
@@ -1916,9 +1911,9 @@ export function registerOperationRoutes(
1916
1911
  operationId: childDep.id,
1917
1912
  agent: "envoy",
1918
1913
  decisionType: "plan-generation",
1919
- decision: `Child operation plan generated with ${result.plan.steps.length} steps`,
1914
+ decision: `Child operation plan generated with ${result.plan.scriptedPlan.stepSummary.length} steps`,
1920
1915
  reasoning: result.plan.reasoning,
1921
- context: { stepCount: result.plan.steps.length, envoyId: planningEnvoy.id, parentOperationId: parentOp.id },
1916
+ context: { stepCount: result.plan.scriptedPlan.stepSummary.length, envoyId: planningEnvoy.id, parentOperationId: parentOp.id },
1922
1917
  });
1923
1918
  } catch (err) {
1924
1919
  const childDep = deployments.get(childId);
@@ -1952,9 +1947,9 @@ export function registerOperationRoutes(
1952
1947
  // All children planned — build combined summary plan and await approval
1953
1948
  const allChildren = childIds.map((id) => deployments.get(id)).filter(Boolean) as import("@synth-deploy/core").Operation[];
1954
1949
 
1955
- const combinedSteps = allChildren.flatMap((c, idx) => {
1956
- if (!c.plan) return [];
1957
- return c.plan.steps.map((step) => ({
1950
+ const combinedStepSummary = allChildren.flatMap((c, idx) => {
1951
+ if (!c.plan?.scriptedPlan) return [];
1952
+ return c.plan.scriptedPlan.stepSummary.map((step) => ({
1958
1953
  ...step,
1959
1954
  description: `[${idx + 1}/${allChildren.length}: ${c.input.type}] ${step.description}`,
1960
1955
  }));
@@ -1964,10 +1959,35 @@ export function registerOperationRoutes(
1964
1959
  `Step ${idx + 1} (${c.input.type}): ${c.plan?.reasoning ?? "no reasoning"}`
1965
1960
  ).join("\n\n");
1966
1961
 
1962
+ // Combine child execution scripts into a single composite script
1963
+ const combinedScript = allChildren
1964
+ .map((c, idx) => `# --- Child ${idx + 1}/${allChildren.length}: ${c.input.type} ---\n${c.plan?.scriptedPlan?.executionScript ?? "# no script"}`)
1965
+ .join("\n\n");
1966
+
1967
1967
  const parentDep = deployments.get(parentOp.id);
1968
1968
  if (parentDep && parentDep.status === "pending") {
1969
- parentDep.plan = { steps: combinedSteps, reasoning: combinedReasoning };
1970
- parentDep.rollbackPlan = { steps: [], reasoning: "Child operations handle their own rollback" };
1969
+ parentDep.plan = {
1970
+ scriptedPlan: {
1971
+ platform: "bash",
1972
+ executionScript: combinedScript,
1973
+ dryRunScript: null,
1974
+ rollbackScript: null,
1975
+ reasoning: combinedReasoning,
1976
+ stepSummary: combinedStepSummary,
1977
+ },
1978
+ reasoning: combinedReasoning,
1979
+ };
1980
+ parentDep.rollbackPlan = {
1981
+ scriptedPlan: {
1982
+ platform: "bash",
1983
+ executionScript: "# Child operations handle their own rollback",
1984
+ dryRunScript: null,
1985
+ rollbackScript: null,
1986
+ reasoning: "Child operations handle their own rollback",
1987
+ stepSummary: [],
1988
+ },
1989
+ reasoning: "Child operations handle their own rollback",
1990
+ };
1971
1991
  parentDep.status = "awaiting_approval" as typeof parentDep.status;
1972
1992
  parentDep.recommendation = computeRecommendation(parentDep, deployments);
1973
1993
  deployments.save(parentDep);
@@ -1979,7 +1999,7 @@ export function registerOperationRoutes(
1979
1999
  decisionType: "composite-plan-ready",
1980
2000
  decision: `All ${allChildren.length} child plans ready — composite awaiting approval`,
1981
2001
  reasoning: combinedReasoning,
1982
- context: { childIds, totalSteps: combinedSteps.length },
2002
+ context: { childIds, totalSteps: combinedStepSummary.length },
1983
2003
  });
1984
2004
  }
1985
2005
  }
@@ -245,10 +245,10 @@ export const CreateDeploymentSchema = z.object({
245
245
 
246
246
  const ChildOperationInputSchema = z.discriminatedUnion("type", [
247
247
  z.object({ type: z.literal("deploy"), artifactId: z.string().min(1), artifactVersionId: z.string().optional() }),
248
- z.object({ type: z.literal("maintain"), intent: z.string().min(1), parameters: z.record(z.unknown()).optional() }),
248
+ z.object({ type: z.literal("maintain"), intent: z.string().min(1) }),
249
249
  z.object({ type: z.literal("query"), intent: z.string().min(1) }),
250
250
  z.object({ type: z.literal("investigate"), intent: z.string().min(1), allowWrite: z.boolean().optional() }),
251
- z.object({ type: z.literal("trigger"), condition: z.string().min(1), responseIntent: z.string().min(1), parameters: z.record(z.unknown()).optional() }),
251
+ z.object({ type: z.literal("trigger"), condition: z.string().min(1), responseIntent: z.string().min(1) }),
252
252
  ]);
253
253
 
254
254
  // --- Operations ---
@@ -284,37 +284,33 @@ export const RejectDeploymentSchema = z.object({
284
284
  });
285
285
 
286
286
  export const ModifyDeploymentPlanSchema = z.object({
287
- steps: z.array(z.object({
287
+ executionScript: z.string().min(1, "Execution script must not be empty"),
288
+ rollbackScript: z.string().optional(),
289
+ reason: z.string().min(1),
290
+ });
291
+
292
+ const ScriptedPlanSchema = z.object({
293
+ platform: z.enum(["bash", "powershell"]),
294
+ executionScript: z.string().min(1),
295
+ dryRunScript: z.string().nullable(),
296
+ rollbackScript: z.string().nullable(),
297
+ reasoning: z.string().min(1),
298
+ stepSummary: z.array(z.object({
288
299
  description: z.string().min(1),
289
- action: z.string().min(1),
290
- target: z.string().min(1),
291
300
  reversible: z.boolean(),
292
- rollbackAction: z.string().optional(),
293
- })).min(1, "Plan must contain at least one step"),
294
- reason: z.string().min(1),
301
+ })),
302
+ diffFromCurrent: z.array(z.object({ key: z.string(), from: z.string(), to: z.string() })).optional(),
295
303
  });
296
304
 
297
305
  export const SubmitPlanSchema = z.object({
298
306
  plan: z.object({
299
- steps: z.array(z.object({
300
- description: z.string().min(1),
301
- action: z.string().min(1),
302
- target: z.string().min(1),
303
- reversible: z.boolean(),
304
- rollbackAction: z.string().optional(),
305
- })).min(1),
307
+ scriptedPlan: ScriptedPlanSchema,
306
308
  reasoning: z.string().min(1),
307
309
  diffFromCurrent: z.array(z.object({ key: z.string(), from: z.string(), to: z.string() })).optional(),
308
310
  diffFromPreviousPlan: z.string().optional(),
309
311
  }),
310
312
  rollbackPlan: z.object({
311
- steps: z.array(z.object({
312
- description: z.string().min(1),
313
- action: z.string().min(1),
314
- target: z.string().min(1),
315
- reversible: z.boolean(),
316
- rollbackAction: z.string().optional(),
317
- })),
313
+ scriptedPlan: ScriptedPlanSchema,
318
314
  reasoning: z.string().min(1),
319
315
  }),
320
316
  });
@@ -98,12 +98,12 @@ export class FleetExecutor {
98
98
 
99
99
  const client = this.createEnvoyClient(entry.url, entry.token);
100
100
  try {
101
- const validation = await client.validatePlan(plan.steps);
101
+ const validation = await client.validatePlan(plan.scriptedPlan);
102
102
  results.push({
103
103
  envoyId,
104
104
  envoyName: entry.name,
105
105
  validated: validation.valid,
106
- issues: validation.violations?.map((v) => v.reason),
106
+ issues: validation.violations,
107
107
  });
108
108
  } catch (err) {
109
109
  results.push({
@@ -246,7 +246,14 @@ export class GraphExecutor {
246
246
  operationId: node.deploymentId ?? node.id,
247
247
  plan: enrichedPlan,
248
248
  rollbackPlan: {
249
- steps: [],
249
+ scriptedPlan: {
250
+ platform: "bash",
251
+ executionScript: "# No rollback plan provided",
252
+ dryRunScript: null,
253
+ rollbackScript: null,
254
+ reasoning: "No rollback plan provided",
255
+ stepSummary: [],
256
+ },
250
257
  reasoning: "No rollback plan provided",
251
258
  },
252
259
  artifactType: "graph-node",
@@ -419,7 +426,14 @@ export class GraphExecutor {
419
426
  operationId: node.deploymentId ?? nodeId,
420
427
  plan,
421
428
  rollbackPlan: {
422
- steps: [],
429
+ scriptedPlan: {
430
+ platform: "bash",
431
+ executionScript: "# Rollback of rollback not supported",
432
+ dryRunScript: null,
433
+ rollbackScript: null,
434
+ reasoning: "Rollback of rollback not supported",
435
+ stepSummary: [],
436
+ },
423
437
  reasoning: "Rollback of rollback not supported",
424
438
  },
425
439
  artifactType: "graph-node-rollback",
package/src/index.ts CHANGED
@@ -296,13 +296,23 @@ if (process.env.SYNTH_SEED_DEMO !== 'false' && partitions.list().length === 0) {
296
296
  environmentId: prodEnv.id as Deployment["environmentId"], version: "2.3.0", status: "succeeded",
297
297
  variables: { ...acmePartition.variables, ...prodEnv.variables },
298
298
  plan: {
299
- steps: [
300
- { description: "Stop service", action: "systemctl stop web-app", target: "prd-web-01", reversible: true, rollbackAction: "systemctl start web-app", execPreview: "systemctl stop web-app" },
301
- { description: "Backup current binaries", action: "cp -r /opt/web-app/ /opt/web-app.bak/", target: "prd-web-01", reversible: false, execPreview: "cp -r /opt/web-app/ /opt/web-app.bak/" },
302
- { description: "Deploy new artifact", action: "tar -xzf web-app-2.3.0.tar.gz -C /opt/web-app/", target: "prd-web-01", reversible: true, rollbackAction: "cp -r /opt/web-app.bak/ /opt/web-app/", execPreview: "tar -xzf /opt/releases/web-app-2.3.0.tar.gz -C /opt/web-app/" },
303
- { description: "Apply environment config (1 variable changed: API_ENDPOINT)", action: "envsubst < config.template > /opt/web-app/.env", target: "prd-web-01", reversible: true, rollbackAction: "cp /opt/web-app.bak/.env /opt/web-app/.env", execPreview: "envsubst < /opt/web-app/config.template > /opt/web-app/.env" },
304
- { description: "Start service and verify health endpoint → 200 OK", action: "systemctl start web-app && curl -f http://localhost:8080/health", target: "prd-web-01", reversible: true, rollbackAction: "systemctl stop web-app", execPreview: "systemctl start web-app" },
305
- ],
299
+ scriptedPlan: {
300
+ platform: "bash",
301
+ executionScript: "#!/usr/bin/env bash\nset -euo pipefail\nsystemctl stop web-app\ncp -r /opt/web-app/ /opt/web-app.bak/\ntar -xzf /opt/releases/web-app-2.3.0.tar.gz -C /opt/web-app/\nenvsubst < /opt/web-app/config.template > /opt/web-app/.env\nsystemctl start web-app\ncurl -f --retry 3 --retry-delay 5 http://localhost:8080/health",
302
+ dryRunScript: null,
303
+ rollbackScript: "#!/usr/bin/env bash\nset -euo pipefail\nsystemctl stop web-app\ncp -r /opt/web-app.bak/ /opt/web-app/\ncp /opt/web-app.bak/.env /opt/web-app/.env\nsystemctl start web-app",
304
+ reasoning: "Standard 5-step deploy: stop, backup, extract, config, start. One config change: API_ENDPOINT updated to v2 endpoint validated in staging for 4h.",
305
+ stepSummary: [
306
+ { description: "Stop service", reversible: true },
307
+ { description: "Backup current binaries", reversible: false },
308
+ { description: "Deploy new artifact", reversible: true },
309
+ { description: "Apply environment config (1 variable changed: API_ENDPOINT)", reversible: true },
310
+ { description: "Start service and verify health endpoint → 200 OK", reversible: true },
311
+ ],
312
+ diffFromCurrent: [
313
+ { key: "API_ENDPOINT", from: "https://api.acme.corp/v1", to: "https://api.acme.corp/v2" },
314
+ ],
315
+ },
306
316
  reasoning: "Standard 5-step deploy: stop, backup, extract, config, start. One config change: API_ENDPOINT updated to v2 endpoint validated in staging for 4h.",
307
317
  diffFromCurrent: [
308
318
  { key: "API_ENDPOINT", from: "https://api.acme.corp/v1", to: "https://api.acme.corp/v2" },
@@ -367,12 +377,19 @@ if (process.env.SYNTH_SEED_DEMO !== 'false' && partitions.list().length === 0) {
367
377
  environmentId: stagingEnv.id as Deployment["environmentId"], version: "1.13.0-beta.2", status: "running",
368
378
  variables: { ...globexPartition.variables, ...stagingEnv.variables },
369
379
  plan: {
370
- steps: [
371
- { description: "Pull latest image from registry", action: "docker pull", target: "registry.internal/api:1.13.0-beta.2", reversible: true, rollbackAction: "docker pull registry.internal/api:1.12.0", execPreview: "docker pull registry.internal/api:1.13.0-beta.2" },
372
- { description: "Stop running container", action: "docker stop", target: "api-staging", reversible: true, rollbackAction: "docker start api-staging", execPreview: "docker stop api-staging" },
373
- { description: "Start new container with updated image", action: "docker run", target: "registry.internal/api:1.13.0-beta.2", reversible: true, rollbackAction: "docker stop api-staging && docker run ... api:1.12.0", execPreview: "docker run -d --name api-staging --env-file /opt/api/.env -p 8080:8080 registry.internal/api:1.13.0-beta.2" },
374
- { description: "Verify health endpoint returns 200", action: "verify health", target: "http://localhost:8080/health", reversible: false, execPreview: "curl -f --retry 3 --retry-delay 5 http://localhost:8080/health" },
375
- ],
380
+ scriptedPlan: {
381
+ platform: "bash",
382
+ executionScript: "#!/usr/bin/env bash\nset -euo pipefail\ndocker pull registry.internal/api:1.13.0-beta.2\ndocker stop api-staging\ndocker run -d --name api-staging --env-file /opt/api/.env -p 8080:8080 registry.internal/api:1.13.0-beta.2\ncurl -f --retry 3 --retry-delay 5 http://localhost:8080/health",
383
+ dryRunScript: null,
384
+ rollbackScript: "#!/usr/bin/env bash\nset -euo pipefail\ndocker stop api-staging\ndocker pull registry.internal/api:1.12.0\ndocker start api-staging",
385
+ reasoning: "Container swap: pull new image, stop old container, start new one, verify health. Staging environment — rollback is fast via image tag swap.",
386
+ stepSummary: [
387
+ { description: "Pull latest image from registry", reversible: true },
388
+ { description: "Stop running container", reversible: true },
389
+ { description: "Start new container with updated image", reversible: true },
390
+ { description: "Verify health endpoint returns 200", reversible: false },
391
+ ],
392
+ },
376
393
  reasoning: "Container swap: pull new image, stop old container, start new one, verify health. Staging environment — rollback is fast via image tag swap.",
377
394
  },
378
395
  debriefEntryIds: [],
@@ -383,14 +400,21 @@ if (process.env.SYNTH_SEED_DEMO !== 'false' && partitions.list().length === 0) {
383
400
  environmentId: prodEnv.id as Deployment["environmentId"], version: "3.1.0", status: "awaiting_approval",
384
401
  variables: { ...globexPartition.variables, ...prodEnv.variables },
385
402
  plan: {
386
- steps: [
387
- { description: "Drain queue — wait for in-flight jobs to complete", action: "run command", target: "worker-drain", reversible: false, execPreview: "npm run worker:drain --timeout=120" },
388
- { description: "Stop worker processes on all nodes", action: "systemctl stop", target: "synth-worker", reversible: true, rollbackAction: "systemctl start synth-worker", execPreview: "systemctl stop synth-worker" },
389
- { description: "Deploy new worker binary", action: "copy file", target: "/opt/worker/", reversible: true, rollbackAction: "restore /opt/worker/ from backup", execPreview: "cp -r /opt/releases/worker-3.1.0/* /opt/worker/" },
390
- { description: "Update queue concurrency config (WORKER_CONCURRENCY: 4 8)", action: "write config", target: "/opt/worker/.env", reversible: true, rollbackAction: "restore previous .env", execPreview: "envsubst < /opt/worker/config.template > /opt/worker/.env" },
391
- { description: "Start worker and verify queue depth drops", action: "systemctl start", target: "synth-worker", reversible: true, rollbackAction: "systemctl stop synth-worker", execPreview: "systemctl start synth-worker" },
392
- { description: "Verify queue processing resumes within 30s", action: "verify health", target: "http://localhost:9090/metrics", reversible: false, execPreview: "curl -f --retry 6 --retry-delay 5 http://localhost:9090/metrics" },
393
- ],
403
+ scriptedPlan: {
404
+ platform: "bash",
405
+ executionScript: "#!/usr/bin/env bash\nset -euo pipefail\nnpm run worker:drain --timeout=120\nsystemctl stop synth-worker\ncp -r /opt/releases/worker-3.1.0/* /opt/worker/\nenvsubst < /opt/worker/config.template > /opt/worker/.env\nsystemctl start synth-worker\ncurl -f --retry 6 --retry-delay 5 http://localhost:9090/metrics",
406
+ dryRunScript: null,
407
+ rollbackScript: "#!/usr/bin/env bash\nset -euo pipefail\nsystemctl stop synth-worker\ncp -r /opt/worker.bak/* /opt/worker/\ncp /opt/worker.bak/.env /opt/worker/.env\nsystemctl start synth-worker",
408
+ reasoning: "Worker upgrade with concurrency increase. Drain first to avoid job loss, then replace binary and config atomically. Queue depth check confirms processing resumed.",
409
+ stepSummary: [
410
+ { description: "Drain queue — wait for in-flight jobs to complete", reversible: false },
411
+ { description: "Stop worker processes on all nodes", reversible: true },
412
+ { description: "Deploy new worker binary", reversible: true },
413
+ { description: "Update queue concurrency config (WORKER_CONCURRENCY: 4 → 8)", reversible: true },
414
+ { description: "Start worker and verify queue depth drops", reversible: true },
415
+ { description: "Verify queue processing resumes within 30s", reversible: false },
416
+ ],
417
+ },
394
418
  reasoning: "Worker upgrade with concurrency increase. Drain first to avoid job loss, then replace binary and config atomically. Queue depth check confirms processing resumed.",
395
419
  },
396
420
  debriefEntryIds: [],
@@ -24,8 +24,8 @@ vi.mock("../src/agent/envoy-client.js", () => ({
24
24
  EnvoyClient: vi.fn().mockImplementation(function (this: any) {
25
25
  this.requestPlan = vi.fn().mockResolvedValue({
26
26
  blocked: false,
27
- plan: { steps: [{ description: "Step 1", action: "run", target: "/app", reversible: false }], reasoning: "test plan" },
28
- rollbackPlan: { steps: [], reasoning: "no rollback" },
27
+ plan: { scriptedPlan: { platform: "bash", executionScript: "echo test", dryRunScript: null, rollbackScript: null, reasoning: "test plan", stepSummary: [{ description: "Step 1", reversible: false }] }, reasoning: "test plan" },
28
+ rollbackPlan: { scriptedPlan: { platform: "bash", executionScript: "echo rollback", dryRunScript: null, rollbackScript: null, reasoning: "no rollback", stepSummary: [] }, reasoning: "no rollback" },
29
29
  });
30
30
  this.executeApprovedPlan = vi.fn().mockResolvedValue({});
31
31
  this.removeMonitoringDirective = vi.fn().mockResolvedValue({});
@@ -63,7 +63,7 @@ const MOCK_REGISTRY: EnvoyRegistry = {
63
63
  } as unknown as EnvoyRegistry;
64
64
 
65
65
  const MOCK_PLAN: OperationPlan = {
66
- steps: [{ description: "Check services", action: "shell", target: "systemctl status", reversible: false }],
66
+ scriptedPlan: { platform: "bash", executionScript: "systemctl status", dryRunScript: null, rollbackScript: null, reasoning: "standard maintenance check", stepSummary: [{ description: "Check services", reversible: false }] },
67
67
  reasoning: "standard maintenance check",
68
68
  };
69
69
 
@@ -284,8 +284,8 @@ describe("Composite Operations — planning via envoy registry", () => {
284
284
  this.requestPlan = vi.fn().mockResolvedValue({
285
285
  blocked: true,
286
286
  blockReason: "insufficient permissions",
287
- plan: { steps: [], reasoning: "" },
288
- rollbackPlan: { steps: [], reasoning: "" },
287
+ plan: { scriptedPlan: { platform: "bash", executionScript: "", dryRunScript: null, rollbackScript: null, reasoning: "", stepSummary: [] }, reasoning: "" },
288
+ rollbackPlan: { scriptedPlan: { platform: "bash", executionScript: "", dryRunScript: null, rollbackScript: null, reasoning: "", stepSummary: [] }, reasoning: "" },
289
289
  });
290
290
  });
291
291
 
@@ -409,7 +409,7 @@ describe("Composite Operations — approval and execution", () => {
409
409
  // Child has a plan but no envoyId, and this ctx has no envoy registry
410
410
  seedChild(ctx.deployments, parent.id, {
411
411
  plan: MOCK_PLAN,
412
- rollbackPlan: { steps: [], reasoning: "" },
412
+ rollbackPlan: { scriptedPlan: { platform: "bash", executionScript: "", dryRunScript: null, rollbackScript: null, reasoning: "", stepSummary: [] }, reasoning: "" },
413
413
  });
414
414
 
415
415
  await ctx.app.inject({