@highstate/backend 0.18.0 → 0.20.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 (120) hide show
  1. package/dist/{chunk-JT4KWE3B.js → chunk-52MY2TCE.js} +348 -19
  2. package/dist/chunk-52MY2TCE.js.map +1 -0
  3. package/dist/{chunk-I7BWSAN6.js → chunk-UAWBPTDW.js} +3 -3
  4. package/dist/{chunk-I7BWSAN6.js.map → chunk-UAWBPTDW.js.map} +1 -1
  5. package/dist/highstate.manifest.json +4 -4
  6. package/dist/index.js +4159 -785
  7. package/dist/index.js.map +1 -1
  8. package/dist/library/worker/main.js +5 -2
  9. package/dist/library/worker/main.js.map +1 -1
  10. package/dist/shared/index.js +2 -2
  11. package/package.json +7 -7
  12. package/prisma/backend/_schema/object.prisma +12 -0
  13. package/prisma/backend/sqlite/migrations/20260222113554_add_object_tracking/migration.sql +7 -0
  14. package/prisma/project/artifact.prisma +3 -0
  15. package/prisma/project/entity.prisma +125 -0
  16. package/prisma/project/instance.prisma +6 -0
  17. package/prisma/project/migrations/20260301210131_add_entity_tracking/migration.sql +70 -0
  18. package/prisma/project/migrations/20260302212734_add_resource_hooks_flag/migration.sql +1 -0
  19. package/prisma/project/operation.prisma +3 -0
  20. package/src/business/artifact.test.ts +22 -2
  21. package/src/business/artifact.ts +7 -1
  22. package/src/business/entity-snapshot.test.ts +684 -0
  23. package/src/business/entity-snapshot.ts +904 -0
  24. package/src/business/evaluation.test.ts +56 -0
  25. package/src/business/evaluation.ts +102 -22
  26. package/src/business/global-search.test.ts +344 -0
  27. package/src/business/global-search.ts +902 -0
  28. package/src/business/index.ts +4 -0
  29. package/src/business/instance-lock.ts +58 -74
  30. package/src/business/instance-state.test.ts +15 -1
  31. package/src/business/instance-state.ts +37 -14
  32. package/src/business/object-ref-index.test.ts +140 -0
  33. package/src/business/object-ref-index.ts +193 -0
  34. package/src/business/operation.test.ts +15 -1
  35. package/src/business/operation.ts +4 -0
  36. package/src/business/project-model.ts +154 -13
  37. package/src/business/project-unlock.ts +25 -2
  38. package/src/business/project.ts +9 -0
  39. package/src/business/secret.test.ts +35 -2
  40. package/src/business/secret.ts +32 -9
  41. package/src/business/settings.ts +761 -0
  42. package/src/business/unit-output.test.ts +477 -0
  43. package/src/business/unit-output.ts +461 -0
  44. package/src/business/worker.ts +55 -4
  45. package/src/database/_generated/backend/postgresql/browser.ts +6 -0
  46. package/src/database/_generated/backend/postgresql/client.ts +6 -0
  47. package/src/database/_generated/backend/postgresql/internal/class.ts +23 -5
  48. package/src/database/_generated/backend/postgresql/internal/prismaNamespace.ts +89 -5
  49. package/src/database/_generated/backend/postgresql/internal/prismaNamespaceBrowser.ts +9 -0
  50. package/src/database/_generated/backend/postgresql/models/Object.ts +1076 -0
  51. package/src/database/_generated/backend/postgresql/models.ts +1 -0
  52. package/src/database/_generated/backend/sqlite/browser.ts +6 -0
  53. package/src/database/_generated/backend/sqlite/client.ts +6 -0
  54. package/src/database/_generated/backend/sqlite/internal/class.ts +23 -5
  55. package/src/database/_generated/backend/sqlite/internal/prismaNamespace.ts +89 -5
  56. package/src/database/_generated/backend/sqlite/internal/prismaNamespaceBrowser.ts +9 -0
  57. package/src/database/_generated/backend/sqlite/models/Object.ts +1074 -0
  58. package/src/database/_generated/backend/sqlite/models.ts +1 -0
  59. package/src/database/_generated/project/browser.ts +23 -0
  60. package/src/database/_generated/project/client.ts +23 -0
  61. package/src/database/_generated/project/commonInputTypes.ts +87 -53
  62. package/src/database/_generated/project/enums.ts +8 -0
  63. package/src/database/_generated/project/internal/class.ts +53 -5
  64. package/src/database/_generated/project/internal/prismaNamespace.ts +367 -13
  65. package/src/database/_generated/project/internal/prismaNamespaceBrowser.ts +48 -1
  66. package/src/database/_generated/project/models/Artifact.ts +199 -11
  67. package/src/database/_generated/project/models/Entity.ts +1274 -0
  68. package/src/database/_generated/project/models/EntitySnapshot.ts +2389 -0
  69. package/src/database/_generated/project/models/EntitySnapshotContent.ts +1260 -0
  70. package/src/database/_generated/project/models/EntitySnapshotReference.ts +1449 -0
  71. package/src/database/_generated/project/models/InstanceState.ts +361 -1
  72. package/src/database/_generated/project/models/Operation.ts +148 -3
  73. package/src/database/_generated/project/models/OperationLog.ts +0 -4
  74. package/src/database/_generated/project/models.ts +4 -0
  75. package/src/database/migration.ts +3 -0
  76. package/src/library/worker/evaluator.ts +7 -1
  77. package/src/orchestrator/manager.ts +7 -0
  78. package/src/orchestrator/operation-context.captured-outputs.test.ts +118 -0
  79. package/src/orchestrator/operation-context.ts +154 -16
  80. package/src/orchestrator/operation-plan.destroy.test.md +33 -12
  81. package/src/orchestrator/operation-plan.destroy.test.ts +140 -2
  82. package/src/orchestrator/operation-plan.fixtures.ts +2 -0
  83. package/src/orchestrator/operation-plan.md +4 -1
  84. package/src/orchestrator/operation-plan.ts +286 -92
  85. package/src/orchestrator/operation-plan.update.test.md +286 -11
  86. package/src/orchestrator/operation-plan.update.test.ts +656 -5
  87. package/src/orchestrator/operation-workset.ts +72 -22
  88. package/src/orchestrator/operation.cancel.test.ts +4 -0
  89. package/src/orchestrator/operation.composite.test.ts +341 -0
  90. package/src/orchestrator/operation.destroy.test.ts +4 -0
  91. package/src/orchestrator/operation.output-validation.failure.test.ts +124 -0
  92. package/src/orchestrator/operation.preview.test.ts +4 -0
  93. package/src/orchestrator/operation.refresh.test.ts +4 -0
  94. package/src/orchestrator/operation.test-utils.ts +52 -13
  95. package/src/orchestrator/operation.ts +228 -68
  96. package/src/orchestrator/operation.update.failure.test.ts +4 -0
  97. package/src/orchestrator/operation.update.skip.test.ts +110 -0
  98. package/src/orchestrator/operation.update.test.ts +4 -0
  99. package/src/orchestrator/plan-test-builder.ts +1 -0
  100. package/src/orchestrator/unit-input-values.test.ts +450 -0
  101. package/src/orchestrator/unit-input-values.ts +281 -0
  102. package/src/pubsub/manager.ts +3 -0
  103. package/src/runner/abstractions.ts +23 -54
  104. package/src/runner/local.ts +109 -85
  105. package/src/services.ts +52 -1
  106. package/src/shared/models/prisma.ts +1 -0
  107. package/src/shared/models/project/entity.ts +121 -0
  108. package/src/shared/models/project/index.ts +1 -0
  109. package/src/shared/models/project/operation.ts +61 -3
  110. package/src/shared/models/project/state.ts +10 -0
  111. package/src/shared/models/project/worker.ts +7 -0
  112. package/src/shared/resolvers/effective-output-type.test.ts +494 -0
  113. package/src/shared/resolvers/effective-output-type.ts +162 -0
  114. package/src/shared/resolvers/index.ts +1 -0
  115. package/src/shared/resolvers/input.ts +61 -9
  116. package/src/shared/utils/index.ts +1 -0
  117. package/src/shared/utils/stable-json.ts +41 -0
  118. package/src/terminal/manager.ts +6 -0
  119. package/src/worker/manager.ts +97 -1
  120. package/dist/chunk-JT4KWE3B.js.map +0 -1
@@ -35,12 +35,42 @@ export function createOperationPlan(
35
35
  requestedInstanceIds: string[],
36
36
  options: OperationOptions,
37
37
  ): OperationPhase[] {
38
+ const enabledGhostStrategies = [
39
+ options.onlyDestroyGhosts,
40
+ options.firstDestroyGhosts,
41
+ options.ignoreGhosts,
42
+ ].filter(Boolean).length
43
+
44
+ if (enabledGhostStrategies > 1) {
45
+ throw new Error(
46
+ "Operation options are invalid: only one of onlyDestroyGhosts, firstDestroyGhosts, ignoreGhosts can be enabled.",
47
+ )
48
+ }
49
+
50
+ if (type !== "update" && enabledGhostStrategies > 0) {
51
+ throw new Error(
52
+ "Operation options are invalid: onlyDestroyGhosts, firstDestroyGhosts and ignoreGhosts are supported only for update operations.",
53
+ )
54
+ }
55
+
56
+ if (options.forceUpdateDependencies && options.ignoreChangedDependencies) {
57
+ throw new Error(
58
+ "Operation options are invalid: forceUpdateDependencies and ignoreChangedDependencies cannot both be enabled.",
59
+ )
60
+ }
61
+
38
62
  if (options.forceUpdateDependencies && options.ignoreDependencies) {
39
63
  throw new Error(
40
64
  "Operation options are invalid: forceUpdateDependencies and ignoreDependencies cannot both be enabled.",
41
65
  )
42
66
  }
43
67
 
68
+ if (options.ignoreChangedDependencies && options.ignoreDependencies) {
69
+ throw new Error(
70
+ "Operation options are invalid: ignoreChangedDependencies and ignoreDependencies cannot both be enabled.",
71
+ )
72
+ }
73
+
44
74
  if (type === "preview") {
45
75
  if (requestedInstanceIds.length !== 1) {
46
76
  throw new Error("Preview operations can only target a single instance")
@@ -123,7 +153,14 @@ function processInstance(
123
153
  options: OperationOptions,
124
154
  operationType: OperationType,
125
155
  ): void {
126
- const instance = context.getInstance(instanceId)
156
+ const instance =
157
+ operationType === "destroy"
158
+ ? context.tryGetInstanceForDestroy(instanceId)
159
+ : context.tryGetInstance(instanceId)
160
+
161
+ if (!instance) {
162
+ throw new Error(`Instance with ID ${instanceId} not found in the operation context`)
163
+ }
127
164
 
128
165
  // update composite classification
129
166
  updateCompositeClassification(instance, workState, context)
@@ -140,7 +177,7 @@ function processInstance(
140
177
  }
141
178
 
142
179
  // propagate to related instances
143
- propagateToRelated(instance, workState)
180
+ propagateToRelated(instance, workState, context)
144
181
  }
145
182
 
146
183
  function updateCompositeClassification(
@@ -171,7 +208,11 @@ function updateCompositeClassification(
171
208
  // check if this dependency is external to this composite
172
209
  const requiredBy = workState.dependencyRequiredBy.get(child.id)
173
210
  if (requiredBy) {
174
- const requiredByInstance = context.getInstance(requiredBy)
211
+ const requiredByInstance = context.tryGetInstance(requiredBy)
212
+ if (!requiredByInstance) {
213
+ continue
214
+ }
215
+
175
216
  // if the requiring instance is not a child of this composite, it's external
176
217
  if (requiredByInstance.parentId !== instance.id) {
177
218
  hasExternalDependencyChildren = true
@@ -234,7 +275,12 @@ function processUpdateInclusions(
234
275
  continue
235
276
  }
236
277
 
237
- const shouldInclude = options.forceUpdateDependencies || isOutdated(depInstance, context)
278
+ const dependencyState = context.getState(depInstance.id)
279
+
280
+ const shouldInclude =
281
+ options.forceUpdateDependencies ||
282
+ (!options.ignoreChangedDependencies && isOutdated(depInstance, context)) ||
283
+ isFailedOrUndeployedState(dependencyState)
238
284
 
239
285
  if (shouldInclude && !workState.included.has(depInstance.id)) {
240
286
  include(depInstance.id, "dependency", workState, {
@@ -276,7 +322,7 @@ function processRefreshInclusions(
276
322
  if (workState.included.has(instance.id)) {
277
323
  const dependencies = context.getDependencies(instance.id)
278
324
  for (const depInstance of dependencies) {
279
- if (options.ignoreDependencies) {
325
+ if (options.ignoreDependencies || options.ignoreChangedDependencies) {
280
326
  continue
281
327
  }
282
328
 
@@ -292,6 +338,10 @@ function processRefreshInclusions(
292
338
  }
293
339
  }
294
340
 
341
+ function isFailedOrUndeployedState(state: InstanceState): boolean {
342
+ return state.status === "failed" || state.status === "undeployed"
343
+ }
344
+
295
345
  function processDestroyInclusions(
296
346
  instance: InstanceModel,
297
347
  workState: WorkState,
@@ -324,21 +374,16 @@ function processDestroyInclusions(
324
374
  }
325
375
  }
326
376
 
327
- function propagateToRelated(instance: InstanceModel, workState: WorkState): void {
328
- // check if this instance should propagate upward
329
- // composites included as "parent_composite" should not propagate upward (compositional boundary)
330
- if (instance.kind === "composite") {
331
- const inclusionReason = workState.included.get(instance.id)
332
- if (inclusionReason === "parent_composite") {
333
- // compositional boundary - don't propagate upward
334
- return
335
- }
336
- }
337
-
377
+ function propagateToRelated(
378
+ instance: InstanceModel,
379
+ workState: WorkState,
380
+ context: OperationContext,
381
+ ): void {
338
382
  // propagate upward to parent if instance is included
339
383
  if (
340
384
  workState.included.has(instance.id) &&
341
385
  instance.parentId &&
386
+ context.tryGetInstance(instance.parentId) &&
342
387
  !workState.included.has(instance.parentId)
343
388
  ) {
344
389
  include(instance.parentId, "parent_composite", workState, {
@@ -361,7 +406,11 @@ function findSubstantiveAncestor(
361
406
  return currentId
362
407
  }
363
408
 
364
- const instance = context.getInstance(currentId)
409
+ const instance = context.tryGetInstance(currentId)
410
+ if (!instance) {
411
+ return null
412
+ }
413
+
365
414
  currentId = instance.parentId
366
415
  }
367
416
 
@@ -436,11 +485,7 @@ function createOrderedPhases(
436
485
  // include parent composites only if they have children needing updates
437
486
  if (inclusionReason === "parent_composite") {
438
487
  const children = context.getInstanceChildren(id)
439
- return children.some(
440
- child =>
441
- workState.included.has(child.id) &&
442
- workState.included.get(child.id) !== "parent_composite",
443
- )
488
+ return children.some(child => workState.included.has(child.id))
444
489
  }
445
490
 
446
491
  // include other types (dependency, composite_child, etc.)
@@ -448,68 +493,50 @@ function createOrderedPhases(
448
493
  })
449
494
 
450
495
  const updateInstances = topologicalSort(instancesNeedingUpdate, context, false)
451
- .map(id => createPhaseInstance(id, context, workState))
496
+ .map(id => createPhaseInstance(id, context, type, workState))
452
497
  .filter(inst => inst !== null) as OperationPhaseInstance[]
453
498
 
454
- if (updateInstances.length > 0) {
455
- const phaseType = type === "refresh" ? "refresh" : "update"
456
- phases.push({ type: phaseType, instances: updateInstances })
457
- }
499
+ const phaseType = type === "refresh" ? "refresh" : "update"
500
+ const updatePhase =
501
+ updateInstances.length > 0 ? ({ type: phaseType, instances: updateInstances } as const) : null
458
502
 
459
- // handle ghost cleanup for updates (but not for refresh operations)
460
- if (type !== "refresh") {
461
- const compositesNeedingGhostCleanup = new Set<InstanceId>()
462
- for (const instanceId of includedIds) {
463
- const instance = context.getInstance(instanceId)
464
- if (instance.kind !== "composite") continue
503
+ const ghostDestroyPhase =
504
+ type !== "refresh" && !options.ignoreGhosts
505
+ ? createGhostDestroyPhase(context, includedIds, workState)
506
+ : null
465
507
 
466
- const compositeType = workState.compositeTypes.get(instanceId)
467
- if (compositeType !== "substantive") continue
508
+ if (type === "update" && options.onlyDestroyGhosts) {
509
+ if (ghostDestroyPhase) {
510
+ phases.push(ghostDestroyPhase)
511
+ }
468
512
 
469
- // check if this composite has ghost children
470
- const children = context.getInstanceChildren(instanceId)
471
- const hasGhostChildren = children.some(child => {
472
- const state = context.getState(child.id)
473
- return isVirtualGhostInstance(state)
474
- })
513
+ return phases
514
+ }
475
515
 
476
- if (hasGhostChildren) {
477
- compositesNeedingGhostCleanup.add(instanceId)
478
- }
516
+ if (type === "update" && options.firstDestroyGhosts) {
517
+ if (ghostDestroyPhase) {
518
+ phases.push(ghostDestroyPhase)
479
519
  }
480
- const ghostInstances = findGhostCleanup(context, compositesNeedingGhostCleanup)
481
-
482
- if (ghostInstances.length > 0) {
483
- const ghostInstanceMap = new Map<InstanceId, OperationPhaseInstance>(
484
- ghostInstances.map(instance => [instance.id, instance]),
485
- )
486
-
487
- const sortedGhosts = topologicalSort(
488
- ghostInstances.map(g => g.id),
489
- context,
490
- true,
491
- )
492
- .map(id => {
493
- const ghostInstance = ghostInstanceMap.get(id)
494
-
495
- if (ghostInstance?.message === "ghost cleanup") {
496
- return ghostInstance
497
- }
498
-
499
- return createPhaseInstance(id, context, workState)
500
- })
501
- .filter((instance): instance is OperationPhaseInstance => instance !== null)
502
520
 
503
- if (sortedGhosts.length > 0) {
504
- phases.push({ type: "destroy", instances: sortedGhosts })
505
- }
521
+ if (updatePhase) {
522
+ phases.push(updatePhase)
506
523
  }
524
+
525
+ return phases
526
+ }
527
+
528
+ if (updatePhase) {
529
+ phases.push(updatePhase)
530
+ }
531
+
532
+ if (ghostDestroyPhase) {
533
+ phases.push(ghostDestroyPhase)
507
534
  }
508
535
  }
509
536
 
510
537
  if (type === "destroy") {
511
538
  const destroyInstances = topologicalSort(includedIds, context, true)
512
- .map(id => createPhaseInstance(id, context, workState))
539
+ .map(id => createPhaseInstance(id, context, type, workState))
513
540
  .filter(inst => inst !== null) as OperationPhaseInstance[]
514
541
 
515
542
  if (destroyInstances.length > 0) {
@@ -519,11 +546,11 @@ function createOrderedPhases(
519
546
 
520
547
  if (type === "recreate") {
521
548
  const destroyInstances = topologicalSort(includedIds, context, true)
522
- .map(id => createPhaseInstance(id, context, workState))
549
+ .map(id => createPhaseInstance(id, context, type, workState))
523
550
  .filter(inst => inst !== null) as OperationPhaseInstance[]
524
551
 
525
552
  const updateInstances = topologicalSort(includedIds, context, false)
526
- .map(id => createPhaseInstance(id, context, workState))
553
+ .map(id => createPhaseInstance(id, context, type, workState))
527
554
  .filter(inst => inst !== null) as OperationPhaseInstance[]
528
555
 
529
556
  if (destroyInstances.length > 0) {
@@ -537,12 +564,101 @@ function createOrderedPhases(
537
564
  return phases
538
565
  }
539
566
 
567
+ function createGhostDestroyPhase(
568
+ context: OperationContext,
569
+ includedIds: InstanceId[],
570
+ workState: WorkState,
571
+ ): OperationPhase | null {
572
+ const compositesNeedingGhostCleanup = new Set<InstanceId>()
573
+
574
+ for (const instanceId of includedIds) {
575
+ const instance = context.getInstance(instanceId)
576
+ if (instance.kind !== "composite") continue
577
+
578
+ const compositeType = workState.compositeTypes.get(instanceId)
579
+ if (compositeType !== "substantive") continue
580
+
581
+ if (hasGhostDescendant(instanceId, context)) {
582
+ compositesNeedingGhostCleanup.add(instanceId)
583
+ }
584
+ }
585
+
586
+ const ghostInstances = findGhostCleanup(context, compositesNeedingGhostCleanup)
587
+ if (ghostInstances.length === 0) {
588
+ return null
589
+ }
590
+
591
+ const ghostInstanceMap = new Map<InstanceId, OperationPhaseInstance>(
592
+ ghostInstances.map(instance => [instance.id, instance]),
593
+ )
594
+
595
+ const sortedGhosts = topologicalSort(
596
+ ghostInstances.map(g => g.id),
597
+ context,
598
+ true,
599
+ )
600
+ .map(id => {
601
+ const ghostInstance = ghostInstanceMap.get(id)
602
+
603
+ if (ghostInstance?.message === "ghost cleanup") {
604
+ return ghostInstance
605
+ }
606
+
607
+ return createPhaseInstance(id, context, "update", workState)
608
+ })
609
+ .filter((instance): instance is OperationPhaseInstance => instance !== null)
610
+
611
+ if (sortedGhosts.length === 0) {
612
+ return null
613
+ }
614
+
615
+ return { type: "destroy", instances: sortedGhosts }
616
+ }
617
+
618
+ function hasGhostDescendant(instanceId: InstanceId, context: OperationContext): boolean {
619
+ const queue = context.getInstanceChildren(instanceId).map(child => child.id)
620
+
621
+ while (queue.length > 0) {
622
+ const childId = queue.shift()!
623
+ const child = context.getInstance(childId)
624
+ const childState = context.getState(child.id)
625
+
626
+ if (isVirtualGhostInstance(childState)) {
627
+ return true
628
+ }
629
+
630
+ if (child.kind === "composite") {
631
+ queue.push(...context.getInstanceChildren(child.id).map(instance => instance.id))
632
+ }
633
+ }
634
+
635
+ return false
636
+ }
637
+
540
638
  function createPhaseInstance(
541
639
  instanceId: InstanceId,
542
640
  context: OperationContext,
641
+ operationType: OperationType,
543
642
  workState?: WorkState,
544
643
  ): OperationPhaseInstance | null {
545
- const instance = context.getInstance(instanceId)
644
+ const state = context.getState(instanceId)
645
+ const instance =
646
+ operationType === "destroy"
647
+ ? context.tryGetInstanceForDestroy(instanceId)
648
+ : context.tryGetInstance(instanceId)
649
+
650
+ if (!instance) {
651
+ return null
652
+ }
653
+
654
+ if (
655
+ operationType !== "destroy" &&
656
+ instance.kind === "composite" &&
657
+ !hasUnitDescendant(instanceId, context)
658
+ ) {
659
+ return null
660
+ }
661
+
546
662
  let message = "included in operation" // fallback
547
663
 
548
664
  if (workState) {
@@ -550,7 +666,7 @@ function createPhaseInstance(
550
666
  const requiredBy = workState.dependencyRequiredBy.get(instanceId)
551
667
  const triggeringChild = workState.childTriggeringParent.get(instanceId)
552
668
  const forceFlag = workState.forceFlags.get(instanceId)
553
- const instanceState = context.getState(instanceId)
669
+ const instanceState = state
554
670
 
555
671
  message = generateContextualMessage(
556
672
  context,
@@ -565,11 +681,34 @@ function createPhaseInstance(
565
681
 
566
682
  return {
567
683
  id: instanceId,
568
- parentId: instance.parentId,
684
+ parentId: instance.parentId ?? state.parentInstanceId ?? undefined,
569
685
  message,
570
686
  }
571
687
  }
572
688
 
689
+ function hasUnitDescendant(instanceId: InstanceId, context: OperationContext): boolean {
690
+ const queue = context.getInstanceChildren(instanceId).map(child => child.id)
691
+ const visited = new Set<InstanceId>()
692
+
693
+ while (queue.length > 0) {
694
+ const childId = queue.pop()!
695
+
696
+ if (visited.has(childId)) {
697
+ continue
698
+ }
699
+ visited.add(childId)
700
+
701
+ const child = context.getInstance(childId)
702
+ if (child.kind === "unit") {
703
+ return true
704
+ }
705
+
706
+ queue.push(...context.getInstanceChildren(childId).map(instance => instance.id))
707
+ }
708
+
709
+ return false
710
+ }
711
+
573
712
  function generateContextualMessage(
574
713
  context: OperationContext,
575
714
  instanceId: InstanceId,
@@ -586,7 +725,10 @@ function generateContextualMessage(
586
725
  if (state.status === "failed") return "failed"
587
726
  if (state.status === "undeployed") return "undeployed"
588
727
 
589
- const instance = context.getInstance(instanceId)
728
+ const instance = context.tryGetInstance(instanceId)
729
+ if (!instance) {
730
+ return "up-to-date"
731
+ }
590
732
 
591
733
  // composites are containers and cannot be changed/outdated
592
734
  if (instance.kind === "composite") {
@@ -654,32 +796,84 @@ function findGhostCleanup(
654
796
  context: OperationContext,
655
797
  compositesNeedingGhostCleanup: Set<InstanceId>,
656
798
  ): OperationPhaseInstance[] {
657
- const ghosts: OperationPhaseInstance[] = []
799
+ const requiredCompositeIds = new Set<InstanceId>()
800
+ const ghostIds = new Set<InstanceId>()
801
+
802
+ function collectGhostsInSubtree(rootCompositeId: InstanceId): void {
803
+ const queue: InstanceId[] = [rootCompositeId]
804
+
805
+ while (queue.length > 0) {
806
+ const currentId = queue.shift()!
807
+ const children = context.getInstanceChildren(currentId)
808
+
809
+ for (const child of children) {
810
+ const childState = context.getState(child.id)
811
+
812
+ if (child.kind === "composite") {
813
+ queue.push(child.id)
814
+ }
815
+
816
+ if (!isVirtualGhostInstance(childState)) {
817
+ continue
818
+ }
819
+
820
+ ghostIds.add(child.id)
821
+
822
+ // Include the full composite chain from the ghost parent up to the cleanup root,
823
+ // otherwise destroy-phase parent state recalculation may target non-participating composites.
824
+ let parentId = child.parentId
825
+ while (parentId) {
826
+ const parent = context.tryGetInstance(parentId)
827
+ if (!parent || parent.kind !== "composite") {
828
+ break
829
+ }
830
+
831
+ requiredCompositeIds.add(parentId)
832
+
833
+ if (parentId === rootCompositeId) {
834
+ break
835
+ }
836
+
837
+ parentId = parent.parentId
838
+ }
839
+ }
840
+ }
841
+ }
658
842
 
659
- // find ghost instances and their parent composites that need cleanup
660
843
  for (const instanceId of compositesNeedingGhostCleanup) {
661
844
  const instance = context.getInstance(instanceId)
662
845
  if (instance.kind !== "composite") continue
663
846
 
664
- // add the composite itself for destroy if needed
847
+ requiredCompositeIds.add(instanceId)
848
+ collectGhostsInSubtree(instanceId)
849
+ }
850
+
851
+ const ghosts: OperationPhaseInstance[] = []
852
+
853
+ for (const compositeId of requiredCompositeIds) {
854
+ const composite = context.tryGetInstance(compositeId)
855
+ if (!composite) {
856
+ continue
857
+ }
858
+
665
859
  ghosts.push({
666
- id: instanceId,
667
- parentId: instance.parentId,
860
+ id: compositeId,
861
+ parentId: composite.parentId,
668
862
  message: "included in operation",
669
863
  })
864
+ }
670
865
 
671
- // find ghost children
672
- const children = context.getInstanceChildren(instanceId)
673
- for (const child of children) {
674
- const state = context.getState(child.id)
675
- if (isVirtualGhostInstance(state)) {
676
- ghosts.push({
677
- id: child.id,
678
- parentId: child.parentId,
679
- message: "ghost cleanup",
680
- })
681
- }
866
+ for (const ghostId of ghostIds) {
867
+ const ghost = context.tryGetInstance(ghostId)
868
+ if (!ghost) {
869
+ continue
682
870
  }
871
+
872
+ ghosts.push({
873
+ id: ghostId,
874
+ parentId: ghost.parentId,
875
+ message: "ghost cleanup",
876
+ })
683
877
  }
684
878
 
685
879
  return ghosts