@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
@@ -1,16 +1,16 @@
1
1
  import type { InstanceId } from "@highstate/contract"
2
2
  import type { InstanceStateService, UpdateOperationStateOptions } from "../business"
3
3
  import type { InstanceOperationStatus } from "../database"
4
+ import type {
5
+ InstanceStatus,
6
+ OperationPhase,
7
+ OperationPhaseType,
8
+ OperationType,
9
+ ProjectOutput,
10
+ } from "../shared"
4
11
  import type { OperationContext } from "./operation-context"
5
12
  import { EventEmitter, on } from "node:events"
6
13
  import { mapValues } from "remeda"
7
- import {
8
- type InstanceStatus,
9
- isTransientInstanceOperationStatus,
10
- type OperationPhase,
11
- type OperationPhaseType,
12
- type ProjectOutput,
13
- } from "../shared"
14
14
 
15
15
  type AbortControllerPair = {
16
16
  abortController: AbortController
@@ -35,6 +35,7 @@ export class OperationWorkset {
35
35
  constructor(
36
36
  readonly project: ProjectOutput,
37
37
  readonly operationId: string,
38
+ readonly operationType: OperationType,
38
39
  readonly phases: OperationPhase[],
39
40
  private readonly context: OperationContext,
40
41
  private readonly instanceStateService: InstanceStateService,
@@ -117,16 +118,33 @@ export class OperationWorkset {
117
118
  const patches = await this.instanceStateService.createOperationStates(
118
119
  this.project.id,
119
120
  Array.from(this.allAffectedInstanceIds).map(instanceId => {
120
- const instance = this.context.getInstance(instanceId)
121
121
  const state = this.context.getState(instanceId)
122
-
123
- const resolvedInputs = mapValues(
124
- //
125
- this.context.getResolvedInputs(instance.id) ?? {},
126
- inputs => inputs.map(input => input.input),
122
+ const hasDestroyPhase = this.phases.some(
123
+ phase =>
124
+ phase.type === "destroy" &&
125
+ phase.instances.some(instance => instance.id === instanceId),
127
126
  )
128
127
 
129
- const serializedResolvedInputs = this.context.serializeResolvedInputs(resolvedInputs)
128
+ // destroy plans may reference state-only instances that are no longer in the project model
129
+ const instance = hasDestroyPhase
130
+ ? (this.context.tryGetInstance(instanceId) ??
131
+ this.context.tryGetInstanceForDestroy(instanceId))
132
+ : this.context.tryGetInstance(instanceId)
133
+
134
+ if (!instance) {
135
+ throw new Error(`Instance with ID ${instanceId} not found in the operation context`)
136
+ }
137
+
138
+ const serializedResolvedInputs =
139
+ this.context.getResolvedInputs(instance.id) != null
140
+ ? this.context.serializeResolvedInputs(
141
+ mapValues(
142
+ //
143
+ this.context.getResolvedInputs(instance.id) ?? {},
144
+ inputs => inputs.map(input => input.input),
145
+ ),
146
+ )
147
+ : (state.resolvedInputs ?? {})
130
148
 
131
149
  return [
132
150
  {
@@ -155,6 +173,10 @@ export class OperationWorkset {
155
173
  async updateState(instanceId: InstanceId, options: UpdateOperationStateOptions): Promise<void> {
156
174
  const state = this.context.getState(instanceId)
157
175
 
176
+ if (state.lastOperationState?.operationId !== this.operationId) {
177
+ return
178
+ }
179
+
158
180
  const patch = await this.instanceStateService.updateOperationState(
159
181
  this.project.id,
160
182
  state.id,
@@ -164,9 +186,11 @@ export class OperationWorkset {
164
186
 
165
187
  Object.assign(state, patch)
166
188
 
167
- if (state.parentInstanceId && this.currentPhase !== "preview") {
189
+ const parentInstanceId = this.getPhaseParentId(instanceId)
190
+
191
+ if (parentInstanceId && this.currentPhase !== "preview") {
168
192
  // TODO: update all updates in single transaction
169
- await this.recalculateCompositeInstanceState(state.parentInstanceId)
193
+ await this.recalculateCompositeInstanceState(parentInstanceId)
170
194
  }
171
195
  }
172
196
 
@@ -221,12 +245,6 @@ export class OperationWorkset {
221
245
 
222
246
  await this.updateState(instanceId, {
223
247
  operationState: {
224
- status:
225
- !state.lastOperationState?.status ||
226
- // do not override final statuses
227
- isTransientInstanceOperationStatus(state.lastOperationState.status)
228
- ? this.getTransientStatusByOperationPhase()
229
- : state.lastOperationState.status,
230
248
  currentResourceCount,
231
249
  totalResourceCount: finalTotalResourceCount,
232
250
  },
@@ -271,6 +289,7 @@ export class OperationWorkset {
271
289
 
272
290
  getNextStableInstanceStatus(instanceId: InstanceId): InstanceStatus {
273
291
  const state = this.context.getState(instanceId)
292
+ const modelInstance = this.context.tryGetInstance(instanceId)
274
293
 
275
294
  switch (this.currentPhase) {
276
295
  case "preview":
@@ -278,12 +297,43 @@ export class OperationWorkset {
278
297
  case "update":
279
298
  return "deployed"
280
299
  case "destroy":
300
+ // update operations may include a destroy phase only for ghost cleanup
301
+ // keep composite containers deployed only while they still have unit descendants
302
+ if (this.operationType === "update" && modelInstance?.kind === "composite") {
303
+ return this.hasActiveUnitDescendant(instanceId) ? state.status : "undeployed"
304
+ }
305
+
281
306
  return "undeployed"
282
307
  case "refresh":
283
308
  return state.status // do not change instance status when refreshing
284
309
  }
285
310
  }
286
311
 
312
+ private hasActiveUnitDescendant(instanceId: InstanceId): boolean {
313
+ const queue = [...this.context.getStateChildIds(instanceId)]
314
+ const visited = new Set<InstanceId>()
315
+
316
+ while (queue.length > 0) {
317
+ const childId = queue.shift()
318
+ if (!childId || visited.has(childId)) {
319
+ continue
320
+ }
321
+
322
+ visited.add(childId)
323
+
324
+ const childState = this.context.getState(childId)
325
+ if (childState.kind === "unit" && childState.status !== "undeployed") {
326
+ return true
327
+ }
328
+
329
+ if (childState.kind === "composite") {
330
+ queue.push(...this.context.getStateChildIds(childId))
331
+ }
332
+ }
333
+
334
+ return false
335
+ }
336
+
287
337
  setupAbortControllersForAllInstances(): void {
288
338
  for (const instanceId of this.allAffectedInstanceIds) {
289
339
  this.setupInstanceAbortControllers(instanceId)
@@ -20,6 +20,8 @@ describe("Operation - Cancel", () => {
20
20
  instanceStateService,
21
21
  projectModelService,
22
22
  unitExtraService,
23
+ entitySnapshotService,
24
+ unitOutputService,
23
25
  createUnit,
24
26
  createDeployedUnitState,
25
27
  createOperation,
@@ -86,6 +88,8 @@ describe("Operation - Cancel", () => {
86
88
  instanceStateService,
87
89
  projectModelService,
88
90
  unitExtraService,
91
+ entitySnapshotService,
92
+ unitOutputService,
89
93
  logger,
90
94
  )
91
95
 
@@ -18,6 +18,8 @@ describe("Operation - Composite", () => {
18
18
  instanceStateService,
19
19
  projectModelService,
20
20
  unitExtraService,
21
+ entitySnapshotService,
22
+ unitOutputService,
21
23
  createComposite,
22
24
  createUnit,
23
25
  createDeployedUnitState,
@@ -79,6 +81,8 @@ describe("Operation - Composite", () => {
79
81
  instanceStateService,
80
82
  projectModelService,
81
83
  unitExtraService,
84
+ entitySnapshotService,
85
+ unitOutputService,
82
86
  logger,
83
87
  )
84
88
 
@@ -120,4 +124,341 @@ describe("Operation - Composite", () => {
120
124
  )
121
125
  },
122
126
  )
127
+
128
+ operationTest(
129
+ "recalculates using phase parent when state parent is stale and outside operation",
130
+ async ({
131
+ project,
132
+ logger,
133
+ runnerBackend,
134
+ libraryBackend,
135
+ artifactService,
136
+ instanceLockService,
137
+ operationService,
138
+ secretService,
139
+ instanceStateService,
140
+ projectModelService,
141
+ unitExtraService,
142
+ entitySnapshotService,
143
+ unitOutputService,
144
+ createComposite,
145
+ createUnit,
146
+ createDeployedUnitState,
147
+ createOperation,
148
+ createContext,
149
+ setupPersistenceMocks,
150
+ setupImmediateLocking,
151
+ expect,
152
+ }) => {
153
+ // arrange
154
+ const grandParent = createComposite("GrandParent")
155
+ const parent = {
156
+ ...createComposite("Parent"),
157
+ parentId: grandParent.id,
158
+ }
159
+ const oldParent = createComposite("OldParent")
160
+ const child = {
161
+ ...createUnit("Child"),
162
+ parentId: parent.id,
163
+ }
164
+
165
+ const grandParentState = createDeployedUnitState(grandParent)
166
+ const parentState = createDeployedUnitState(parent)
167
+ const oldParentState = createDeployedUnitState(oldParent)
168
+ const childState = createDeployedUnitState(child)
169
+
170
+ // simulate state/model drift: child state still points to old parent state
171
+ childState.parentInstanceId = oldParent.id
172
+
173
+ await createContext({
174
+ instances: [grandParent, parent, child],
175
+ states: [grandParentState, parentState, oldParentState, childState],
176
+ })
177
+
178
+ setupImmediateLocking()
179
+ setupPersistenceMocks({ instances: [grandParent, parent, child, oldParent] })
180
+
181
+ const inOperationStateIds = new Set([grandParent.id, parent.id, child.id])
182
+ const baseUpdateMock = instanceStateService.updateOperationState.getMockImplementation()
183
+
184
+ instanceStateService.updateOperationState.mockImplementation(
185
+ async (projectId, stateId, operationId, options) => {
186
+ if (!inOperationStateIds.has(stateId as typeof grandParent.id)) {
187
+ throw new Error(`No operation state row for stateId ${stateId}`)
188
+ }
189
+
190
+ if (!baseUpdateMock) {
191
+ throw new Error("updateOperationState base mock is not initialized")
192
+ }
193
+
194
+ return await baseUpdateMock(projectId, stateId, operationId, options)
195
+ },
196
+ )
197
+
198
+ const operation = createOperation({
199
+ type: "update",
200
+ requestedInstanceIds: [child.id],
201
+ phases: [
202
+ {
203
+ type: "update",
204
+ instances: [
205
+ { id: grandParent.id, message: "ancestor", parentId: undefined },
206
+ { id: parent.id, message: "parent", parentId: grandParent.id },
207
+ { id: child.id, message: "requested", parentId: parent.id },
208
+ ],
209
+ },
210
+ ],
211
+ })
212
+
213
+ const runtimeOperation = new RuntimeOperation(
214
+ project,
215
+ operation,
216
+ runnerBackend,
217
+ libraryBackend,
218
+ artifactService,
219
+ instanceLockService,
220
+ operationService,
221
+ secretService,
222
+ instanceStateService,
223
+ projectModelService,
224
+ unitExtraService,
225
+ entitySnapshotService,
226
+ unitOutputService,
227
+ logger,
228
+ )
229
+
230
+ // act
231
+ await runtimeOperation.operateSafe()
232
+
233
+ // assert
234
+ const recalculatedOldParent = instanceStateService.updateOperationState.mock.calls.some(
235
+ ([, stateId]) => stateId === oldParent.id,
236
+ )
237
+
238
+ expect(recalculatedOldParent).toBe(false)
239
+ expect(operationService.markOperationFinished).toHaveBeenCalledWith(
240
+ project.id,
241
+ operation.id,
242
+ "completed",
243
+ )
244
+ },
245
+ )
246
+
247
+ operationTest(
248
+ "keeps composite parents deployed during ghost cleanup destroy phase in update operations",
249
+ async ({
250
+ project,
251
+ logger,
252
+ runnerBackend,
253
+ libraryBackend,
254
+ artifactService,
255
+ instanceLockService,
256
+ operationService,
257
+ secretService,
258
+ instanceStateService,
259
+ projectModelService,
260
+ unitExtraService,
261
+ entitySnapshotService,
262
+ unitOutputService,
263
+ createComposite,
264
+ createUnit,
265
+ createDeployedUnitState,
266
+ createOperation,
267
+ createContext,
268
+ setupPersistenceMocks,
269
+ setupImmediateLocking,
270
+ expect,
271
+ }) => {
272
+ // arrange
273
+ const grandParent = createComposite("GrandParent")
274
+ const parent = {
275
+ ...createComposite("Parent"),
276
+ parentId: grandParent.id,
277
+ }
278
+ const child = {
279
+ ...createUnit("Child"),
280
+ parentId: parent.id,
281
+ }
282
+ const ghostChild = {
283
+ ...createUnit("GhostChild"),
284
+ parentId: parent.id,
285
+ }
286
+
287
+ const grandParentState = createDeployedUnitState(grandParent)
288
+ const parentState = createDeployedUnitState(parent)
289
+ const childState = createDeployedUnitState(child)
290
+ const ghostChildState = createDeployedUnitState(ghostChild)
291
+
292
+ await createContext({
293
+ instances: [grandParent, parent, child],
294
+ states: [grandParentState, parentState, childState, ghostChildState],
295
+ })
296
+ setupImmediateLocking()
297
+ setupPersistenceMocks({ instances: [grandParent, parent, child, ghostChild] })
298
+
299
+ const operation = createOperation({
300
+ type: "update",
301
+ requestedInstanceIds: [parent.id],
302
+ phases: [
303
+ {
304
+ type: "update",
305
+ instances: [
306
+ { id: grandParent.id, message: "ancestor", parentId: undefined },
307
+ { id: parent.id, message: "requested", parentId: grandParent.id },
308
+ { id: child.id, message: "child", parentId: parent.id },
309
+ ],
310
+ },
311
+ {
312
+ type: "destroy",
313
+ instances: [
314
+ { id: ghostChild.id, message: "ghost cleanup", parentId: parent.id },
315
+ { id: parent.id, message: "parent of included child", parentId: grandParent.id },
316
+ { id: grandParent.id, message: "parent of included child", parentId: undefined },
317
+ ],
318
+ },
319
+ ],
320
+ })
321
+
322
+ const runtimeOperation = new RuntimeOperation(
323
+ project,
324
+ operation,
325
+ runnerBackend,
326
+ libraryBackend,
327
+ artifactService,
328
+ instanceLockService,
329
+ operationService,
330
+ secretService,
331
+ instanceStateService,
332
+ projectModelService,
333
+ unitExtraService,
334
+ entitySnapshotService,
335
+ unitOutputService,
336
+ logger,
337
+ )
338
+
339
+ // act
340
+ await runtimeOperation.operateSafe()
341
+
342
+ // assert
343
+ const parentStateUpdates = instanceStateService.updateOperationState.mock.calls
344
+ .filter(([, stateId]) => stateId === parent.id)
345
+ .map(([, , , options]) => options.instanceState?.status)
346
+ .filter((status): status is NonNullable<typeof status> => status != null)
347
+
348
+ const grandParentStateUpdates = instanceStateService.updateOperationState.mock.calls
349
+ .filter(([, stateId]) => stateId === grandParent.id)
350
+ .map(([, , , options]) => options.instanceState?.status)
351
+ .filter((status): status is NonNullable<typeof status> => status != null)
352
+
353
+ expect(parentStateUpdates).toContain("deployed")
354
+ expect(grandParentStateUpdates).toContain("deployed")
355
+ expect(parentStateUpdates).not.toContain("undeployed")
356
+ expect(grandParentStateUpdates).not.toContain("undeployed")
357
+ },
358
+ )
359
+
360
+ operationTest(
361
+ "marks composite parents undeployed after ghost cleanup when no unit descendants remain",
362
+ async ({
363
+ project,
364
+ logger,
365
+ runnerBackend,
366
+ libraryBackend,
367
+ artifactService,
368
+ instanceLockService,
369
+ operationService,
370
+ secretService,
371
+ instanceStateService,
372
+ projectModelService,
373
+ unitExtraService,
374
+ entitySnapshotService,
375
+ unitOutputService,
376
+ createComposite,
377
+ createUnit,
378
+ createDeployedUnitState,
379
+ createOperation,
380
+ createContext,
381
+ setupPersistenceMocks,
382
+ setupImmediateLocking,
383
+ expect,
384
+ }) => {
385
+ // arrange
386
+ const grandParent = createComposite("GrandParent")
387
+ const parent = {
388
+ ...createComposite("Parent"),
389
+ parentId: grandParent.id,
390
+ }
391
+ const ghostChild = {
392
+ ...createUnit("GhostChild"),
393
+ parentId: parent.id,
394
+ }
395
+
396
+ const grandParentState = createDeployedUnitState(grandParent)
397
+ const parentState = createDeployedUnitState(parent)
398
+ const ghostChildState = createDeployedUnitState(ghostChild)
399
+
400
+ await createContext({
401
+ instances: [grandParent, parent],
402
+ states: [grandParentState, parentState, ghostChildState],
403
+ })
404
+ setupImmediateLocking()
405
+ setupPersistenceMocks({ instances: [grandParent, parent, ghostChild] })
406
+
407
+ const operation = createOperation({
408
+ type: "update",
409
+ requestedInstanceIds: [parent.id],
410
+ phases: [
411
+ {
412
+ type: "update",
413
+ instances: [
414
+ { id: grandParent.id, message: "ancestor", parentId: undefined },
415
+ { id: parent.id, message: "requested", parentId: grandParent.id },
416
+ ],
417
+ },
418
+ {
419
+ type: "destroy",
420
+ instances: [
421
+ { id: ghostChild.id, message: "ghost cleanup", parentId: parent.id },
422
+ { id: parent.id, message: "parent of included child", parentId: grandParent.id },
423
+ { id: grandParent.id, message: "parent of included child", parentId: undefined },
424
+ ],
425
+ },
426
+ ],
427
+ })
428
+
429
+ const runtimeOperation = new RuntimeOperation(
430
+ project,
431
+ operation,
432
+ runnerBackend,
433
+ libraryBackend,
434
+ artifactService,
435
+ instanceLockService,
436
+ operationService,
437
+ secretService,
438
+ instanceStateService,
439
+ projectModelService,
440
+ unitExtraService,
441
+ entitySnapshotService,
442
+ unitOutputService,
443
+ logger,
444
+ )
445
+
446
+ // act
447
+ await runtimeOperation.operateSafe()
448
+
449
+ // assert
450
+ const parentStateUpdates = instanceStateService.updateOperationState.mock.calls
451
+ .filter(([, stateId]) => stateId === parent.id)
452
+ .map(([, , , options]) => options.instanceState?.status)
453
+ .filter((status): status is NonNullable<typeof status> => status != null)
454
+
455
+ const grandParentStateUpdates = instanceStateService.updateOperationState.mock.calls
456
+ .filter(([, stateId]) => stateId === grandParent.id)
457
+ .map(([, , , options]) => options.instanceState?.status)
458
+ .filter((status): status is NonNullable<typeof status> => status != null)
459
+
460
+ expect(parentStateUpdates).toContain("undeployed")
461
+ expect(grandParentStateUpdates).toContain("undeployed")
462
+ },
463
+ )
123
464
  })
@@ -18,6 +18,8 @@ describe("Operation - Destroy", () => {
18
18
  instanceStateService,
19
19
  projectModelService,
20
20
  unitExtraService,
21
+ entitySnapshotService,
22
+ unitOutputService,
21
23
  createUnit,
22
24
  createDeployedUnitState,
23
25
  createOperation,
@@ -59,6 +61,8 @@ describe("Operation - Destroy", () => {
59
61
  instanceStateService,
60
62
  projectModelService,
61
63
  unitExtraService,
64
+ entitySnapshotService,
65
+ unitOutputService,
62
66
  logger,
63
67
  )
64
68
 
@@ -0,0 +1,124 @@
1
+ import { describe } from "vitest"
2
+ import { RuntimeOperation } from "./operation"
3
+ import { operationTest } from "./operation.test-utils"
4
+
5
+ describe("Operation - Output Validation Failure", () => {
6
+ operationTest(
7
+ "writes instance log and fails instance when unit outputs are invalid",
8
+ async ({
9
+ project,
10
+ logger,
11
+ runnerBackend,
12
+ runner,
13
+ libraryBackend,
14
+ artifactService,
15
+ instanceLockService,
16
+ operationService,
17
+ secretService,
18
+ instanceStateService,
19
+ projectModelService,
20
+ unitExtraService,
21
+ entitySnapshotService,
22
+ unitOutputService,
23
+ createUnit,
24
+ createDeployedUnitState,
25
+ createOperation,
26
+ createContext,
27
+ setupPersistenceMocks,
28
+ setupImmediateLocking,
29
+ expect,
30
+ }) => {
31
+ const unit = createUnit("A")
32
+ const state = createDeployedUnitState(unit)
33
+
34
+ await createContext({ instances: [unit], states: [state] })
35
+ setupImmediateLocking()
36
+ setupPersistenceMocks({ instances: [unit] })
37
+
38
+ unitOutputService.parseUnitOutputs.mockResolvedValue({
39
+ outputHash: null,
40
+ statusFields: null,
41
+ terminals: null,
42
+ pages: null,
43
+ triggers: null,
44
+ secrets: null,
45
+ workers: null,
46
+ exportedArtifactIds: null,
47
+ hasResourceHooks: false,
48
+ entitySnapshotError: "invalid entity schema",
49
+ entitySnapshotPayload: null,
50
+ })
51
+
52
+ runner.setAutoCompletion(false)
53
+ runner.emitCompletion(unit.id, {
54
+ operationType: "update",
55
+ rawOutputs: {
56
+ value: { value: { some: "output" } },
57
+ },
58
+ })
59
+
60
+ const operation = createOperation({
61
+ type: "update",
62
+ requestedInstanceIds: [unit.id],
63
+ phases: [
64
+ {
65
+ type: "update",
66
+ instances: [{ id: unit.id, message: "requested", parentId: undefined }],
67
+ },
68
+ ],
69
+ })
70
+
71
+ const runtimeOperation = new RuntimeOperation(
72
+ project,
73
+ operation,
74
+ runnerBackend,
75
+ libraryBackend,
76
+ artifactService,
77
+ instanceLockService,
78
+ operationService,
79
+ secretService,
80
+ instanceStateService,
81
+ projectModelService,
82
+ unitExtraService,
83
+ entitySnapshotService,
84
+ unitOutputService,
85
+ logger,
86
+ )
87
+
88
+ await runtimeOperation.operateSafe()
89
+
90
+ expect(operationService.appendLog).toHaveBeenCalledWith(
91
+ project.id,
92
+ operation.id,
93
+ state.id,
94
+ expect.stringContaining("Failed to parse unit outputs"),
95
+ )
96
+
97
+ expect(instanceStateService.updateOperationState).toHaveBeenCalledWith(
98
+ project.id,
99
+ state.id,
100
+ operation.id,
101
+ expect.any(Object),
102
+ )
103
+
104
+ const wasMarkedFailed = instanceStateService.updateOperationState.mock.calls.some(call => {
105
+ const patch = call[3] as unknown
106
+ if (!patch || typeof patch !== "object") {
107
+ return false
108
+ }
109
+
110
+ const maybePatch = patch as {
111
+ operationState?: { status?: unknown }
112
+ instanceState?: { status?: unknown }
113
+ }
114
+
115
+ return (
116
+ maybePatch.operationState?.status === "failed" &&
117
+ maybePatch.instanceState?.status === "failed"
118
+ )
119
+ })
120
+
121
+ expect(wasMarkedFailed).toBe(true)
122
+ },
123
+ )
124
+ })
@@ -18,6 +18,8 @@ describe("Operation - Preview", () => {
18
18
  instanceStateService,
19
19
  projectModelService,
20
20
  unitExtraService,
21
+ entitySnapshotService,
22
+ unitOutputService,
21
23
  createUnit,
22
24
  createDeployedUnitState,
23
25
  createOperation,
@@ -59,6 +61,8 @@ describe("Operation - Preview", () => {
59
61
  instanceStateService,
60
62
  projectModelService,
61
63
  unitExtraService,
64
+ entitySnapshotService,
65
+ unitOutputService,
62
66
  logger,
63
67
  )
64
68