@highstate/backend 0.9.14 → 0.9.16

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 (144) hide show
  1. package/dist/chunk-RCB4AFGD.js +159 -0
  2. package/dist/chunk-RCB4AFGD.js.map +1 -0
  3. package/dist/chunk-WHALQHEZ.js +2017 -0
  4. package/dist/chunk-WHALQHEZ.js.map +1 -0
  5. package/dist/highstate.manifest.json +3 -3
  6. package/dist/index.js +6146 -2174
  7. package/dist/index.js.map +1 -1
  8. package/dist/library/worker/main.js +51 -159
  9. package/dist/library/worker/main.js.map +1 -1
  10. package/dist/shared/index.js +159 -43
  11. package/package.json +25 -7
  12. package/src/artifact/abstractions.ts +46 -0
  13. package/src/artifact/encryption.ts +69 -0
  14. package/src/artifact/factory.ts +36 -0
  15. package/src/artifact/index.ts +3 -0
  16. package/src/artifact/local.ts +142 -0
  17. package/src/business/api-key.ts +65 -0
  18. package/src/business/artifact.ts +288 -0
  19. package/src/business/backend-unlock.ts +10 -0
  20. package/src/business/index.ts +9 -0
  21. package/src/business/instance-lock.ts +124 -0
  22. package/src/business/instance-state.ts +292 -0
  23. package/src/business/operation.ts +251 -0
  24. package/src/business/project-unlock.ts +242 -0
  25. package/src/business/secret.ts +187 -0
  26. package/src/business/worker.ts +161 -0
  27. package/src/common/index.ts +2 -1
  28. package/src/common/performance.ts +44 -0
  29. package/src/common/tree.ts +33 -0
  30. package/src/common/utils.ts +40 -1
  31. package/src/config.ts +14 -10
  32. package/src/hotstate/abstractions.ts +48 -0
  33. package/src/hotstate/factory.ts +17 -0
  34. package/src/{secret → hotstate}/index.ts +1 -0
  35. package/src/hotstate/manager.ts +192 -0
  36. package/src/hotstate/memory.ts +100 -0
  37. package/src/hotstate/validation.ts +101 -0
  38. package/src/index.ts +2 -1
  39. package/src/library/abstractions.ts +10 -23
  40. package/src/library/factory.ts +2 -2
  41. package/src/library/local.ts +89 -102
  42. package/src/library/worker/evaluator.ts +14 -47
  43. package/src/library/worker/loader.lite.ts +41 -0
  44. package/src/library/worker/main.ts +14 -65
  45. package/src/library/worker/protocol.ts +8 -24
  46. package/src/lock/abstractions.ts +6 -0
  47. package/src/lock/factory.ts +15 -0
  48. package/src/{workspace → lock}/index.ts +1 -0
  49. package/src/lock/manager.ts +82 -0
  50. package/src/lock/memory.ts +19 -0
  51. package/src/orchestrator/manager.ts +131 -82
  52. package/src/orchestrator/operation-workset.ts +188 -77
  53. package/src/orchestrator/operation.ts +975 -284
  54. package/src/project/abstractions.ts +20 -7
  55. package/src/project/factory.ts +1 -1
  56. package/src/project/index.ts +0 -1
  57. package/src/project/local.ts +73 -17
  58. package/src/project/manager.ts +272 -131
  59. package/src/pubsub/abstractions.ts +13 -0
  60. package/src/pubsub/factory.ts +19 -0
  61. package/src/pubsub/index.ts +3 -0
  62. package/src/pubsub/local.ts +36 -0
  63. package/src/pubsub/manager.ts +100 -0
  64. package/src/pubsub/validation.ts +33 -0
  65. package/src/runner/abstractions.ts +135 -68
  66. package/src/runner/artifact-env.ts +160 -0
  67. package/src/runner/factory.ts +20 -5
  68. package/src/runner/force-abort.ts +117 -0
  69. package/src/runner/local.ts +281 -372
  70. package/src/{common → runner}/pulumi.ts +86 -37
  71. package/src/services.ts +193 -35
  72. package/src/shared/index.ts +3 -11
  73. package/src/shared/models/backend/index.ts +3 -0
  74. package/src/shared/models/backend/project.ts +63 -0
  75. package/src/shared/models/backend/unlock-method.ts +20 -0
  76. package/src/shared/models/base.ts +151 -0
  77. package/src/shared/models/errors.ts +5 -0
  78. package/src/shared/models/index.ts +4 -0
  79. package/src/shared/models/project/api-key.ts +62 -0
  80. package/src/shared/models/project/artifact.ts +113 -0
  81. package/src/shared/models/project/component.ts +45 -0
  82. package/src/shared/models/project/index.ts +14 -0
  83. package/src/shared/{project.ts → models/project/instance.ts} +12 -0
  84. package/src/shared/models/project/lock.ts +91 -0
  85. package/src/shared/{operation.ts → models/project/operation.ts} +43 -8
  86. package/src/shared/models/project/page.ts +57 -0
  87. package/src/shared/models/project/secret.ts +112 -0
  88. package/src/shared/models/project/service-account.ts +22 -0
  89. package/src/shared/models/project/state.ts +432 -0
  90. package/src/shared/models/project/terminal.ts +99 -0
  91. package/src/shared/models/project/trigger.ts +56 -0
  92. package/src/shared/models/project/unlock-method.ts +31 -0
  93. package/src/shared/models/project/worker.ts +105 -0
  94. package/src/shared/resolvers/graph-resolver.ts +74 -13
  95. package/src/shared/resolvers/index.ts +5 -0
  96. package/src/shared/resolvers/input-hash.ts +53 -15
  97. package/src/shared/resolvers/input.ts +1 -9
  98. package/src/shared/resolvers/registry.ts +7 -2
  99. package/src/shared/resolvers/state.ts +12 -0
  100. package/src/shared/resolvers/validation.ts +61 -20
  101. package/src/shared/{async-batcher.ts → utils/async-batcher.ts} +13 -1
  102. package/src/shared/utils/hash.ts +6 -0
  103. package/src/shared/utils/index.ts +3 -0
  104. package/src/shared/utils/promise-tracker.ts +23 -0
  105. package/src/state/abstractions.ts +330 -101
  106. package/src/state/encryption.ts +59 -0
  107. package/src/state/factory.ts +3 -5
  108. package/src/state/index.ts +3 -0
  109. package/src/state/keyring.ts +22 -0
  110. package/src/state/local/backend.ts +299 -0
  111. package/src/state/local/collection.ts +342 -0
  112. package/src/state/local/index.ts +2 -0
  113. package/src/state/manager.ts +804 -18
  114. package/src/state/repository/index.ts +2 -0
  115. package/src/state/repository/repository.index.ts +193 -0
  116. package/src/state/repository/repository.ts +458 -0
  117. package/src/terminal/{shared.ts → abstractions.ts} +3 -3
  118. package/src/terminal/docker.ts +18 -14
  119. package/src/terminal/factory.ts +3 -3
  120. package/src/terminal/index.ts +1 -1
  121. package/src/terminal/manager.ts +134 -80
  122. package/src/terminal/run.sh.ts +22 -10
  123. package/src/worker/abstractions.ts +42 -0
  124. package/src/worker/docker.ts +83 -0
  125. package/src/worker/factory.ts +20 -0
  126. package/src/worker/index.ts +3 -0
  127. package/src/worker/manager.ts +139 -0
  128. package/dist/chunk-C2TJAQAD.js +0 -937
  129. package/dist/chunk-C2TJAQAD.js.map +0 -1
  130. package/dist/chunk-WXDYCRTT.js +0 -234
  131. package/dist/chunk-WXDYCRTT.js.map +0 -1
  132. package/src/library/worker/loader.ts +0 -114
  133. package/src/preferences/shared.ts +0 -1
  134. package/src/project/lock.ts +0 -39
  135. package/src/secret/abstractions.ts +0 -59
  136. package/src/secret/factory.ts +0 -22
  137. package/src/secret/local.ts +0 -152
  138. package/src/shared/state.ts +0 -270
  139. package/src/shared/terminal.ts +0 -13
  140. package/src/state/local.ts +0 -612
  141. package/src/workspace/abstractions.ts +0 -41
  142. package/src/workspace/factory.ts +0 -14
  143. package/src/workspace/local.ts +0 -54
  144. /package/src/shared/{library.ts → models/backend/library.ts} +0 -0
@@ -1,75 +1,43 @@
1
- import type { StateBackend, StateManager } from "../state"
1
+ import type { StateManager } from "../state"
2
2
  import type { ProjectBackend } from "./abstractions"
3
3
  import type { Logger } from "pino"
4
- import type { LibraryBackend } from "../library"
5
- import type { ProjectLockManager } from "./lock"
6
- import { EventEmitter, on } from "node:events"
4
+ import type { InstanceEvaluationResult, LibraryBackend } from "../library"
5
+ import type { InstanceLockService } from "../business"
6
+ import type { PubSubManager } from "../pubsub"
7
+ import { randomUUID } from "node:crypto"
7
8
  import { isUnitModel, type InstanceModel } from "@highstate/contract"
8
9
  import {
9
10
  type InputResolverNode,
10
11
  type InputHashNode,
11
12
  type InstanceModelPatch,
12
- type LibraryUpdate,
13
- createInstanceState,
14
13
  type CompositeInstance,
15
14
  type ResolvedInstanceInput,
16
15
  type HubModel,
16
+ type LibraryUpdate,
17
17
  InputResolver,
18
18
  InputHashResolver,
19
19
  } from "../shared"
20
-
21
- type CompositeInstanceEvent =
22
- | {
23
- type: "evaluation-started"
24
- instanceId: string
25
- }
26
- | {
27
- type: "updated"
28
- instance: CompositeInstance
29
- }
30
- | {
31
- type: "deleted"
32
- instanceId: string
33
- }
34
- | {
35
- type: "failed"
36
- instanceId: string
37
- }
38
-
39
- type CompositeInstanceEvents = {
40
- [K in string]: [CompositeInstanceEvent]
41
- }
20
+ import { renderTree, type TreeNode } from "../common"
42
21
 
43
22
  export type FullProjectModel = {
23
+ libraryId: string
44
24
  instances: InstanceModel[]
45
25
  hubs: HubModel[]
46
26
  compositeInstances: CompositeInstance[]
47
27
  }
48
28
 
49
29
  export class ProjectManager {
30
+ private readonly watchedLibraries = new Set<string>()
31
+ private readonly libraryWatchers = new Map<string, AbortController>()
32
+
50
33
  private constructor(
51
34
  private readonly projectBackend: ProjectBackend,
52
- private readonly stateBackend: StateBackend,
53
35
  private readonly libraryBackend: LibraryBackend,
54
- private readonly projectLockManager: ProjectLockManager,
55
36
  private readonly stateManager: StateManager,
37
+ private readonly instanceLockService: InstanceLockService,
38
+ private readonly pubsubManager: PubSubManager,
56
39
  private readonly logger: Logger,
57
- ) {
58
- void this.watchLibraryChanges()
59
- }
60
-
61
- private readonly compositeInstanceEE = new EventEmitter<CompositeInstanceEvents>()
62
-
63
- async *watchCompositeInstances(
64
- projectId: string,
65
- signal?: AbortSignal,
66
- ): AsyncIterable<CompositeInstanceEvent> {
67
- for await (const [children] of on(this.compositeInstanceEE, projectId, {
68
- signal,
69
- })) {
70
- yield children
71
- }
72
- }
40
+ ) {}
73
41
 
74
42
  /**
75
43
  * Loads the full info of a project, including instances, hubs, and composite instances.
@@ -79,13 +47,22 @@ export class ProjectManager {
79
47
  * @param projectId The ID of the project to load.
80
48
  */
81
49
  async getProject(projectId: string): Promise<FullProjectModel> {
82
- const [{ instances, hubs }, compositeInstances, library] = await Promise.all([
50
+ const [projectInfo, project] = await Promise.all([
51
+ this.projectBackend.getProjectInfo(projectId),
83
52
  this.projectBackend.getProject(projectId),
84
- this.stateBackend.getCompositeInstances(projectId),
85
- this.libraryBackend.loadLibrary(),
86
53
  ])
87
54
 
88
- const filteredInstances = instances.filter(instance => instance.type in library.components)
55
+ // Ensure we're watching this library for changes
56
+ this.ensureLibraryWatched(projectInfo.libraryId)
57
+
58
+ const [compositeInstances, library] = await Promise.all([
59
+ this.stateManager.getCompositeInstanceRepository(projectId).getAllItems(),
60
+ this.libraryBackend.loadLibrary(projectInfo.libraryId),
61
+ ])
62
+
63
+ const filteredInstances = project.instances.filter(
64
+ instance => instance.type in library.components,
65
+ )
89
66
  const filteredCompositeInstances = compositeInstances
90
67
  .filter(instance => instance.instance.type in library.components)
91
68
  .map(instance => ({
@@ -94,8 +71,9 @@ export class ProjectManager {
94
71
  }))
95
72
 
96
73
  return {
74
+ libraryId: projectInfo.libraryId,
97
75
  instances: filteredInstances,
98
- hubs,
76
+ hubs: project.hubs,
99
77
  compositeInstances: filteredCompositeInstances,
100
78
  }
101
79
  }
@@ -113,7 +91,7 @@ export class ProjectManager {
113
91
  patch: InstanceModelPatch,
114
92
  ): Promise<InstanceModel> {
115
93
  const instance = await this.projectBackend.updateInstance(projectId, instanceId, patch)
116
- await this.evaluateChangedCompositeInstances(projectId)
94
+ // await this.evaluateChangedCompositeInstances(projectId)
117
95
 
118
96
  return instance
119
97
  }
@@ -132,10 +110,18 @@ export class ProjectManager {
132
110
  async deleteInstance(projectId: string, instanceId: string): Promise<void> {
133
111
  await Promise.all([
134
112
  this.projectBackend.deleteInstance(projectId, instanceId),
135
- this.stateBackend.clearCompositeInstances(projectId, [instanceId]),
113
+ this.stateManager.getCompositeInstanceRepository(projectId).delete(instanceId),
136
114
  ])
137
115
  }
138
116
 
117
+ async createManyNodes(
118
+ projectId: string,
119
+ instances: InstanceModel[],
120
+ hubs: HubModel[],
121
+ ): Promise<void> {
122
+ await this.projectBackend.createManyNodes(projectId, instances, hubs)
123
+ }
124
+
139
125
  private async evaluateChangedCompositeInstances(projectId: string): Promise<void> {
140
126
  const { inputHashResolver, instances, library, evaluatedInputHashes } =
141
127
  await this.prepareResolvers(projectId)
@@ -152,22 +138,51 @@ export class ProjectManager {
152
138
  )
153
139
  .map(instance => instance.id)
154
140
 
155
- await this.projectLockManager.getLock(projectId).lockInstances(instanceIds, async () => {
141
+ await this.instanceLockService.tryLockInstances(
142
+ projectId,
143
+ instanceIds,
144
+ {
145
+ title: "Composite Instance Evaluation",
146
+ description: "This instance is now being evaluated",
147
+ icon: "logos:typescript-icon-round",
148
+ },
149
+ {
150
+ type: "evaluation",
151
+ evaluationId: randomUUID(),
152
+ },
153
+ )
154
+
155
+ try {
156
156
  this.logger.debug({ instanceIds }, "evaluating composite instances")
157
157
 
158
158
  for (const instanceId of instanceIds) {
159
- this.compositeInstanceEE.emit(projectId, { type: "evaluation-started", instanceId })
159
+ void this.pubsubManager.publish(`instance:state:patch:${projectId}`, {
160
+ patch: {
161
+ id: instanceId,
162
+ evaluationStatus: {
163
+ status: "evaluating",
164
+ },
165
+ },
166
+ })
160
167
  }
161
168
 
162
169
  const [
163
- { instances, resolvedInputs, stateMap, inputHashResolver },
164
- topLevelCompositeChildrenIds,
170
+ { instances, resolvedInputs, stateMap, inputHashResolver, libraryId },
171
+ existingCompositeInstances,
165
172
  ] = await Promise.all([
166
173
  this.prepareResolvers(projectId),
167
- this.stateBackend.getTopLevelCompositeChildrenIds(projectId, instanceIds),
174
+ this.stateManager.getCompositeInstanceRepository(projectId).getAllRecord(),
168
175
  ])
169
176
 
177
+ // Get child instance IDs for the instances being re-evaluated
178
+ const oldChildInstanceIds = new Set(
179
+ existingCompositeInstances
180
+ .filter(ci => instanceIds.includes(ci.instance.id))
181
+ .flatMap(ci => ci.childCompositeInstanceIds ?? []),
182
+ )
183
+
170
184
  const results = await this.libraryBackend.evaluateCompositeInstances(
185
+ libraryId,
171
186
  instances,
172
187
  resolvedInputs,
173
188
  instanceIds,
@@ -175,9 +190,11 @@ export class ProjectManager {
175
190
 
176
191
  const newStates = results.map(result => {
177
192
  const existingState = stateMap.get(result.instanceId)
178
- const newState = existingState ?? createInstanceState(result.instanceId)
193
+ const newState = existingState ?? { id: result.instanceId }
179
194
 
180
- newState.evaluationError = result.success ? null : result.error
195
+ newState.evaluationStatus = result.success
196
+ ? { status: "evaluated", message: ProjectManager.createSuccessEvaluationMessage(result) }
197
+ : { status: "error", message: result.error }
181
198
 
182
199
  return newState
183
200
  })
@@ -190,76 +207,81 @@ export class ProjectManager {
190
207
  .flatMap(result =>
191
208
  result.compositeInstances.map(instance => ({
192
209
  ...instance,
210
+ id: instance.instance.id,
193
211
  inputHash:
194
212
  // only store inputHash for top-level composite instances
195
213
  instance.instance.id === result.instanceId
196
214
  ? inputHashResolver.requireOutput(instance.instance.id).inputHash
197
215
  : "",
216
+ // only store childCompositeInstanceIds for top-level composite instances
217
+ childCompositeInstanceIds:
218
+ instance.instance.id === result.instanceId
219
+ ? result.compositeInstances
220
+ .filter(ci => ci.instance.id !== result.instanceId)
221
+ .map(ci => ci.instance.id)
222
+ : undefined,
198
223
  })),
199
224
  )
200
225
 
201
- const newTopLevelCompositeChildrenIds = Object.fromEntries(
226
+ const newChildInstanceIds = new Set(
202
227
  results
203
228
  .filter(result => result.success)
204
- .map(result => [
205
- result.instanceId,
229
+ .flatMap(result =>
206
230
  result.compositeInstances
207
231
  .filter(instance => instance.instance.id !== result.instanceId)
208
232
  .map(instance => instance.instance.id),
209
- ]),
233
+ ),
210
234
  )
211
235
 
212
- const deletedCompositeInstanceIds = new Set(
213
- Object.values(topLevelCompositeChildrenIds).flat(),
214
- )
236
+ const deletedCompositeInstanceIds = new Set<string>()
215
237
 
216
- for (const childInstanceId of Object.values(newTopLevelCompositeChildrenIds).flat()) {
217
- deletedCompositeInstanceIds.delete(childInstanceId)
238
+ // Find child instances that were deleted (existed before but don't exist now)
239
+ for (const oldChildId of oldChildInstanceIds) {
240
+ if (!newChildInstanceIds.has(oldChildId)) {
241
+ deletedCompositeInstanceIds.add(oldChildId)
242
+ }
218
243
  }
219
244
 
220
- await Promise.all([
221
- this.stateBackend.clearCompositeInstances(
222
- projectId,
223
- Array.from(deletedCompositeInstanceIds),
224
- ),
225
- this.stateBackend.putTopLevelCompositeChildrenIds(
226
- projectId,
227
- newTopLevelCompositeChildrenIds,
228
- ),
229
- ])
245
+ await this.stateManager
246
+ .getCompositeInstanceRepository(projectId)
247
+ .deleteMany(Array.from(deletedCompositeInstanceIds))
230
248
 
231
249
  for (const state of newStates) {
232
- this.stateManager.emitStatePatch(projectId, state)
250
+ void this.pubsubManager.publish(`instance:state:patch:${projectId}`, { patch: state })
233
251
 
234
- if (state.evaluationError) {
252
+ if (state.evaluationStatus?.status === "error") {
235
253
  this.logger.error(
236
- { projectId, instanceId: state.id, error: state.evaluationError },
237
- "instance evaluation failed",
254
+ { projectId, instanceId: state.id },
255
+ `evaluation error for instance "%s": %s`,
256
+ state.evaluationStatus.message,
238
257
  )
239
-
240
- this.compositeInstanceEE.emit(projectId, {
241
- type: "failed",
242
- instanceId: state.id,
243
- })
244
258
  }
245
259
  }
246
260
 
247
261
  for (const instance of compositeInstances) {
248
- this.compositeInstanceEE.emit(projectId, { type: "updated", instance })
262
+ void this.pubsubManager.publish(`composite-instance:${projectId}`, {
263
+ type: "updated",
264
+ instance,
265
+ })
249
266
  }
250
267
 
251
268
  for (const instanceId of deletedCompositeInstanceIds) {
252
- this.compositeInstanceEE.emit(projectId, { type: "deleted", instanceId })
269
+ void this.pubsubManager.publish(`composite-instance:${projectId}`, {
270
+ type: "deleted",
271
+ instanceId,
272
+ })
253
273
  }
254
274
 
255
275
  const promises: Promise<void>[] = []
256
276
 
257
277
  if (newStates.length > 0) {
258
- promises.push(this.stateBackend.putInstanceStates(projectId, newStates))
278
+ promises.push(this.stateManager.getInstanceStateRepository(projectId).putMany(newStates))
259
279
  }
260
280
 
261
281
  if (compositeInstances.length > 0) {
262
- promises.push(this.stateBackend.putCompositeInstances(projectId, compositeInstances))
282
+ promises.push(
283
+ this.stateManager.getCompositeInstanceRepository(projectId).putMany(compositeInstances),
284
+ )
263
285
  }
264
286
 
265
287
  this.logger.info(
@@ -274,17 +296,21 @@ export class ProjectManager {
274
296
  )
275
297
 
276
298
  await Promise.all(promises)
277
- })
299
+ } finally {
300
+ // TODO: implement detecting of lost evaluation locks
301
+ await this.instanceLockService.unlockInstancesUnconditionally(projectId, instanceIds)
302
+ }
278
303
  }
279
304
 
280
305
  private async prepareResolvers(projectId: string) {
281
- const [{ instances, hubs }, states, library, evaluatedInputHashes] = await Promise.all([
306
+ const [projectInfo, { instances, hubs }, states] = await Promise.all([
307
+ this.projectBackend.getProjectInfo(projectId),
282
308
  this.projectBackend.getProject(projectId),
283
- this.stateBackend.getAllInstanceStates(projectId),
284
- this.libraryBackend.loadLibrary(),
285
- this.stateBackend.getCompositeInstanceInputHashes(projectId),
309
+ this.stateManager.getInstanceStateRepository(projectId).getAllItems(),
286
310
  ])
287
311
 
312
+ const library = await this.libraryBackend.loadLibrary(projectInfo.libraryId)
313
+
288
314
  const filteredInstances = instances.filter(instance => instance.type in library.components)
289
315
  const stateMap = new Map(states.map(state => [state.id, state]))
290
316
 
@@ -318,7 +344,10 @@ export class ProjectManager {
318
344
 
319
345
  let sourceHash: string | undefined
320
346
  if (isUnitModel(library.components[instance.type])) {
321
- const resolvedUnits = await this.libraryBackend.getResolvedUnitSources([instance.type])
347
+ const resolvedUnits = await this.libraryBackend.getResolvedUnitSources(
348
+ projectInfo.libraryId,
349
+ [instance.type],
350
+ )
322
351
  const resolvedUnit = resolvedUnits.find(unit => unit.unitType === instance.type)
323
352
 
324
353
  if (resolvedUnit) {
@@ -346,28 +375,58 @@ export class ProjectManager {
346
375
  inputHashInputs,
347
376
  inputHashResolver,
348
377
  library,
378
+ libraryId: projectInfo.libraryId,
349
379
  instances: filteredInstances,
350
380
  stateMap,
351
381
  resolvedInputs,
352
- evaluatedInputHashes,
353
382
  }
354
383
  }
355
384
 
356
- private async watchLibraryChanges(): Promise<void> {
357
- for await (const updates of this.libraryBackend.watchLibrary()) {
358
- try {
359
- await this.handleLibraryUpdates(updates)
360
- } catch (error) {
361
- this.logger.error({ error }, "failed to handle library updates")
385
+ /**
386
+ * Ensures a library is being watched for changes.
387
+ * Called when a project using this library is accessed.
388
+ */
389
+ private ensureLibraryWatched(libraryId: string): void {
390
+ if (this.watchedLibraries.has(libraryId)) {
391
+ return
392
+ }
393
+
394
+ this.watchedLibraries.add(libraryId)
395
+ const abortController = new AbortController()
396
+ this.libraryWatchers.set(libraryId, abortController)
397
+
398
+ this.logger.debug({ libraryId }, "starting library watcher")
399
+
400
+ // Start watching this library in the background
401
+ void this.watchSingleLibrary(libraryId, abortController.signal)
402
+ }
403
+
404
+ /**
405
+ * Watches a single library for changes and handles updates.
406
+ */
407
+ private async watchSingleLibrary(libraryId: string, signal: AbortSignal): Promise<void> {
408
+ try {
409
+ for await (const updates of this.libraryBackend.watchLibrary(libraryId, signal)) {
410
+ try {
411
+ await this.handleLibraryUpdates(libraryId, updates)
412
+ } catch (error) {
413
+ this.logger.error({ error, libraryId }, "failed to handle library updates")
414
+ }
415
+ }
416
+ } catch (error) {
417
+ if (signal.aborted) {
418
+ this.logger.debug({ libraryId }, "library watcher stopped")
419
+ } else {
420
+ this.logger.error({ error, libraryId }, "library watcher failed")
362
421
  }
422
+ } finally {
423
+ this.watchedLibraries.delete(libraryId)
424
+ this.libraryWatchers.delete(libraryId)
363
425
  }
364
426
  }
365
427
 
366
- private async handleLibraryUpdates(updates: LibraryUpdate[]): Promise<void> {
428
+ private async handleLibraryUpdates(libraryId: string, updates: LibraryUpdate[]): Promise<void> {
367
429
  const changedComponents = new Set<string>()
368
- const library = await this.libraryBackend.loadLibrary()
369
-
370
- // TODO: handle entity updates
371
430
 
372
431
  for (const update of updates) {
373
432
  switch (update.type) {
@@ -377,6 +436,11 @@ export class ProjectManager {
377
436
  case "component-removed":
378
437
  changedComponents.add(update.componentType)
379
438
  break
439
+ // Handle entity updates if needed
440
+ case "entity-updated":
441
+ case "entity-removed":
442
+ // Entity updates don't directly affect composite instance evaluation
443
+ break
380
444
  }
381
445
  }
382
446
 
@@ -385,49 +449,126 @@ export class ProjectManager {
385
449
  }
386
450
 
387
451
  this.logger.info(
388
- { changedComponents },
389
- "library components changed, updating composite instances",
452
+ { libraryId, changedComponents: Array.from(changedComponents) },
453
+ "library components changed, updating composite instances for affected projects",
390
454
  )
391
455
 
392
- const projects = await this.projectBackend.getProjectIds()
393
- for (const projectId of projects) {
394
- const { instances } = await this.prepareResolvers(projectId)
456
+ // Get all projects and find those using this libraryId
457
+ const allProjectIds = await this.projectBackend.getProjectIds()
395
458
 
396
- const filteredInstances = instances.filter(
397
- instance =>
398
- changedComponents.has(instance.type) &&
399
- library.components[instance.type] &&
400
- !isUnitModel(library.components[instance.type]),
401
- )
459
+ for (const projectId of allProjectIds) {
460
+ try {
461
+ // Check if this project uses the changed library
462
+ const projectInfo = await this.projectBackend.getProjectInfo(projectId)
463
+ if (projectInfo.libraryId !== libraryId) {
464
+ continue // Skip projects that don't use this library
465
+ }
402
466
 
403
- this.logger.info(
404
- { projectId, filteredInstanceIds: filteredInstances.map(instance => instance.id) },
405
- "updating composite instances for project",
406
- )
467
+ // Load the updated library for filtering
468
+ const library = await this.libraryBackend.loadLibrary(libraryId)
469
+ const { instances } = await this.prepareResolvers(projectId)
407
470
 
408
- try {
409
- await this.evaluateChangedCompositeInstances(projectId)
471
+ const affectedInstances = instances.filter(
472
+ instance =>
473
+ changedComponents.has(instance.type) &&
474
+ library.components[instance.type] &&
475
+ !isUnitModel(library.components[instance.type]),
476
+ )
477
+
478
+ if (affectedInstances.length > 0) {
479
+ this.logger.info(
480
+ {
481
+ projectId,
482
+ libraryId,
483
+ affectedInstanceIds: affectedInstances.map(instance => instance.id),
484
+ },
485
+ "updating composite instances for project due to library changes",
486
+ )
487
+
488
+ await this.evaluateChangedCompositeInstances(projectId)
489
+ }
410
490
  } catch (error) {
411
- this.logger.error({ error }, "failed to evaluate composite instances")
491
+ this.logger.error(
492
+ { error, projectId, libraryId },
493
+ "failed to evaluate composite instances for project during library update",
494
+ )
412
495
  }
413
496
  }
414
497
  }
415
498
 
499
+ /**
500
+ * Cleanup method to stop all library watchers.
501
+ * Should be called when the ProjectManager is being shut down.
502
+ */
503
+ dispose(): void {
504
+ for (const [libraryId, abortController] of this.libraryWatchers.entries()) {
505
+ this.logger.debug({ libraryId }, "stopping library watcher")
506
+ abortController.abort()
507
+ }
508
+ this.libraryWatchers.clear()
509
+ this.watchedLibraries.clear()
510
+ }
511
+
416
512
  static create(
417
513
  projectBackend: ProjectBackend,
418
- stateBackend: StateBackend,
419
514
  libraryBackend: LibraryBackend,
420
- projectLockManager: ProjectLockManager,
421
515
  stateManager: StateManager,
516
+ instanceLockService: InstanceLockService,
517
+ pubsubManager: PubSubManager,
422
518
  logger: Logger,
423
519
  ): ProjectManager {
424
520
  return new ProjectManager(
425
521
  projectBackend,
426
- stateBackend,
427
522
  libraryBackend,
428
- projectLockManager,
429
523
  stateManager,
524
+ instanceLockService,
525
+ pubsubManager,
430
526
  logger.child({ service: "ProjectManager" }),
431
527
  )
432
528
  }
529
+
530
+ static createSuccessEvaluationMessage(
531
+ result: InstanceEvaluationResult & { success: true },
532
+ ): string {
533
+ const treeNodes = new Map<string, TreeNode>()
534
+
535
+ // the order of composite instances are guaranteed to be topologically sorted
536
+ for (const compositeInstance of result.compositeInstances) {
537
+ const node: TreeNode = {
538
+ text: compositeInstance.id,
539
+ children: [],
540
+ }
541
+
542
+ treeNodes.set(compositeInstance.id, node)
543
+
544
+ const parentNode = compositeInstance.instance.parentId
545
+ ? treeNodes.get(compositeInstance.instance.parentId)
546
+ : undefined
547
+
548
+ if (parentNode) {
549
+ parentNode.children.push(node)
550
+ }
551
+
552
+ for (const child of compositeInstance.children) {
553
+ const childNode: TreeNode = {
554
+ text: child.id,
555
+ children: [],
556
+ }
557
+
558
+ node.children.push(childNode)
559
+ treeNodes.set(child.id, childNode)
560
+ }
561
+ }
562
+
563
+ // get the root node
564
+ const rootNode = treeNodes.get(result.instanceId)
565
+ if (!rootNode) {
566
+ return `Composite instance evaluation completed successfully, but failed to build the tree structure.`
567
+ }
568
+
569
+ // render the tree structure
570
+ const tree = renderTree(rootNode)
571
+
572
+ return `Composite instance evaluation completed successfully.\n\nInstance Tree:\n${tree}`
573
+ }
433
574
  }
@@ -0,0 +1,13 @@
1
+ import type { z } from "zod"
2
+
3
+ export interface PubSubBackend {
4
+ /**
5
+ * Subscribes to a topic and returns an async iterator for the messages.
6
+ */
7
+ subscribe(topic: string, schema: z.ZodType, signal?: AbortSignal): AsyncIterable<unknown>
8
+
9
+ /**
10
+ * Publishes a message to a topic.
11
+ */
12
+ publish(topic: string, schema: z.ZodType, message: unknown): Promise<void>
13
+ }
@@ -0,0 +1,19 @@
1
+ import type { Logger } from "pino"
2
+ import type { PubSubBackend } from "./abstractions"
3
+ import { z } from "zod"
4
+ import { LocalPubSubBackend } from "./local"
5
+
6
+ export const pubSubBackendConfig = z.object({
7
+ HIGHSTATE_PUBSUB_BACKEND_TYPE: z.enum(["local"]).default("local"),
8
+ })
9
+
10
+ export function createPubSubBackend(
11
+ config: z.infer<typeof pubSubBackendConfig>,
12
+ logger: Logger,
13
+ ): PubSubBackend {
14
+ switch (config.HIGHSTATE_PUBSUB_BACKEND_TYPE) {
15
+ case "local": {
16
+ return LocalPubSubBackend.create(logger)
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./abstractions"
2
+ export * from "./factory"
3
+ export * from "./manager"
@@ -0,0 +1,36 @@
1
+ import type { Logger } from "pino"
2
+ import type { PubSubBackend } from "./abstractions"
3
+ import type { z } from "zod"
4
+ import { EventEmitter, on } from "node:events"
5
+
6
+ export class LocalPubSubBackend implements PubSubBackend {
7
+ private readonly eventEmitter = new EventEmitter()
8
+
9
+ private constructor(private readonly logger: Logger) {}
10
+
11
+ async *subscribe<TSchema extends z.ZodSchema>(
12
+ topic: string,
13
+ _schema: TSchema,
14
+ signal?: AbortSignal,
15
+ ): AsyncIterable<z.infer<TSchema>> {
16
+ for await (const [event] of on(this.eventEmitter, topic, { signal })) {
17
+ // do not validate here because all IPC happends in the same process
18
+ yield event as z.infer<TSchema>
19
+ }
20
+ }
21
+
22
+ publish<TSchema extends z.ZodSchema>(
23
+ topic: string,
24
+ _schema: TSchema,
25
+ message: z.infer<TSchema>,
26
+ ): Promise<void> {
27
+ this.eventEmitter.emit(topic, message)
28
+ this.logger.trace({ topic, message }, "published message to topic")
29
+
30
+ return Promise.resolve()
31
+ }
32
+
33
+ static create(logger: Logger): PubSubBackend {
34
+ return new LocalPubSubBackend(logger)
35
+ }
36
+ }