@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
@@ -0,0 +1,162 @@
1
+ import type { ComponentModel, EntityModel, InstanceInput, InstanceModel } from "@highstate/contract"
2
+
3
+ export type InstanceTypeContext = {
4
+ instance: InstanceModel
5
+ component: ComponentModel
6
+ entities: Readonly<Record<string, EntityModel>>
7
+ }
8
+
9
+ export type ResolveEffectiveOutputTypeOptions = {
10
+ input: InstanceInput
11
+ fallbackType: string
12
+ getInstanceContext: (instanceId: string) => InstanceTypeContext | undefined
13
+ visited?: Set<string>
14
+ }
15
+
16
+ /**
17
+ * Resolves the effective entity type of a connected output.
18
+ *
19
+ * The function starts from the connected output declaration and then applies
20
+ * two additional transformations used by the platform:
21
+ *
22
+ * 1. `fromInput` forwarding.
23
+ * If the output references `fromInput`, it follows that input edge and reuses
24
+ * the upstream effective type when the forwarding source is deterministic.
25
+ *
26
+ * 2. Input `path` traversal.
27
+ * If the edge provides a `path`, the type is narrowed by following entity
28
+ * inclusions segment-by-segment.
29
+ *
30
+ * The function is intentionally conservative.
31
+ * If a forwarding source is ambiguous (missing, multiple, hub/injection-backed,
32
+ * unknown node/entity, or cyclic), it falls back to the declared type.
33
+ *
34
+ * @param options.input The connected instance output reference being consumed.
35
+ * @param options.fallbackType The type to use when resolution cannot continue safely.
36
+ * @param options.getInstanceContext A lookup that returns instance/component/entities for an instance ID.
37
+ * @param options.visited A recursion guard set used to prevent forwarding cycles.
38
+ * @returns The effective resolved output entity type.
39
+ */
40
+ export function resolveEffectiveOutputType(options: ResolveEffectiveOutputTypeOptions): string {
41
+ const visited = options.visited ?? new Set<string>()
42
+
43
+ const producer = options.getInstanceContext(options.input.instanceId)
44
+ if (!producer) {
45
+ return resolveTypeByPathOrFallbackInclusion({
46
+ rootType: options.fallbackType,
47
+ path: options.input.path,
48
+ entities: undefined,
49
+ fallbackType: options.fallbackType,
50
+ })
51
+ }
52
+
53
+ const outputSpec = producer.component.outputs[options.input.output]
54
+ if (!outputSpec) {
55
+ return resolveTypeByPathOrFallbackInclusion({
56
+ rootType: options.fallbackType,
57
+ path: options.input.path,
58
+ entities: producer.entities,
59
+ fallbackType: options.fallbackType,
60
+ })
61
+ }
62
+
63
+ let effectiveType: string = outputSpec.type
64
+
65
+ if (outputSpec.fromInput) {
66
+ const visitedKey = `${producer.instance.id}:${options.input.output}`
67
+ if (!visited.has(visitedKey)) {
68
+ visited.add(visitedKey)
69
+
70
+ const forwardedInputName = outputSpec.fromInput
71
+ const forwardedFallback = producer.component.inputs[forwardedInputName]?.type ?? effectiveType
72
+ const hasHubInputs = (producer.instance.hubInputs?.[forwardedInputName]?.length ?? 0) > 0
73
+ const hasInjectionInputs = (producer.instance.injectionInputs?.length ?? 0) > 0
74
+ const directInputs = producer.instance.inputs?.[forwardedInputName] ?? []
75
+
76
+ if (!hasHubInputs && !hasInjectionInputs && directInputs.length === 1) {
77
+ const forwardedInput = directInputs[0]
78
+ if (forwardedInput) {
79
+ effectiveType = resolveEffectiveOutputType({
80
+ input: forwardedInput,
81
+ fallbackType: forwardedFallback,
82
+ getInstanceContext: options.getInstanceContext,
83
+ visited,
84
+ })
85
+ } else {
86
+ effectiveType = forwardedFallback
87
+ }
88
+ } else {
89
+ effectiveType = forwardedFallback
90
+ }
91
+ }
92
+ }
93
+
94
+ return resolveTypeByPathOrFallbackInclusion({
95
+ rootType: effectiveType,
96
+ path: options.input.path,
97
+ entities: producer.entities,
98
+ fallbackType: options.fallbackType,
99
+ })
100
+ }
101
+
102
+ function resolveTypeByPathOrFallbackInclusion(options: {
103
+ rootType: string
104
+ path: string | undefined
105
+ entities: Readonly<Record<string, EntityModel>> | undefined
106
+ fallbackType: string
107
+ }): string {
108
+ const { rootType, path, entities, fallbackType } = options
109
+
110
+ if (!path || !entities) {
111
+ return resolveTypeByImplicitInclusion(rootType, fallbackType, entities)
112
+ }
113
+
114
+ let currentType = rootType
115
+ const segments = path.split(".")
116
+
117
+ for (const segment of segments) {
118
+ const entity = entities[currentType]
119
+ if (!entity) {
120
+ return rootType
121
+ }
122
+
123
+ const inclusion = entity.inclusions?.find(inc => inc.field === segment)
124
+ if (!inclusion) {
125
+ return rootType
126
+ }
127
+
128
+ currentType = inclusion.type
129
+ }
130
+
131
+ return currentType
132
+ }
133
+
134
+ function resolveTypeByImplicitInclusion(
135
+ rootType: string,
136
+ fallbackType: string,
137
+ entities: Readonly<Record<string, EntityModel>> | undefined,
138
+ ): string {
139
+ if (!entities) {
140
+ return rootType
141
+ }
142
+
143
+ if (rootType === fallbackType) {
144
+ return rootType
145
+ }
146
+
147
+ const rootEntity = entities[rootType]
148
+ if (!rootEntity) {
149
+ return rootType
150
+ }
151
+
152
+ if (rootEntity.extensions?.includes(fallbackType)) {
153
+ return rootType
154
+ }
155
+
156
+ const inclusion = rootEntity.inclusions?.find(inc => inc.type === fallbackType)
157
+ if (inclusion) {
158
+ return fallbackType
159
+ }
160
+
161
+ return rootType
162
+ }
@@ -1,3 +1,4 @@
1
+ export * from "./effective-output-type"
1
2
  export * from "./graph-resolver"
2
3
  export * from "./input"
3
4
  export * from "./input-hash"
@@ -1,12 +1,15 @@
1
1
  import {
2
2
  type ComponentModel,
3
+ type EntityModel,
3
4
  type HubInput,
4
5
  type HubModel,
5
6
  type InstanceInput,
6
7
  type InstanceModel,
8
+ inputKey,
7
9
  isUnitModel,
8
10
  } from "@highstate/contract"
9
11
  import { fromEntries, mapValues } from "remeda"
12
+ import { resolveEffectiveOutputType } from "./effective-output-type"
10
13
  import { GraphResolver } from "./graph-resolver"
11
14
 
12
15
  export type InputResolverNode =
@@ -14,10 +17,12 @@ export type InputResolverNode =
14
17
  kind: "instance"
15
18
  instance: InstanceModel
16
19
  component: ComponentModel
20
+ entities: Readonly<Record<string, EntityModel>>
17
21
  }
18
22
  | {
19
23
  kind: "hub"
20
24
  hub: HubModel
25
+ entities: Readonly<Record<string, EntityModel>>
21
26
  }
22
27
 
23
28
  export type ResolvedInstanceInput = {
@@ -30,6 +35,7 @@ export type InputResolverOutput =
30
35
  kind: "instance"
31
36
  instance: InstanceModel
32
37
  component: ComponentModel
38
+ entities: Readonly<Record<string, EntityModel>>
33
39
  resolvedInputs: Record<string, ResolvedInstanceInput[]>
34
40
  resolvedOutputs: Record<string, InstanceInput[]> | undefined
35
41
  resolvedInjectionInputs: ResolvedInstanceInput[]
@@ -37,6 +43,7 @@ export type InputResolverOutput =
37
43
  }
38
44
  | {
39
45
  kind: "hub"
46
+ hub: HubModel
40
47
  resolvedInputs: ResolvedInstanceInput[]
41
48
  }
42
49
 
@@ -78,6 +85,25 @@ export class InputResolver extends GraphResolver<InputResolverNode, InputResolve
78
85
  return dependencies
79
86
  }
80
87
 
88
+ private resolveOutputTypeForInput(input: InstanceInput, fallbackType: string): string {
89
+ return resolveEffectiveOutputType({
90
+ input,
91
+ fallbackType,
92
+ getInstanceContext: instanceId => {
93
+ const output = this.outputs.get(`instance:${instanceId}`)
94
+ if (!output || output.kind !== "instance") {
95
+ return undefined
96
+ }
97
+
98
+ return {
99
+ instance: output.instance,
100
+ component: output.component,
101
+ entities: output.entities,
102
+ }
103
+ },
104
+ })
105
+ }
106
+
81
107
  processNode(node: InputResolverNode): InputResolverOutput {
82
108
  const getHubOutput = (input: HubInput) => {
83
109
  const output = this.outputs.get(`hub:${input.hubId}`)
@@ -118,7 +144,7 @@ export class InputResolver extends GraphResolver<InputResolverNode, InputResolve
118
144
  const hubResult: Map<string, ResolvedInstanceInput> = new Map()
119
145
 
120
146
  const addHubResult = (input: ResolvedInstanceInput) => {
121
- hubResult.set(`${input.input.instanceId}:${input.input.output}`, input)
147
+ hubResult.set(inputKey(input.input), input)
122
148
  }
123
149
 
124
150
  for (const input of node.hub.inputs ?? []) {
@@ -130,7 +156,10 @@ export class InputResolver extends GraphResolver<InputResolverNode, InputResolve
130
156
  continue
131
157
  }
132
158
 
133
- addHubResult({ input, type: componentInput.type })
159
+ addHubResult({
160
+ input,
161
+ type: this.resolveOutputTypeForInput(input, componentInput.type),
162
+ })
134
163
  }
135
164
 
136
165
  for (const injectionInput of node.hub.injectionInputs ?? []) {
@@ -143,6 +172,7 @@ export class InputResolver extends GraphResolver<InputResolverNode, InputResolve
143
172
 
144
173
  return {
145
174
  kind: "hub",
175
+ hub: node.hub,
146
176
  resolvedInputs: Array.from(hubResult.values()),
147
177
  }
148
178
  }
@@ -160,6 +190,7 @@ export class InputResolver extends GraphResolver<InputResolverNode, InputResolve
160
190
  kind: "instance",
161
191
  instance: node.instance,
162
192
  component: node.component,
193
+ entities: node.entities,
163
194
  resolvedInputs: mapValues(node.instance.resolvedInputs, (inputs, inputName) => {
164
195
  const componentInput = node.component.inputs[inputName]
165
196
  if (!componentInput) {
@@ -189,7 +220,7 @@ export class InputResolver extends GraphResolver<InputResolverNode, InputResolve
189
220
  resolvedInputsMap.set(inputName, inputs)
190
221
  }
191
222
 
192
- inputs.set(`${input.input.instanceId}:${input.input.output}`, input)
223
+ inputs.set(inputKey(input.input), input)
193
224
  }
194
225
 
195
226
  const addInstanceInput = (inputName: string, input: InstanceInput) => {
@@ -211,17 +242,37 @@ export class InputResolver extends GraphResolver<InputResolverNode, InputResolve
211
242
  }
212
243
 
213
244
  if (isUnitModel(component)) {
214
- addInstanceResult(inputName, { input, type: componentInput.type })
245
+ addInstanceResult(inputName, {
246
+ input,
247
+ type: this.resolveOutputTypeForInput(input, componentInput.type),
248
+ })
215
249
  return
216
250
  }
217
251
 
218
252
  if (resolvedOutputs) {
219
253
  for (const output of resolvedOutputs) {
220
- addInstanceResult(inputName, { input: output, type: componentInput.type })
254
+ addInstanceResult(inputName, {
255
+ input: {
256
+ ...output,
257
+ // keep explicit path from the edge while preserving already-resolved output path
258
+ path: input.path ?? output.path,
259
+ },
260
+ type: this.resolveOutputTypeForInput(
261
+ {
262
+ instanceId: input.instanceId,
263
+ output: input.output,
264
+ path: input.path ?? output.path,
265
+ },
266
+ componentInput.type,
267
+ ),
268
+ })
221
269
  }
222
270
  } else {
223
271
  // if the instance is not evaluated, we a forced to use the input as is
224
- addInstanceResult(inputName, { input, type: componentInput.type })
272
+ addInstanceResult(inputName, {
273
+ input,
274
+ type: this.resolveOutputTypeForInput(input, componentInput.type),
275
+ })
225
276
  }
226
277
  }
227
278
 
@@ -237,7 +288,7 @@ export class InputResolver extends GraphResolver<InputResolverNode, InputResolve
237
288
  for (const injectionInput of node.instance.injectionInputs ?? []) {
238
289
  const { resolvedInputs } = getHubOutput(injectionInput)
239
290
  for (const input of resolvedInputs) {
240
- injectionInputs.set(`${input.input.instanceId}:${input.input.output}`, input)
291
+ injectionInputs.set(inputKey(input.input), input)
241
292
  }
242
293
  }
243
294
 
@@ -248,7 +299,7 @@ export class InputResolver extends GraphResolver<InputResolverNode, InputResolve
248
299
  for (const hubInput of hubInputs) {
249
300
  const { resolvedInputs } = getHubOutput(hubInput)
250
301
  for (const input of resolvedInputs) {
251
- allInputs.set(`${input.input.instanceId}:${input.input.output}`, input)
302
+ allInputs.set(inputKey(input.input), input)
252
303
  }
253
304
  }
254
305
 
@@ -256,7 +307,7 @@ export class InputResolver extends GraphResolver<InputResolverNode, InputResolve
256
307
  if (input.type === componentInput.type) {
257
308
  addInstanceInput(inputName, input.input)
258
309
 
259
- const key = `${input.input.instanceId}:${input.input.output}`
310
+ const key = inputKey(input.input)
260
311
  if (injectionInputs.has(key)) {
261
312
  matchedInjectionInputs.set(key, input)
262
313
  }
@@ -275,6 +326,7 @@ export class InputResolver extends GraphResolver<InputResolverNode, InputResolve
275
326
  kind: "instance",
276
327
  instance: node.instance,
277
328
  component: node.component,
329
+ entities: node.entities,
278
330
  resolvedInputs,
279
331
  resolvedOutputs: node.instance.resolvedOutputs,
280
332
  resolvedInjectionInputs: Array.from(injectionInputs.values()),
@@ -1,3 +1,4 @@
1
1
  export * from "./async-batcher"
2
2
  export * from "./hash"
3
3
  export * from "./promise-tracker"
4
+ export * from "./stable-json"
@@ -0,0 +1,41 @@
1
+ export function stableJsonStringify(value: unknown): string {
2
+ if (value === null) {
3
+ return "null"
4
+ }
5
+
6
+ if (Array.isArray(value)) {
7
+ return `[${value.map(stableJsonStringify).join(",")}]`
8
+ }
9
+
10
+ switch (typeof value) {
11
+ case "string":
12
+ return JSON.stringify(value)
13
+ case "number": {
14
+ if (!Number.isFinite(value)) {
15
+ throw new Error("Snapshot content contains a non-finite number")
16
+ }
17
+
18
+ return JSON.stringify(value)
19
+ }
20
+ case "boolean":
21
+ return value ? "true" : "false"
22
+ case "object": {
23
+ const record = value as Record<string, unknown>
24
+ const keys = Object.keys(record).sort()
25
+ const parts: string[] = []
26
+
27
+ for (const key of keys) {
28
+ const item = record[key]
29
+ if (item === undefined) {
30
+ throw new Error("Snapshot content contains undefined")
31
+ }
32
+
33
+ parts.push(`${JSON.stringify(key)}:${stableJsonStringify(item)}`)
34
+ }
35
+
36
+ return `{${parts.join(",")}}`
37
+ }
38
+ default:
39
+ throw new Error(`Snapshot content contains non-JSON value of type "${typeof value}"`)
40
+ }
41
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Logger } from "pino"
2
2
  import type { ProjectUnlockService } from "../business"
3
+ import type { ObjectRefIndexService } from "../business/object-ref-index"
3
4
  import type { DatabaseManager, Terminal, TerminalSession } from "../database"
4
5
  import type { PubSubManager } from "../pubsub"
5
6
  import type { TerminalSessionOutput } from "../shared/models/project/terminal"
@@ -35,6 +36,7 @@ export class TerminalManager {
35
36
  private readonly database: DatabaseManager,
36
37
  private readonly pubsubManager: PubSubManager,
37
38
  private readonly projectUnlockService: ProjectUnlockService,
39
+ private readonly objectRefIndexService: ObjectRefIndexService,
38
40
  private readonly logger: Logger,
39
41
  ) {
40
42
  this.projectUnlockService.registerUnlockTask(
@@ -113,6 +115,8 @@ export class TerminalManager {
113
115
  },
114
116
  })
115
117
 
118
+ await this.objectRefIndexService.track(projectId, [session.id])
119
+
116
120
  const output = toTerminalSessionOutput(terminal, session)
117
121
 
118
122
  this.logger.info({ msg: "terminal session created", id: output.id })
@@ -319,6 +323,7 @@ export class TerminalManager {
319
323
  database: DatabaseManager,
320
324
  pubsubManager: PubSubManager,
321
325
  projectUnlockService: ProjectUnlockService,
326
+ objectRefIndexService: ObjectRefIndexService,
322
327
  logger: Logger,
323
328
  ): TerminalManager {
324
329
  return new TerminalManager(
@@ -326,6 +331,7 @@ export class TerminalManager {
326
331
  database,
327
332
  pubsubManager,
328
333
  projectUnlockService,
334
+ objectRefIndexService,
329
335
  logger.child({ service: "TerminalManager" }),
330
336
  )
331
337
  }
@@ -2,6 +2,7 @@ import type { Logger } from "pino"
2
2
  import type { ApiKeyService, ProjectUnlockService } from "../business"
3
3
  import type { DatabaseManager, Worker, WorkerVersion } from "../database"
4
4
  import type { PubSubManager } from "../pubsub"
5
+ import type { WorkerVersionStatus } from "../shared"
5
6
  import type { WorkerBackend } from "./abstractions"
6
7
  import { PassThrough } from "node:stream"
7
8
  import { z } from "zod"
@@ -9,6 +10,9 @@ import { type AsyncBatcher, createAsyncBatcher } from "../shared"
9
10
 
10
11
  export const workerManagerConfig = z.object({
11
12
  HIGHSTATE_WORKER_API_PATH: z.string().default("/var/run/highstate.sock"),
13
+ HIGHSTATE_WORKER_START_MAX_ATTEMPTS: z.coerce.number().int().positive().default(5),
14
+ HIGHSTATE_WORKER_RESTART_BACKOFF_BASE_MS: z.coerce.number().int().nonnegative().default(1000),
15
+ HIGHSTATE_WORKER_RESTART_BACKOFF_MAX_MS: z.coerce.number().int().nonnegative().default(30000),
12
16
  })
13
17
 
14
18
  type RunningWorkerInfo = {
@@ -22,7 +26,40 @@ type RunningWorkerInfo = {
22
26
  lineBuffer?: string
23
27
  }
24
28
 
25
- export const maxWorkerStartAttempts = 5
29
+ function getWorkerRestartBackoffMs(failedAttempts: number, baseMs: number, maxMs: number): number {
30
+ if (failedAttempts <= 0) {
31
+ return 0
32
+ }
33
+
34
+ const exponent = Math.max(0, failedAttempts - 1)
35
+ const uncapped = baseMs * 2 ** exponent
36
+
37
+ return Math.min(maxMs, uncapped)
38
+ }
39
+
40
+ async function waitForAbortableDelay(delayMs: number, signal: AbortSignal): Promise<boolean> {
41
+ if (delayMs <= 0) {
42
+ return true
43
+ }
44
+
45
+ if (signal.aborted) {
46
+ return false
47
+ }
48
+
49
+ return await new Promise(resolve => {
50
+ const onAbort = () => {
51
+ clearTimeout(timer)
52
+ resolve(false)
53
+ }
54
+
55
+ const timer = setTimeout(() => {
56
+ signal.removeEventListener("abort", onAbort)
57
+ resolve(true)
58
+ }, delayMs)
59
+
60
+ signal.addEventListener("abort", onAbort, { once: true })
61
+ })
62
+ }
26
63
 
27
64
  export class WorkerManager {
28
65
  constructor(
@@ -50,6 +87,17 @@ export class WorkerManager {
50
87
 
51
88
  private readonly runningWorkers = new Map<string, RunningWorkerInfo>()
52
89
 
90
+ private async publishWorkerVersionStatus(
91
+ projectId: string,
92
+ workerVersionId: string,
93
+ status: WorkerVersionStatus,
94
+ ): Promise<void> {
95
+ await this.pubsubManager.publish(["worker-version-status", projectId], {
96
+ workerVersionId,
97
+ status,
98
+ })
99
+ }
100
+
53
101
  private async startWorkerVersion(
54
102
  projectId: string,
55
103
  workerVersion: WorkerVersion & { worker: Worker },
@@ -70,6 +118,7 @@ export class WorkerManager {
70
118
  // calculate attempt number
71
119
  const previousFailedAttempts = existingInfo?.failedAttempts ?? 0
72
120
  const failedAttempts = restart ? previousFailedAttempts + 1 : 0
121
+ const maxWorkerStartAttempts = this.config.HIGHSTATE_WORKER_START_MAX_ATTEMPTS
73
122
 
74
123
  // check if max attempts reached
75
124
  if (failedAttempts >= maxWorkerStartAttempts) {
@@ -95,6 +144,8 @@ export class WorkerManager {
95
144
  },
96
145
  })
97
146
 
147
+ await this.publishWorkerVersionStatus(projectId, workerVersion.id, "error")
148
+
98
149
  // clean up from running workers map
99
150
  if (existingInfo) {
100
151
  existingInfo.logBatcher && void existingInfo.logBatcher.flush()
@@ -104,6 +155,45 @@ export class WorkerManager {
104
155
  return
105
156
  }
106
157
 
158
+ if (restart && existingInfo) {
159
+ const restartBackoffMs = getWorkerRestartBackoffMs(
160
+ failedAttempts,
161
+ this.config.HIGHSTATE_WORKER_RESTART_BACKOFF_BASE_MS,
162
+ this.config.HIGHSTATE_WORKER_RESTART_BACKOFF_MAX_MS,
163
+ )
164
+
165
+ if (restartBackoffMs > 0) {
166
+ this.logger.debug(
167
+ { projectId, workerVersionId: workerVersion.id, restartBackoffMs, failedAttempts },
168
+ `delaying worker version "%s" restart for %s ms after attempt %s`,
169
+ workerVersion.id,
170
+ restartBackoffMs,
171
+ failedAttempts,
172
+ )
173
+
174
+ await this.writeWorkerLog(
175
+ projectId,
176
+ workerVersion.id,
177
+ `worker restart scheduled in ${restartBackoffMs}ms`,
178
+ )
179
+
180
+ const canContinue = await waitForAbortableDelay(
181
+ restartBackoffMs,
182
+ existingInfo.abortController.signal,
183
+ )
184
+
185
+ if (!canContinue) {
186
+ return
187
+ }
188
+
189
+ const currentInfo = this.runningWorkers.get(workerVersion.id)
190
+
191
+ if (currentInfo !== existingInfo || currentInfo.abortController.signal.aborted) {
192
+ return
193
+ }
194
+ }
195
+ }
196
+
107
197
  // regenerate API token
108
198
  const apiKey = await this.apiKeyService.regenerateToken(projectId, workerVersion.apiKeyId)
109
199
  const stdout = new PassThrough()
@@ -184,6 +274,8 @@ export class WorkerManager {
184
274
  },
185
275
  })
186
276
 
277
+ await this.publishWorkerVersionStatus(projectId, workerVersion.id, "starting")
278
+
187
279
  void this.workerBackend
188
280
  .run({
189
281
  projectId,
@@ -272,6 +364,8 @@ export class WorkerManager {
272
364
  },
273
365
  })
274
366
 
367
+ await this.publishWorkerVersionStatus(projectId, workerVersionId, "running")
368
+
275
369
  this.logger.debug(
276
370
  { projectId, workerVersionId },
277
371
  `worker version "%s" is now running in project "%s"`,
@@ -311,6 +405,8 @@ export class WorkerManager {
311
405
  },
312
406
  })
313
407
 
408
+ await this.publishWorkerVersionStatus(info.projectId, workerVersionId, "stopped")
409
+
314
410
  this.logger.debug(
315
411
  { projectId: info.projectId, workerVersionId },
316
412
  `stopped worker version "%s"`,