@highstate/backend 0.4.3 → 0.4.5

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.
package/dist/index.mjs CHANGED
@@ -1,7 +1,6 @@
1
1
  import { z } from 'zod';
2
- import { pickBy, mapKeys, mapValues, funnel } from 'remeda';
2
+ import { pickBy, mapKeys, mapValues, funnel, omit, pick } from 'remeda';
3
3
  import { BetterLock } from 'better-lock';
4
- import { consola } from 'consola';
5
4
  import { basename, relative, dirname, resolve as resolve$1 } from 'node:path';
6
5
  import { findWorkspaceDir, readPackageJSON } from 'pkg-types';
7
6
  import { fileURLToPath } from 'node:url';
@@ -11,8 +10,9 @@ import Watcher from 'watcher';
11
10
  import { resolve } from 'import-meta-resolve';
12
11
  import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
13
12
  import { getInstanceId, isUnitModel, parseInstanceId } from '@highstate/contract';
14
- import { b as instanceModelSchema, I as InputHashCalculator, l as createInstanceState, t as terminalFactorySchema, h as instanceRepresentationSchema, f as instanceStatusFieldMapSchema, c as compositeInstanceSchema, s as projectOperationSchema, k as instanceStateSchema, m as applyInstanceStatePatch, o as createInstanceStateFrontendPatch } from './input-hash-C8HEDMjz.mjs';
15
- import 'crypto-hash';
13
+ import { h as hubModelSchema, i as instanceModelSchema, c as createInputResolver, a as createInputHashResolver, b as createInstanceState, d as instanceTerminalSchema, e as instancePageSchema, f as instanceFileSchema, g as instanceStatusFieldSchema, j as instanceTriggerSchema, k as compositeInstanceSchema, p as projectOperationSchema, l as instanceStateSchema, m as isFinalOperationStatus, t as terminalSessionSchema, n as applyPartialInstanceState, o as createInstanceStateFrontendPatch } from './terminal-C1HuyJ6e.mjs';
14
+ import { sha256 } from 'crypto-hash';
15
+ import 'ajv';
16
16
  import { Readable, PassThrough } from 'node:stream';
17
17
  import { tmpdir, homedir } from 'node:os';
18
18
  import spawn from 'nano-spawn';
@@ -57,6 +57,12 @@ function errorToString(error) {
57
57
  }
58
58
  return JSON.stringify(error);
59
59
  }
60
+ function arrayAccumulator(values, value) {
61
+ if (!values) {
62
+ return [value];
63
+ }
64
+ return [...values, value];
65
+ }
60
66
 
61
67
  class LocalPulumiHost {
62
68
  constructor(logger) {
@@ -74,7 +80,7 @@ class LocalPulumiHost {
74
80
  }
75
81
  }
76
82
  async runInline(projectId, pulumiProjectName, pulumiStackName, program, fn) {
77
- return this.lock.acquire(`${pulumiProjectName}.${pulumiStackName}`, async () => {
83
+ return await this.lock.acquire(`${pulumiProjectName}.${pulumiStackName}`, async () => {
78
84
  const { LocalWorkspace } = await import('@pulumi/pulumi/automation/index.js');
79
85
  const stack = await LocalWorkspace.createOrSelectStack(
80
86
  {
@@ -87,13 +93,16 @@ class LocalPulumiHost {
87
93
  name: pulumiProjectName,
88
94
  runtime: "nodejs"
89
95
  },
90
- envVars: { PULUMI_CONFIG_PASSPHRASE: this.getPassword(projectId) }
96
+ envVars: {
97
+ PULUMI_CONFIG_PASSPHRASE: this.getPassword(projectId),
98
+ PULUMI_K8S_AWAIT_ALL: "true"
99
+ }
91
100
  }
92
101
  );
93
102
  try {
94
103
  return await runWithRetryOnError(
95
104
  () => fn(stack),
96
- (error) => LocalPulumiHost.tryUnlockStack(stack, error)
105
+ (error) => this.tryUnlockStack(stack, error)
97
106
  );
98
107
  } catch (e) {
99
108
  if (e instanceof Error && e.message.includes("canceled")) {
@@ -104,12 +113,12 @@ class LocalPulumiHost {
104
113
  });
105
114
  }
106
115
  async runEmpty(projectId, pulumiProjectName, pulumiStackName, fn) {
107
- return this.runInline(projectId, pulumiProjectName, pulumiStackName, async () => {
116
+ return await this.runInline(projectId, pulumiProjectName, pulumiStackName, async () => {
108
117
  }, fn);
109
118
  }
110
119
  // TODO: extract args to options object
111
120
  async runLocal(projectId, pulumiProjectName, pulumiStackName, programPathResolver, fn, stackConfig) {
112
- return this.lock.acquire(`${pulumiProjectName}.${pulumiStackName}`, async () => {
121
+ return await this.lock.acquire(`${pulumiProjectName}.${pulumiStackName}`, async () => {
113
122
  const { LocalWorkspace } = await import('@pulumi/pulumi/automation/index.js');
114
123
  const stack = await LocalWorkspace.createOrSelectStack(
115
124
  {
@@ -125,14 +134,17 @@ class LocalPulumiHost {
125
134
  [pulumiStackName]: {
126
135
  config: stackConfig
127
136
  }
128
- } : undefined,
129
- envVars: { PULUMI_CONFIG_PASSPHRASE: this.getPassword(projectId) }
137
+ } : void 0,
138
+ envVars: {
139
+ PULUMI_CONFIG_PASSPHRASE: this.getPassword(projectId),
140
+ PULUMI_K8S_AWAIT_ALL: "true"
141
+ }
130
142
  }
131
143
  );
132
144
  try {
133
145
  return await runWithRetryOnError(
134
146
  () => fn(stack),
135
- (error) => LocalPulumiHost.tryUnlockStack(stack, error)
147
+ (error) => this.tryUnlockStack(stack, error)
136
148
  );
137
149
  } catch (e) {
138
150
  if (e instanceof Error && e.message.includes("canceled")) {
@@ -153,9 +165,9 @@ class LocalPulumiHost {
153
165
  getPassword(projectId) {
154
166
  return this.sharedPassword || this.passwords.get(projectId) || "";
155
167
  }
156
- static async tryUnlockStack(stack, error) {
168
+ async tryUnlockStack(stack, error) {
157
169
  if (error instanceof Error && error.message.includes("the stack is currently locked")) {
158
- consola.warn("Unlocking stack", stack.name);
170
+ this.logger.warn({ stackName: stack.name }, "inlocking stack");
159
171
  await stack.cancel();
160
172
  return true;
161
173
  }
@@ -446,7 +458,8 @@ const localProjectBackendConfig = z.object({
446
458
  HIGHSTATE_BACKEND_PROJECT_PROJECTS_DIR: z.string().optional()
447
459
  });
448
460
  const projectModelSchema = z.object({
449
- instances: z.record(instanceModelSchema)
461
+ instances: z.record(instanceModelSchema),
462
+ hubs: z.record(hubModelSchema)
450
463
  });
451
464
  class LocalProjectBackend {
452
465
  constructor(projectsDir) {
@@ -460,10 +473,13 @@ class LocalProjectBackend {
460
473
  throw new Error("Failed to get project names", { cause: error });
461
474
  }
462
475
  }
463
- async getInstances(projectId) {
476
+ async getProject(projectId) {
464
477
  try {
465
478
  const project = await this.loadProject(projectId);
466
- return Object.values(project.instances);
479
+ return {
480
+ instances: Object.values(project.instances),
481
+ hubs: Object.values(project.hubs)
482
+ };
467
483
  } catch (error) {
468
484
  throw new Error("Failed to get project instances", { cause: error });
469
485
  }
@@ -481,34 +497,40 @@ class LocalProjectBackend {
481
497
  throw new Error("Failed to create project instance", { cause: error });
482
498
  }
483
499
  }
484
- async moveInstance(projectId, instanceId, position) {
485
- try {
486
- return await this.withInstance(projectId, instanceId, (instance) => {
487
- instance.position = position;
488
- return instance;
489
- });
490
- } catch (error) {
491
- throw new Error("Failed to move project instance", { cause: error });
492
- }
493
- }
494
- async updateInstanceArgs(projectId, instanceId, args) {
495
- try {
496
- return await this.withInstance(projectId, instanceId, (instance) => {
497
- instance.args = args;
498
- return instance;
499
- });
500
- } catch (error) {
501
- throw new Error("Failed to update project instance arguments", { cause: error });
502
- }
503
- }
504
- async updateInstanceInputs(projectId, instanceId, inputs) {
500
+ async updateInstance(projectId, instanceId, patch) {
505
501
  try {
506
502
  return await this.withInstance(projectId, instanceId, (instance) => {
507
- instance.inputs = inputs;
503
+ if (patch.args) {
504
+ instance.args = patch.args;
505
+ }
506
+ if (patch.position) {
507
+ instance.position = patch.position;
508
+ }
509
+ if (patch.inputs) {
510
+ if (Object.keys(patch.inputs).length > 0) {
511
+ instance.inputs = patch.inputs;
512
+ } else {
513
+ delete instance.inputs;
514
+ }
515
+ }
516
+ if (patch.hubInputs) {
517
+ if (Object.keys(patch.hubInputs).length > 0) {
518
+ instance.hubInputs = patch.hubInputs;
519
+ } else {
520
+ delete instance.hubInputs;
521
+ }
522
+ }
523
+ if (patch.injectionInputs) {
524
+ if (patch.injectionInputs.length > 0) {
525
+ instance.injectionInputs = patch.injectionInputs;
526
+ } else {
527
+ delete instance.injectionInputs;
528
+ }
529
+ }
508
530
  return instance;
509
531
  });
510
532
  } catch (error) {
511
- throw new Error("Failed to update project instance inputs", { cause: error });
533
+ throw new Error("Failed to update project instance", { cause: error });
512
534
  }
513
535
  }
514
536
  async deleteInstance(projectId, instanceId) {
@@ -519,14 +541,21 @@ class LocalProjectBackend {
519
541
  }
520
542
  delete project.instances[instanceId];
521
543
  for (const otherInstance of Object.values(project.instances)) {
522
- for (const [inputKey, input] of Object.entries(otherInstance.inputs)) {
523
- if (Array.isArray(input)) {
524
- otherInstance.inputs[inputKey] = input.filter(
525
- (inputItem) => inputItem.instanceId !== instanceId
526
- );
527
- } else if (input.instanceId === instanceId) {
528
- delete otherInstance.inputs[inputKey];
529
- }
544
+ if (!otherInstance.inputs) {
545
+ continue;
546
+ }
547
+ this.deleteInstanceReferences(otherInstance.inputs, instanceId);
548
+ if (Object.keys(otherInstance.inputs).length === 0) {
549
+ delete otherInstance.inputs;
550
+ }
551
+ }
552
+ for (const hub of Object.values(project.hubs)) {
553
+ if (!hub.inputs) {
554
+ continue;
555
+ }
556
+ hub.inputs = hub.inputs.filter((input) => input.instanceId !== instanceId);
557
+ if (hub.inputs.length === 0) {
558
+ delete hub.inputs;
530
559
  }
531
560
  }
532
561
  });
@@ -534,6 +563,14 @@ class LocalProjectBackend {
534
563
  throw new Error("Failed to delete project instance", { cause: error });
535
564
  }
536
565
  }
566
+ deleteInstanceReferences(inputs, instanceId) {
567
+ for (const [inputKey, input] of Object.entries(inputs)) {
568
+ inputs[inputKey] = input.filter((inputItem) => inputItem.instanceId !== instanceId);
569
+ if (inputs[inputKey].length === 0) {
570
+ delete inputs[inputKey];
571
+ }
572
+ }
573
+ }
537
574
  async renameInstance(projectId, instanceId, newName) {
538
575
  try {
539
576
  return await this.withProject(projectId, (project) => {
@@ -550,24 +587,114 @@ class LocalProjectBackend {
550
587
  instance.name = newName;
551
588
  project.instances[newInstanceId] = instance;
552
589
  for (const otherInstance of Object.values(project.instances)) {
553
- for (const input of Object.values(otherInstance.inputs)) {
554
- if (Array.isArray(input)) {
555
- for (const inputItem of input) {
556
- if (inputItem.instanceId === instanceId) {
557
- inputItem.instanceId = instance.id;
558
- }
559
- }
560
- } else if (input.instanceId === instanceId) {
561
- input.instanceId = instance.id;
562
- }
590
+ for (const inputs of Object.values(otherInstance.inputs ?? {})) {
591
+ this.renameInstanceReferences(inputs, instanceId, instance.id);
563
592
  }
564
593
  }
594
+ for (const hub of Object.values(project.hubs)) {
595
+ this.renameInstanceReferences(hub.inputs ?? [], instanceId, instance.id);
596
+ }
565
597
  return instance;
566
598
  });
567
599
  } catch (error) {
568
600
  throw new Error("Failed to rename project instance", { cause: error });
569
601
  }
570
602
  }
603
+ renameInstanceReferences(inputs, instanceId, newInstanceId) {
604
+ for (const input of inputs) {
605
+ if (input.instanceId === instanceId) {
606
+ input.instanceId = newInstanceId;
607
+ }
608
+ }
609
+ }
610
+ async createHub(projectId, hub) {
611
+ try {
612
+ return await this.withProject(projectId, (project) => {
613
+ if (project.hubs[hub.id]) {
614
+ throw new Error(`Hub ${hub.id} already exists`);
615
+ }
616
+ project.hubs[hub.id] = hub;
617
+ return hub;
618
+ });
619
+ } catch (error) {
620
+ throw new Error("Failed to create project hub", { cause: error });
621
+ }
622
+ }
623
+ async updateHub(projectId, hubId, patch) {
624
+ try {
625
+ return await this.withProject(projectId, (project) => {
626
+ const hub = project.hubs[hubId];
627
+ if (!hub) {
628
+ throw new Error(`Hub ${hubId} not found`);
629
+ }
630
+ if (patch.position) {
631
+ hub.position = patch.position;
632
+ }
633
+ if (patch.inputs) {
634
+ if (patch.inputs.length > 0) {
635
+ hub.inputs = patch.inputs;
636
+ } else {
637
+ delete hub.inputs;
638
+ }
639
+ }
640
+ if (patch.injectionInputs) {
641
+ if (patch.injectionInputs.length > 0) {
642
+ hub.injectionInputs = patch.injectionInputs;
643
+ } else {
644
+ delete hub.injectionInputs;
645
+ }
646
+ }
647
+ return hub;
648
+ });
649
+ } catch (error) {
650
+ throw new Error("Failed to update project hub", { cause: error });
651
+ }
652
+ }
653
+ async deleteHub(projectId, hubId) {
654
+ try {
655
+ await this.withProject(projectId, (project) => {
656
+ if (!project.hubs[hubId]) {
657
+ throw new Error(`Hub ${hubId} not found`);
658
+ }
659
+ delete project.hubs[hubId];
660
+ for (const instance of Object.values(project.instances)) {
661
+ if (instance.hubInputs) {
662
+ this.deleteHubReferences(instance.hubInputs, hubId);
663
+ if (Object.keys(instance.hubInputs).length === 0) {
664
+ delete instance.hubInputs;
665
+ }
666
+ }
667
+ if (instance.injectionInputs) {
668
+ instance.injectionInputs = instance.injectionInputs.filter(
669
+ (input) => input.hubId !== hubId
670
+ );
671
+ if (instance.injectionInputs.length === 0) {
672
+ delete instance.injectionInputs;
673
+ }
674
+ }
675
+ }
676
+ for (const otherHub of Object.values(project.hubs)) {
677
+ if (!otherHub.injectionInputs) {
678
+ continue;
679
+ }
680
+ otherHub.injectionInputs = otherHub.injectionInputs.filter((input) => input.hubId !== hubId);
681
+ if (otherHub.injectionInputs.length === 0) {
682
+ delete otherHub.injectionInputs;
683
+ }
684
+ }
685
+ });
686
+ } catch (error) {
687
+ throw new Error("Failed to delete project hub", { cause: error });
688
+ }
689
+ }
690
+ deleteHubReferences(inputs, hubId) {
691
+ for (const [inputKey, input] of Object.entries(inputs)) {
692
+ inputs[inputKey] = input.filter((inputItem) => inputItem.hubId !== hubId);
693
+ if (inputs[inputKey].length === 0) {
694
+ delete inputs[inputKey];
695
+ }
696
+ }
697
+ }
571
698
  getProjectPath(projectId) {
572
699
  return `${this.projectsDir}/${projectId}.json`;
573
700
  }
@@ -578,14 +705,14 @@ class LocalProjectBackend {
578
705
  return projectModelSchema.parse(JSON.parse(content));
579
706
  } catch (error) {
580
707
  if (error instanceof Error && "code" in error && error.code === "ENOENT") {
581
- return { instances: {} };
708
+ return { instances: {}, hubs: {} };
582
709
  }
583
710
  throw error;
584
711
  }
585
712
  }
586
713
  async writeProject(projectId, project) {
587
714
  const projectPath = this.getProjectPath(projectId);
588
- const content = JSON.stringify(project, undefined, 2);
715
+ const content = JSON.stringify(project, void 0, 2);
589
716
  await writeFile(projectPath, content);
590
717
  }
591
718
  async withInstance(projectId, instanceId, callback) {
@@ -653,19 +780,10 @@ class ProjectManager {
653
780
  this.logger = logger;
654
781
  }
655
782
  async getCompositeInstance(projectId, instanceId) {
656
- const instances = await this.projectBackend.getInstances(projectId);
657
- const instance = instances.find((instance2) => instance2.id === instanceId);
658
- const instanceMap = /* @__PURE__ */ new Map();
659
- for (const instance2 of instances) {
660
- instanceMap.set(instance2.id, instance2);
661
- }
662
- if (!instance) {
663
- throw new Error(`instance not found: ${instanceId}`);
664
- }
665
- const calculator = new InputHashCalculator(instanceMap);
783
+ const { resolveInputHash } = await this.prepareInputHashResolver(projectId, instanceId);
666
784
  const [compositeInstance, actualInputHash] = await Promise.all([
667
785
  this.stateBackend.getCompositeInstance(projectId, instanceId),
668
- calculator.calculate(instance)
786
+ resolveInputHash(instanceId)
669
787
  ]);
670
788
  if (compositeInstance && compositeInstance.inputHash === actualInputHash) {
671
789
  return compositeInstance;
@@ -678,7 +796,7 @@ class ProjectManager {
678
796
  projectId,
679
797
  instanceId,
680
798
  actualInputHash,
681
- instanceMap
799
+ resolveInputHash
682
800
  );
683
801
  await this.operationManager.launch({
684
802
  type: "evaluate",
@@ -692,18 +810,8 @@ class ProjectManager {
692
810
  await this.updateInstanceChildren(projectId, createdInstance);
693
811
  return createdInstance;
694
812
  }
695
- async updateInstanceArgs(projectId, instanceId, args) {
696
- const instance = await this.projectBackend.updateInstanceArgs(projectId, instanceId, args);
697
- await this.updateInstanceChildren(projectId, instance);
698
- return instance;
699
- }
700
- async updateInstanceInputs(projectId, instanceId, inputs) {
701
- const instance = await this.projectBackend.updateInstanceInputs(projectId, instanceId, inputs);
702
- await this.updateInstanceChildren(projectId, instance);
703
- return instance;
704
- }
705
- async moveInstance(projectId, instanceId, position) {
706
- const instance = await this.projectBackend.moveInstance(projectId, instanceId, position);
813
+ async updateInstance(projectId, instanceId, patch) {
814
+ const instance = await this.projectBackend.updateInstance(projectId, instanceId, patch);
707
815
  await this.updateInstanceChildren(projectId, instance);
708
816
  return instance;
709
817
  }
@@ -719,7 +827,10 @@ class ProjectManager {
719
827
  ]);
720
828
  }
721
829
  async updateInstanceChildren(projectId, instance) {
722
- const library = await this.library.loadLibrary();
830
+ const { resolveInputHash, library } = await this.prepareInputHashResolver(
831
+ projectId,
832
+ instance.id
833
+ );
723
834
  const component = library.components[instance.type];
724
835
  if (!component) {
725
836
  return;
@@ -727,14 +838,8 @@ class ProjectManager {
727
838
  if (isUnitModel(component)) {
728
839
  return;
729
840
  }
730
- const instances = await this.projectBackend.getInstances(projectId);
731
- const instanceMap = /* @__PURE__ */ new Map();
732
- for (const instance2 of instances) {
733
- instanceMap.set(instance2.id, instance2);
734
- }
735
- const inputHashCalculator = new InputHashCalculator(instanceMap);
841
+ const expectedInputHash = await resolveInputHash(instance.id);
736
842
  const inputHash = await this.stateBackend.getCompositeInstanceInputHash(projectId, instance.id);
737
- const expectedInputHash = await inputHashCalculator.calculate(instance);
738
843
  if (inputHash !== expectedInputHash) {
739
844
  this.logger.info("re-evaluating instance since input hash has changed", {
740
845
  projectId,
@@ -747,13 +852,12 @@ class ProjectManager {
747
852
  });
748
853
  }
749
854
  }
750
- async waitForCompositeInstance(projectId, instanceId, expectedInputHash, instanceMap) {
855
+ async waitForCompositeInstance(projectId, instanceId, expectedInputHash, resolveInputHash) {
751
856
  for await (const instance of this.operationManager.watchCompositeInstance(
752
857
  projectId,
753
858
  instanceId
754
859
  )) {
755
- const calculator = new InputHashCalculator(instanceMap);
756
- const actualInputHash = await calculator.calculate(instance.instance);
860
+ const actualInputHash = await resolveInputHash(instanceId);
757
861
  if (actualInputHash !== expectedInputHash) {
758
862
  throw new Error("Composite instance input hash changed while waiting for evaluation");
759
863
  }
@@ -761,6 +865,54 @@ class ProjectManager {
761
865
  }
762
866
  throw new Error("Composite instance stream ended without receiving the expected instance");
763
867
  }
868
+ async prepareInputHashResolver(projectId, instanceId) {
869
+ const { instances, hubs } = await this.projectBackend.getProject(projectId);
870
+ const library = await this.library.loadLibrary();
871
+ const filteredInstances = instances.filter((instance2) => instance2.type in library.components);
872
+ const states = await this.stateBackend.getInstanceStates(projectId);
873
+ const stateMap = new Map(states.map((state) => [state.id, state]));
874
+ const instance = filteredInstances.find((instance2) => instance2.id === instanceId);
875
+ if (!instance) {
876
+ throw new Error(`Instance not found: ${instanceId}`);
877
+ }
878
+ const inputResolverNodes = /* @__PURE__ */ new Map();
879
+ for (const instance2 of filteredInstances) {
880
+ inputResolverNodes.set(`instance:${instance2.id}`, {
881
+ kind: "instance",
882
+ instance: instance2,
883
+ component: library.components[instance2.type]
884
+ });
885
+ }
886
+ for (const hub of hubs) {
887
+ inputResolverNodes.set(`hub:${hub.id}`, { kind: "hub", hub });
888
+ }
889
+ const resolveInputs = createInputResolver(
890
+ inputResolverNodes,
891
+ this.logger.child({ resolver: "input-resolver" })
892
+ );
893
+ const inputHashNodes = /* @__PURE__ */ new Map();
894
+ for (const instance2 of filteredInstances) {
895
+ const output = await resolveInputs(`instance:${instance2.id}`);
896
+ if (output.kind !== "instance") {
897
+ throw new Error("Expected instance node");
898
+ }
899
+ inputHashNodes.set(instance2.id, {
900
+ instance: instance2,
901
+ resolvedInputs: output.resolvedInputs,
902
+ state: stateMap.get(instance2.id),
903
+ sourceHash: void 0
904
+ // implement source hash
905
+ });
906
+ }
907
+ const resolveInputHash = createInputHashResolver(
908
+ inputHashNodes,
909
+ this.logger.child({ resolver: "input-hash-resolver" })
910
+ );
911
+ return {
912
+ resolveInputHash,
913
+ library
914
+ };
915
+ }
764
916
  static create(projectBackend, stateBackend, operationManager, library, logger) {
765
917
  return new ProjectManager(
766
918
  projectBackend,
@@ -788,8 +940,13 @@ done
788
940
 
789
941
  # Create files
790
942
  for key in "\${filesKeys[@]}"; do
791
- content=$(jq -r ".files[\\"$key\\"]" <<<"$data")
792
- echo -n "$content" > "$key"
943
+ isBinary=$(jq -r ".files[\\"$key\\"].isBinary // false" <<<"$data")
944
+ content=$(jq -r ".files[\\"$key\\"].content" <<<"$data")
945
+ if [ "$isBinary" = "true" ]; then
946
+ echo -n "$content" | base64 -d > "$key"
947
+ else
948
+ echo -n "$content" > "$key"
949
+ fi
793
950
  done
794
951
 
795
952
  # Execute the command, keeping stdin/stdout open
@@ -863,82 +1020,151 @@ function createTerminalBackend(config, logger) {
863
1020
 
864
1021
  const notAttachedTerminalLifetime = 30 * 1e3;
865
1022
  class TerminalManager {
866
- constructor(terminalBackend, logger) {
1023
+ constructor(terminalBackend, stateBackend, runnerBackend, logger) {
867
1024
  this.terminalBackend = terminalBackend;
1025
+ this.stateBackend = stateBackend;
1026
+ this.runnerBackend = runnerBackend;
868
1027
  this.logger = logger;
869
1028
  }
870
- terminals = /* @__PURE__ */ new Map();
871
- create(factory) {
872
- const terminal = {
1029
+ managedTerminals = /* @__PURE__ */ new Map();
1030
+ async createSession(projectId, instanceId, terminalName) {
1031
+ const terminalSession = {
873
1032
  id: randomUUID(),
874
- abortController: new AbortController(),
875
- attached: false,
876
- stdin: new PassThrough(),
877
- stdout: new PassThrough(),
878
- history: []
1033
+ terminalName
879
1034
  };
880
- terminal.stdout.on("data", (data) => {
881
- terminal.history.push(String(data));
882
- });
883
- this.terminals.set(terminal.id, terminal);
884
- void this.terminalBackend.run({
885
- factory,
886
- stdin: terminal.stdin,
887
- stdout: terminal.stdout,
888
- signal: terminal.abortController.signal
889
- }).catch((error) => {
890
- this.logger.error("terminal failed", { id: terminal.id, error });
891
- console.error(error);
892
- }).finally(() => {
893
- this.logger.info("terminal finished", { id: terminal.id });
894
- this.terminals.delete(terminal.id);
895
- });
896
- setTimeout(() => this.closeTerminalIfNotAttached(terminal), notAttachedTerminalLifetime);
897
- this.logger.info("terminal created", { id: terminal.id });
898
- return terminal;
1035
+ await this.stateBackend.putTerminalSession(projectId, instanceId, terminalSession);
1036
+ this.logger.info({ msg: "terminal session created", id: terminalSession.id });
1037
+ await this.createManagedTerminal(projectId, instanceId, terminalSession);
1038
+ return terminalSession;
1039
+ }
1040
+ async ensureSessionCreated(projectId, instanceId, terminalName) {
1041
+ const terminalSessions = await this.stateBackend.getTerminalSessions(projectId, instanceId);
1042
+ let session = terminalSessions.find((session2) => session2.terminalName === terminalName);
1043
+ if (session) {
1044
+ this.logger.info({ msg: "reusing existing terminal session", id: session.id });
1045
+ } else {
1046
+ this.logger.info("creating new terminal session");
1047
+ session = await this.createSession(projectId, instanceId, terminalName);
1048
+ }
1049
+ if (!this.managedTerminals.has(session.id)) {
1050
+ this.logger.info({ msg: "no managed terminal found, creating new one", id: session.id });
1051
+ await this.createManagedTerminal(projectId, instanceId, session);
1052
+ }
1053
+ return session;
899
1054
  }
900
- close(id) {
901
- const terminal = this.terminals.get(id);
902
- if (!terminal) {
903
- return;
1055
+ close(sessionId) {
1056
+ this.logger.info({ msg: "closing terminal session", id: sessionId });
1057
+ const managedTerminal = this.managedTerminals.get(sessionId);
1058
+ if (managedTerminal) {
1059
+ managedTerminal.abortController.abort();
904
1060
  }
905
- terminal.abortController.abort();
906
- this.terminals.delete(id);
907
- this.logger.info("terminal closed", { id });
908
1061
  }
909
- attach(id, stdin, stdout, signal) {
910
- const terminal = this.terminals.get(id);
1062
+ async attach(projectId, instanceId, sessionId, stdin, stdout, signal) {
1063
+ this.logger.info({ msg: "attaching terminal", projectId, instanceId, sessionId });
1064
+ let terminal = this.managedTerminals.get(sessionId);
911
1065
  if (!terminal) {
912
- throw new Error("Terminal not found");
1066
+ this.logger.info({ msg: "no managed terminal found, creating new one", sessionId });
1067
+ const session = await this.stateBackend.getTerminalSession(projectId, instanceId, sessionId);
1068
+ if (!session) {
1069
+ throw new Error(`Terminal session "${sessionId}" not found`);
1070
+ }
1071
+ terminal = await this.createManagedTerminal(projectId, instanceId, session);
1072
+ terminal.history = await this.stateBackend.getTerminalSessionHistory(sessionId);
1073
+ }
1074
+ if (terminal.attached) {
1075
+ throw new Error(`Terminal session "${sessionId}" is already attached`);
913
1076
  }
914
1077
  terminal.attached = true;
915
1078
  for (const line of terminal.history) {
916
- stdout.write(line + "\n");
1079
+ stdout.write(line);
917
1080
  }
918
- this.logger.info("history replayed", { id });
1081
+ this.logger.info({ msg: "history replayed", count: terminal.history.length, sessionId });
919
1082
  stdin.pipe(terminal.stdin);
920
1083
  terminal.stdout.pipe(stdout);
921
- this.logger.info("terminal attached", { id });
1084
+ this.logger.info({ msg: "terminal attached", id: sessionId });
922
1085
  signal.addEventListener("abort", () => {
923
1086
  terminal.attached = false;
924
- this.logger.info("terminal detached", { id });
1087
+ this.logger.info({ msg: "terminal detached", id: sessionId });
925
1088
  });
926
1089
  }
1090
+ async createManagedTerminal(projectId, instanceId, terminalSession) {
1091
+ const [instanceType, instanceName] = parseInstanceId(instanceId);
1092
+ const factory = await this.runnerBackend.getTerminalFactory(
1093
+ { projectId, instanceType, instanceName },
1094
+ terminalSession.terminalName
1095
+ );
1096
+ if (!factory) {
1097
+ throw new Error(
1098
+ `Terminal factory for instance "${instanceId}" with name "${terminalSession.terminalName}" not found`
1099
+ );
1100
+ }
1101
+ const managedTerminal = {
1102
+ sessionId: terminalSession.id,
1103
+ abortController: new AbortController(),
1104
+ attached: false,
1105
+ stdin: new PassThrough(),
1106
+ stdout: new PassThrough(),
1107
+ history: []
1108
+ };
1109
+ managedTerminal.stdout.on("data", (data) => {
1110
+ const line = String(data);
1111
+ managedTerminal.history.push(String(data));
1112
+ void this.persistHistory.call([terminalSession.id, line]);
1113
+ });
1114
+ this.managedTerminals.set(managedTerminal.sessionId, managedTerminal);
1115
+ void this.terminalBackend.run({
1116
+ factory,
1117
+ stdin: managedTerminal.stdin,
1118
+ stdout: managedTerminal.stdout,
1119
+ signal: managedTerminal.abortController.signal
1120
+ }).catch((error) => {
1121
+ this.logger.error({
1122
+ msg: "managed terminal failed",
1123
+ id: managedTerminal.sessionId,
1124
+ error
1125
+ });
1126
+ }).finally(() => {
1127
+ this.logger.info({ msg: "managed terminal closed", id: managedTerminal.sessionId });
1128
+ this.managedTerminals.delete(managedTerminal.sessionId);
1129
+ });
1130
+ setTimeout(() => this.closeTerminalIfNotAttached(managedTerminal), notAttachedTerminalLifetime);
1131
+ this.logger.info({ msg: "managed terminal created", id: managedTerminal.sessionId });
1132
+ return managedTerminal;
1133
+ }
927
1134
  closeTerminalIfNotAttached(terminal) {
928
- if (!this.terminals.has(terminal.id)) {
1135
+ if (!this.managedTerminals.has(terminal.sessionId)) {
929
1136
  return;
930
1137
  }
931
1138
  if (!terminal.attached) {
932
- this.logger.info("terminal not attached for too long, closing", { id: terminal.id });
1139
+ this.logger.info({
1140
+ msg: "terminal not attached for too long, closing",
1141
+ id: terminal.sessionId
1142
+ });
933
1143
  terminal.abortController.abort();
934
- this.terminals.delete(terminal.id);
1144
+ this.managedTerminals.delete(terminal.sessionId);
935
1145
  return;
936
1146
  }
937
1147
  setTimeout(() => this.closeTerminalIfNotAttached(terminal), notAttachedTerminalLifetime);
938
1148
  }
939
- static create(terminalBackend, logger) {
940
- return new TerminalManager(terminalBackend, logger.child({ service: "TerminalManager" }));
1149
+ static create(terminalBackend, stateBackend, runnerBackend, logger) {
1150
+ return new TerminalManager(
1151
+ terminalBackend,
1152
+ stateBackend,
1153
+ runnerBackend,
1154
+ logger.child({ service: "TerminalManager" })
1155
+ );
941
1156
  }
1157
+ persistHistory = funnel(
1158
+ (entries) => {
1159
+ this.logger.trace({ msg: "persisting history lines", count: entries.length });
1160
+ void this.stateBackend.appendTerminalSessionHistory(entries);
1161
+ },
1162
+ {
1163
+ minQuietPeriodMs: 100,
1164
+ maxBurstDurationMs: 200,
1165
+ reducer: arrayAccumulator
1166
+ }
1167
+ );
942
1168
  }
943
1169
 
944
1170
  class InvalidInstanceStatusError extends Error {
@@ -1008,31 +1234,60 @@ class LocalRunnerBackend {
1008
1234
  }
1009
1235
  );
1010
1236
  }
1011
- getTerminalFactory(options) {
1237
+ getTerminalFactory(options, terminalName) {
1012
1238
  return this.pulumiProjectHost.runEmpty(
1013
1239
  options.projectId,
1014
1240
  options.instanceType,
1015
1241
  options.instanceName,
1016
1242
  async (stack) => {
1017
1243
  const outputs = await stack.outputs();
1018
- if (!outputs["$terminal"]) {
1244
+ if (!outputs["$terminals"]) {
1019
1245
  return null;
1020
1246
  }
1021
- return terminalFactorySchema.parse(outputs["$terminal"].value);
1247
+ const terminals = z.array(instanceTerminalSchema).parse(outputs["$terminals"].value);
1248
+ const terminal = terminals.find((t) => t.name === terminalName);
1249
+ if (!terminal) {
1250
+ return null;
1251
+ }
1252
+ return terminal;
1022
1253
  }
1023
1254
  );
1024
1255
  }
1025
- getRepresentationContent(options) {
1256
+ getPageContent(options, pageName) {
1026
1257
  return this.pulumiProjectHost.runEmpty(
1027
1258
  options.projectId,
1028
1259
  options.instanceType,
1029
1260
  options.instanceName,
1030
1261
  async (stack) => {
1031
1262
  const outputs = await stack.outputs();
1032
- if (!outputs["$representation"]) {
1263
+ if (!outputs["$pages"]) {
1264
+ return null;
1265
+ }
1266
+ const pages = z.array(instancePageSchema).parse(outputs["$pages"].value);
1267
+ const page = pages.find((p) => p.name === pageName);
1268
+ if (!page) {
1033
1269
  return null;
1034
1270
  }
1035
- return instanceRepresentationSchema.parse(outputs["$representation"].value).content;
1271
+ return page.content;
1272
+ }
1273
+ );
1274
+ }
1275
+ getFileContent(options, fileName) {
1276
+ return this.pulumiProjectHost.runEmpty(
1277
+ options.projectId,
1278
+ options.instanceType,
1279
+ options.instanceName,
1280
+ async (stack) => {
1281
+ const outputs = await stack.outputs();
1282
+ if (!outputs["$files"]) {
1283
+ return null;
1284
+ }
1285
+ const files = z.array(instanceFileSchema).parse(outputs["$files"].value);
1286
+ const file = files.find((f) => f.name === fileName);
1287
+ if (!file) {
1288
+ return null;
1289
+ }
1290
+ return file.content;
1036
1291
  }
1037
1292
  );
1038
1293
  }
@@ -1050,23 +1305,26 @@ class LocalRunnerBackend {
1050
1305
  ...mapValues(options.config, (value) => ({ value })),
1051
1306
  ...mapValues(options.secrets, (value) => ({ value, secret: true }))
1052
1307
  };
1053
- void this.pulumiProjectHost.runLocal(
1054
- options.projectId,
1055
- options.instanceType,
1056
- options.instanceName,
1057
- () => this.resolveProjectPath(options.source),
1058
- async (stack) => {
1059
- await stack.setAllConfig(configMap);
1060
- const instanceId = LocalRunnerBackend.getInstanceId(options);
1061
- this.updateState({
1062
- id: instanceId,
1063
- status: "updating",
1064
- currentResourceCount: 0,
1065
- totalResourceCount: 0
1066
- });
1067
- let currentResourceCount = 0;
1068
- let totalResourceCount = 0;
1069
- try {
1308
+ void this.updateWorker(options, configMap);
1309
+ }
1310
+ async updateWorker(options, configMap) {
1311
+ const instanceId = LocalRunnerBackend.getInstanceId(options);
1312
+ try {
1313
+ await this.pulumiProjectHost.runLocal(
1314
+ options.projectId,
1315
+ options.instanceType,
1316
+ options.instanceName,
1317
+ () => this.resolveProjectPath(options.source),
1318
+ async (stack) => {
1319
+ await stack.setAllConfig(configMap);
1320
+ this.updateState({
1321
+ id: instanceId,
1322
+ status: "updating",
1323
+ currentResourceCount: 0,
1324
+ totalResourceCount: 0
1325
+ });
1326
+ let currentResourceCount = 0;
1327
+ let totalResourceCount = 0;
1070
1328
  await runWithRetryOnError(
1071
1329
  async () => {
1072
1330
  await stack.up({
@@ -1090,7 +1348,7 @@ class LocalRunnerBackend {
1090
1348
  }
1091
1349
  },
1092
1350
  onOutput: (message) => {
1093
- this.updateState({ id: instanceId, message });
1351
+ this.updateState({ id: instanceId, logLine: message });
1094
1352
  if (this.printOutput) {
1095
1353
  console.log(message);
1096
1354
  }
@@ -1101,21 +1359,28 @@ class LocalRunnerBackend {
1101
1359
  this.updateState({
1102
1360
  id: instanceId,
1103
1361
  status: "created",
1362
+ totalResourceCount: currentResourceCount,
1104
1363
  ...extraOutputs
1105
1364
  });
1106
1365
  },
1107
- (error) => LocalPulumiHost.tryUnlockStack(stack, error)
1366
+ async (error) => {
1367
+ const isUnlocked = await this.pulumiProjectHost.tryUnlockStack(stack, error);
1368
+ if (isUnlocked) return true;
1369
+ const isResolved = await this.tryInstallMissingDependencies(error);
1370
+ if (isResolved) return true;
1371
+ return false;
1372
+ }
1108
1373
  );
1109
- } catch (e) {
1110
- this.updateState({
1111
- id: instanceId,
1112
- status: "error",
1113
- error: e instanceof Error ? e.message : String(e)
1114
- });
1115
- }
1116
- },
1117
- configMap
1118
- );
1374
+ },
1375
+ configMap
1376
+ );
1377
+ } catch (error) {
1378
+ this.updateState({
1379
+ id: instanceId,
1380
+ status: "error",
1381
+ error: errorToString(error)
1382
+ });
1383
+ }
1119
1384
  }
1120
1385
  async destroy(options) {
1121
1386
  const currentStatus = await this.validateStatus(options, [
@@ -1124,24 +1389,27 @@ class LocalRunnerBackend {
1124
1389
  "created",
1125
1390
  "error"
1126
1391
  ]);
1127
- if (currentStatus === "destroying" || currentStatus === "not_created") {
1392
+ if (currentStatus === "destroying") {
1128
1393
  return;
1129
1394
  }
1130
- void this.pulumiProjectHost.runEmpty(
1131
- options.projectId,
1132
- options.instanceType,
1133
- options.instanceName,
1134
- async (stack) => {
1135
- const summary = await stack.workspace.stack();
1136
- let currentResourceCount = summary?.resourceCount ?? 0;
1137
- const instanceId = LocalRunnerBackend.getInstanceId(options);
1138
- this.updateState({
1139
- id: instanceId,
1140
- status: "destroying",
1141
- currentResourceCount,
1142
- totalResourceCount: currentResourceCount
1143
- });
1144
- try {
1395
+ void this.destroyWorker(options);
1396
+ }
1397
+ async destroyWorker(options) {
1398
+ const instanceId = LocalRunnerBackend.getInstanceId(options);
1399
+ try {
1400
+ await this.pulumiProjectHost.runEmpty(
1401
+ options.projectId,
1402
+ options.instanceType,
1403
+ options.instanceName,
1404
+ async (stack) => {
1405
+ const summary = await stack.workspace.stack();
1406
+ let currentResourceCount = summary?.resourceCount ?? 0;
1407
+ this.updateState({
1408
+ id: instanceId,
1409
+ status: "destroying",
1410
+ currentResourceCount,
1411
+ totalResourceCount: currentResourceCount
1412
+ });
1145
1413
  await runWithRetryOnError(
1146
1414
  async () => {
1147
1415
  await stack.destroy({
@@ -1157,7 +1425,7 @@ class LocalRunnerBackend {
1157
1425
  }
1158
1426
  },
1159
1427
  onOutput: (message) => {
1160
- this.updateState({ id: instanceId, message });
1428
+ this.updateState({ id: instanceId, logLine: message });
1161
1429
  if (this.printOutput) {
1162
1430
  console.log(message);
1163
1431
  }
@@ -1168,20 +1436,21 @@ class LocalRunnerBackend {
1168
1436
  this.updateState({
1169
1437
  id: instanceId,
1170
1438
  status: "not_created",
1439
+ totalResourceCount: currentResourceCount,
1171
1440
  ...extraOutputs
1172
1441
  });
1173
1442
  },
1174
- (error) => LocalPulumiHost.tryUnlockStack(stack, error)
1443
+ (error) => this.pulumiProjectHost.tryUnlockStack(stack, error)
1175
1444
  );
1176
- } catch (e) {
1177
- this.updateState({
1178
- id: instanceId,
1179
- status: "error",
1180
- error: e instanceof Error ? e.message : String(e)
1181
- });
1182
1445
  }
1183
- }
1184
- );
1446
+ );
1447
+ } catch (error) {
1448
+ this.updateState({
1449
+ id: instanceId,
1450
+ status: "error",
1451
+ error: errorToString(error)
1452
+ });
1453
+ }
1185
1454
  }
1186
1455
  async refresh(options) {
1187
1456
  const currentStatus = await this.validateStatus(options, [
@@ -1193,26 +1462,38 @@ class LocalRunnerBackend {
1193
1462
  if (currentStatus === "refreshing") {
1194
1463
  return;
1195
1464
  }
1196
- void this.pulumiProjectHost.runEmpty(
1197
- options.projectId,
1198
- options.instanceType,
1199
- options.instanceName,
1200
- async (stack) => {
1201
- const summary = await stack.workspace.stack();
1202
- let currentResourceCount = summary?.resourceCount ?? 0;
1203
- const instanceId = LocalRunnerBackend.getInstanceId(options);
1204
- this.updateState({
1205
- id: instanceId,
1206
- status: "refreshing",
1207
- currentResourceCount,
1208
- totalResourceCount: currentResourceCount
1209
- });
1210
- try {
1465
+ void this.refreshWorker(options);
1466
+ }
1467
+ async refreshWorker(options) {
1468
+ const instanceId = LocalRunnerBackend.getInstanceId(options);
1469
+ try {
1470
+ await this.pulumiProjectHost.runEmpty(
1471
+ options.projectId,
1472
+ options.instanceType,
1473
+ options.instanceName,
1474
+ async (stack) => {
1475
+ const summary = await stack.workspace.stack();
1476
+ let currentResourceCount = 0;
1477
+ let totalResourceCount = summary?.resourceCount ?? 0;
1478
+ this.updateState({
1479
+ id: instanceId,
1480
+ status: "refreshing",
1481
+ currentResourceCount,
1482
+ totalResourceCount
1483
+ });
1211
1484
  await runWithRetryOnError(
1212
1485
  async () => {
1213
1486
  await stack.refresh({
1214
1487
  color: "always",
1215
1488
  onEvent: (event) => {
1489
+ if (event.resourcePreEvent) {
1490
+ totalResourceCount = updateResourceCount(
1491
+ event.resourcePreEvent.metadata.op,
1492
+ totalResourceCount
1493
+ );
1494
+ this.updateState({ id: instanceId, totalResourceCount });
1495
+ return;
1496
+ }
1216
1497
  if (event.resOutputsEvent) {
1217
1498
  currentResourceCount = updateResourceCount(
1218
1499
  event.resOutputsEvent.metadata.op,
@@ -1223,7 +1504,7 @@ class LocalRunnerBackend {
1223
1504
  }
1224
1505
  },
1225
1506
  onOutput: (message) => {
1226
- this.updateState({ id: instanceId, message });
1507
+ this.updateState({ id: instanceId, logLine: message });
1227
1508
  if (this.printOutput) {
1228
1509
  console.log(message);
1229
1510
  }
@@ -1234,52 +1515,62 @@ class LocalRunnerBackend {
1234
1515
  this.updateState({
1235
1516
  id: instanceId,
1236
1517
  status: "created",
1518
+ totalResourceCount: currentResourceCount,
1237
1519
  ...extraOutputs
1238
1520
  });
1239
1521
  },
1240
- (error) => LocalPulumiHost.tryUnlockStack(stack, error)
1522
+ (error) => this.pulumiProjectHost.tryUnlockStack(stack, error)
1241
1523
  );
1242
- } catch (e) {
1243
- this.updateState({
1244
- id: instanceId,
1245
- status: "error",
1246
- error: e instanceof Error ? e.message : String(e)
1247
- });
1248
1524
  }
1249
- }
1250
- );
1525
+ );
1526
+ } catch (error) {
1527
+ this.updateState({
1528
+ id: instanceId,
1529
+ status: "error",
1530
+ error: errorToString(error)
1531
+ });
1532
+ }
1251
1533
  }
1252
1534
  async getExtraOutputsStatePatch(stack) {
1253
1535
  const outputs = await stack.outputs();
1254
1536
  const patch = {};
1255
1537
  if (outputs["$status"]) {
1256
- patch.statusFields = instanceStatusFieldMapSchema.parse(outputs["$status"].value);
1538
+ patch.statusFields = z.array(instanceStatusFieldSchema).parse(outputs["$status"].value);
1257
1539
  } else {
1258
- patch.statusFields = null;
1540
+ patch.statusFields = [];
1259
1541
  }
1260
- if (outputs["$representation"]) {
1261
- const instanceRepresentation = instanceRepresentationSchema.parse(
1262
- outputs["$representation"].value
1263
- );
1264
- patch.representationMeta = {
1265
- contentType: instanceRepresentation.contentType,
1266
- fileName: instanceRepresentation.fileName,
1267
- showQRCode: instanceRepresentation.showQRCode
1268
- };
1542
+ if (outputs["$pages"]) {
1543
+ const pages = z.array(instancePageSchema).parse(outputs["$pages"].value);
1544
+ patch.pages = pages.map((page) => omit(page, ["content"]));
1545
+ } else {
1546
+ patch.pages = [];
1547
+ }
1548
+ if (outputs["$files"]) {
1549
+ const files = z.array(instanceFileSchema).parse(outputs["$files"].value);
1550
+ patch.files = files.map((file) => omit(file, ["content"]));
1269
1551
  } else {
1270
- patch.representationMeta = null;
1552
+ patch.files = [];
1271
1553
  }
1272
- if (outputs["$terminal"]) {
1273
- terminalFactorySchema.parse(outputs["$terminal"].value);
1274
- patch.hasTerminal = true;
1554
+ if (outputs["$terminals"]) {
1555
+ const terminals = z.array(instanceTerminalSchema).parse(outputs["$terminals"].value);
1556
+ patch.terminals = terminals.map((terminal) => pick(terminal, ["name", "title", "description"]));
1275
1557
  } else {
1276
- patch.hasTerminal = false;
1558
+ patch.terminals = [];
1559
+ }
1560
+ if (outputs["$triggers"]) {
1561
+ patch.triggers = z.array(instanceTriggerSchema).parse(outputs["$triggers"].value);
1562
+ } else {
1563
+ patch.triggers = [];
1277
1564
  }
1278
1565
  if (outputs["$secrets"]) {
1279
- patch.secrets = z.record(z.string()).parse(outputs["$secrets"].value);
1566
+ patch.secrets = pickBy(
1567
+ z.record(z.string().nullish()).parse(outputs["$secrets"].value),
1568
+ (v) => !!v
1569
+ );
1280
1570
  } else {
1281
1571
  patch.secrets = null;
1282
1572
  }
1573
+ patch.outputHash = await sha256(JSON.stringify(outputs));
1283
1574
  return patch;
1284
1575
  }
1285
1576
  updateState(patch) {
@@ -1306,26 +1597,36 @@ class LocalRunnerBackend {
1306
1597
  if (!source.path) {
1307
1598
  throw new Error("Source path is required for local units");
1308
1599
  }
1309
- return resolve(this.sourceBasePath, source.type);
1600
+ return resolve$1(this.sourceBasePath, source.path);
1310
1601
  }
1311
1602
  if (!this.skipSourceCheck) {
1312
1603
  const packageName = source.version ? `${source.package}@${source.version}` : source.package;
1313
1604
  await ensureDependencyInstalled(packageName);
1314
1605
  }
1315
- if (!source.path) {
1316
- throw new Error("Source path is required for npm units");
1317
- }
1318
- const fullPath = `${source.package}/${source.path}`;
1606
+ const fullPath = source.path ? `${source.package}/${source.path}` : source.package;
1319
1607
  const url = resolve(fullPath, import.meta.url);
1320
1608
  const path = fileURLToPath(url);
1321
1609
  const projectPath = dirname(path);
1322
1610
  return projectPath;
1323
1611
  }
1612
+ async tryInstallMissingDependencies(error) {
1613
+ if (!(error instanceof Error)) {
1614
+ return false;
1615
+ }
1616
+ const pattern = /Cannot find module '(.*)'/;
1617
+ const match = error.message.match(pattern);
1618
+ if (!match) {
1619
+ return false;
1620
+ }
1621
+ const packageName = match[1];
1622
+ await ensureDependencyInstalled(packageName);
1623
+ return true;
1624
+ }
1324
1625
  static async create(config, pulumiProjectHost) {
1325
1626
  let sourceBasePath = config.HIGHSTATE_BACKEND_RUNNER_LOCAL_SOURCE_BASE_PATH;
1326
1627
  if (!sourceBasePath) {
1327
1628
  const [projectPath] = await resolveMainLocalProject();
1328
- sourceBasePath = resolve(projectPath, "units");
1629
+ sourceBasePath = resolve$1(projectPath, "units");
1329
1630
  }
1330
1631
  return new LocalRunnerBackend(
1331
1632
  config.HIGHSTATE_BACKEND_RUNNER_LOCAL_SKIP_SOURCE_CHECK,
@@ -1363,75 +1664,53 @@ class LocalStateBackend {
1363
1664
  this.logger.debug({ msg: "initialized", dbLocation: db.location });
1364
1665
  }
1365
1666
  async getActiveOperations() {
1366
- const sublevel = this.getJsonSublevel("activeOperations");
1367
- const result = [];
1368
- for await (const operation of sublevel.values()) {
1369
- result.push(projectOperationSchema.parse(operation));
1370
- }
1371
- return result;
1667
+ const sublevel = this.getActiveOperationsSublevel();
1668
+ return this.getSublevelItems(sublevel, projectOperationSchema);
1372
1669
  }
1373
1670
  async getOperations(projectId, beforeOperationId) {
1374
- const sublevel = this.getJsonSublevel(`projects/${projectId}/operations`);
1375
- const result = [];
1671
+ const sublevel = this.getProjectOperationsSublevel(projectId);
1376
1672
  const pageSize = 10;
1377
- for await (const operation of sublevel.values({ lt: beforeOperationId, reverse: true })) {
1378
- result.push(projectOperationSchema.parse(operation));
1379
- if (result.length >= pageSize) {
1380
- break;
1381
- }
1382
- }
1383
- return result;
1673
+ return await this.getSublevelItems(sublevel, projectOperationSchema, pageSize, {
1674
+ lt: beforeOperationId,
1675
+ reverse: true
1676
+ });
1384
1677
  }
1385
1678
  async getInstanceStates(projectId) {
1386
- const sublevel = this.getJsonSublevel(`projects/${projectId}/instances`);
1387
- const result = [];
1388
- for await (const state of sublevel.values()) {
1389
- result.push(instanceStateSchema.parse(state));
1390
- }
1391
- return result;
1679
+ const sublevel = this.getProjectInstanceStatesSublevel(projectId);
1680
+ return await this.getSublevelItems(sublevel, instanceStateSchema);
1392
1681
  }
1393
1682
  async getInstanceState(projectId, instanceID) {
1394
- const sublevel = this.getJsonSublevel(`projects/${projectId}/instances`);
1395
- const state = await sublevel.get(instanceID);
1396
- if (!state) {
1397
- return undefined;
1398
- }
1399
- return instanceStateSchema.parse(state);
1683
+ const sublevel = this.getProjectInstanceStatesSublevel(projectId);
1684
+ return await this.getSublevelItem(sublevel, instanceStateSchema, instanceID);
1400
1685
  }
1401
1686
  async getAffectedInstanceStates(operationId) {
1402
- const sublevel = this.getJsonSublevel(`operations/${operationId}/instances`);
1403
- const result = [];
1404
- for await (const state of sublevel.values()) {
1405
- result.push(instanceStateSchema.parse(state));
1406
- }
1407
- return result;
1687
+ const sublevel = this.getOperationInstanceStatesSublevel(operationId);
1688
+ return await this.getSublevelItems(sublevel, instanceStateSchema);
1408
1689
  }
1409
1690
  async getInstanceLogs(operationId, instanceId) {
1410
- const sublevel = this.db.sublevel(`operations/${operationId}/instanceLogs/${instanceId}`);
1411
- const result = [];
1412
- for await (const line of sublevel.values()) {
1413
- result.push(line);
1414
- }
1415
- return result;
1691
+ const sublevel = this.getOperationInstanceLogsSublevel(operationId, instanceId);
1692
+ return await Array.fromAsync(sublevel.values());
1416
1693
  }
1417
1694
  async putOperation(operation) {
1418
- const sublevel = this.getJsonSublevel(`projects/${operation.projectId}/operations`);
1419
- const activeOperationsSublevel = this.getJsonSublevel("activeOperations");
1420
- if (operation.status !== "completed" && operation.status !== "failed") {
1695
+ this.validateItem(projectOperationSchema, operation);
1696
+ const operationsSublevel = this.getProjectOperationsSublevel(operation.projectId);
1697
+ const activeOperationsSublevel = this.getActiveOperationsSublevel();
1698
+ if (!isFinalOperationStatus(operation.status)) {
1421
1699
  await this.db.batch([
1422
- { type: "put", key: operation.id, value: operation, sublevel },
1700
+ { type: "put", key: operation.id, value: operation, sublevel: operationsSublevel },
1423
1701
  { type: "put", key: operation.id, value: operation, sublevel: activeOperationsSublevel }
1424
1702
  ]);
1425
1703
  } else {
1426
1704
  await this.db.batch([
1427
- { type: "put", key: operation.id, value: operation, sublevel },
1705
+ { type: "put", key: operation.id, value: operation, sublevel: operationsSublevel },
1428
1706
  { type: "del", key: operation.id, sublevel: activeOperationsSublevel }
1429
1707
  ]);
1430
1708
  }
1431
1709
  }
1432
1710
  async putAffectedInstanceStates(projectId, operationId, states) {
1433
- const sublevel = this.getJsonSublevel(`operations/${operationId}/instances`);
1434
- const projectSublevel = this.getJsonSublevel(`projects/${projectId}/instances`);
1711
+ this.validateArray(instanceStateSchema, states);
1712
+ const operationInstanceStatesSublevel = this.getOperationInstanceStatesSublevel(operationId);
1713
+ const projectInstanceStatesSublevel = this.getProjectInstanceStatesSublevel(projectId);
1435
1714
  await this.db.batch(
1436
1715
  // put the states to both the operation and project sublevels
1437
1716
  // denormalization is cool
@@ -1441,19 +1720,20 @@ class LocalStateBackend {
1441
1720
  type: "put",
1442
1721
  key: state.id,
1443
1722
  value: state,
1444
- sublevel
1723
+ sublevel: operationInstanceStatesSublevel
1445
1724
  },
1446
1725
  {
1447
1726
  type: "put",
1448
1727
  key: state.id,
1449
1728
  value: state,
1450
- sublevel: projectSublevel
1729
+ sublevel: projectInstanceStatesSublevel
1451
1730
  }
1452
1731
  ])
1453
1732
  );
1454
1733
  }
1455
1734
  async putInstanceStates(projectId, states) {
1456
- const sublevel = this.getJsonSublevel(`projects/${projectId}/instances`);
1735
+ this.validateArray(instanceStateSchema, states);
1736
+ const sublevel = this.getProjectInstanceStatesSublevel(projectId);
1457
1737
  await sublevel.batch(
1458
1738
  // as i told before, we update the instance states without operations
1459
1739
  // this method is used when upstream instance state changes are detected
@@ -1470,7 +1750,7 @@ class LocalStateBackend {
1470
1750
  if (sublevels.has(instanceId)) {
1471
1751
  continue;
1472
1752
  }
1473
- const sublevel = this.db.sublevel(`operations/${operationId}/instanceLogs/${instanceId}`);
1753
+ const sublevel = this.getOperationInstanceLogsSublevel(operationId, instanceId);
1474
1754
  sublevels.set(instanceId, sublevel);
1475
1755
  }
1476
1756
  await this.db.batch(
@@ -1483,22 +1763,18 @@ class LocalStateBackend {
1483
1763
  );
1484
1764
  }
1485
1765
  async getCompositeInstance(projectId, instanceId) {
1486
- const sublevel = this.getJsonSublevel(`projects/${projectId}/compositeInstances`);
1487
- const instance = await sublevel.get(instanceId);
1488
- if (!instance) {
1489
- return null;
1490
- }
1491
- return evaluatedCompositeInstanceSchema.parse(instance);
1766
+ const sublevel = this.getProjectCompositeInstancesSublevel(projectId);
1767
+ return this.getSublevelItem(sublevel, evaluatedCompositeInstanceSchema, instanceId);
1492
1768
  }
1493
1769
  async getCompositeInstanceInputHash(projectId, instanceId) {
1494
- const sublevel = this.db.sublevel(`projects/${projectId}/compositeInstanceInputHashes`);
1495
- return await sublevel.get(instanceId) ?? null;
1770
+ const sublevel = this.getProjectCompositeInstanceInputHashesSublevel(projectId);
1771
+ const inputHash = await sublevel.get(instanceId);
1772
+ return inputHash ?? null;
1496
1773
  }
1497
1774
  async putCompositeInstances(projectId, instances) {
1498
- const sublevel = this.getJsonSublevel(`projects/${projectId}/compositeInstances`);
1499
- const inputHashesSublevel = this.db.sublevel(
1500
- `projects/${projectId}/compositeInstanceInputHashes`
1501
- );
1775
+ this.validateArray(evaluatedCompositeInstanceSchema, instances);
1776
+ const sublevel = this.getProjectCompositeInstancesSublevel(projectId);
1777
+ const inputHashesSublevel = this.getProjectCompositeInstanceInputHashesSublevel(projectId);
1502
1778
  await this.db.batch(
1503
1779
  instances.flatMap((instance) => [
1504
1780
  {
@@ -1517,10 +1793,8 @@ class LocalStateBackend {
1517
1793
  );
1518
1794
  }
1519
1795
  async clearCompositeInstances(projectId, instanceIds) {
1520
- const sublevel = this.getJsonSublevel(`projects/${projectId}/compositeInstances`);
1521
- const inputHashesSublevel = this.db.sublevel(
1522
- `projects/${projectId}/compositeInstanceInputHashes`
1523
- );
1796
+ const sublevel = this.getProjectCompositeInstancesSublevel(projectId);
1797
+ const inputHashesSublevel = this.getProjectCompositeInstanceInputHashesSublevel(projectId);
1524
1798
  await this.db.batch(
1525
1799
  instanceIds.flatMap((instanceId) => [
1526
1800
  { type: "del", key: instanceId, sublevel },
@@ -1528,6 +1802,138 @@ class LocalStateBackend {
1528
1802
  ])
1529
1803
  );
1530
1804
  }
1805
+ async getTerminalSessions(projectId, instanceId) {
1806
+ const sublevel = this.getTerminalSessionsSublevel(projectId, instanceId);
1807
+ return await this.getSublevelItems(sublevel, terminalSessionSchema);
1808
+ }
1809
+ async getTerminalSession(projectId, instanceId, sessionId) {
1810
+ const sublevel = this.getTerminalSessionsSublevel(projectId, instanceId);
1811
+ return await this.getSublevelItem(sublevel, terminalSessionSchema, sessionId);
1812
+ }
1813
+ async putTerminalSession(projectId, instanceId, session) {
1814
+ this.validateItem(terminalSessionSchema, session);
1815
+ const sublevel = this.getTerminalSessionsSublevel(projectId, instanceId);
1816
+ await sublevel.put(session.id, session);
1817
+ }
1818
+ async getTerminalSessionHistory(sessionId) {
1819
+ const sublevel = this.getTerminalSessionHistorySublevel(sessionId);
1820
+ return await Array.fromAsync(sublevel.values());
1821
+ }
1822
+ async appendTerminalSessionHistory(lines) {
1823
+ const sublevels = /* @__PURE__ */ new Map();
1824
+ for (const [sessionId] of lines) {
1825
+ if (sublevels.has(sessionId)) {
1826
+ continue;
1827
+ }
1828
+ const sublevel = this.getTerminalSessionHistorySublevel(sessionId);
1829
+ sublevels.set(sessionId, sublevel);
1830
+ }
1831
+ await this.db.batch(
1832
+ lines.map(([sessionId, line]) => ({
1833
+ type: "put",
1834
+ key: uuidv7(),
1835
+ value: line,
1836
+ sublevel: sublevels.get(sessionId)
1837
+ }))
1838
+ );
1839
+ }
1840
+ async getSublevelItems(sublevel, schema, limit, options) {
1841
+ const result = [];
1842
+ const iterator = options ? sublevel.iterator(options) : sublevel.iterator();
1843
+ const invalidKeys = [];
1844
+ for await (const [key, value] of iterator) {
1845
+ const parseResult = schema.safeParse(value);
1846
+ if (!parseResult.success) {
1847
+ this.logger.warn({
1848
+ msg: "failed to parse item, it will be deleted",
1849
+ error: parseResult.error,
1850
+ sublevel: sublevel.prefix,
1851
+ key
1852
+ });
1853
+ invalidKeys.push(key);
1854
+ continue;
1855
+ }
1856
+ result.push(parseResult.data);
1857
+ if (limit && result.length >= limit) {
1858
+ break;
1859
+ }
1860
+ }
1861
+ if (invalidKeys.length > 0) {
1862
+ this.logger.info({
1863
+ msg: "deleting invalid items",
1864
+ sublevel: sublevel.prefix,
1865
+ keyCount: invalidKeys.length
1866
+ });
1867
+ await sublevel.batch(invalidKeys.map((key) => ({ type: "del", key })));
1868
+ }
1869
+ return result;
1870
+ }
1871
+ async getSublevelItem(sublevel, schema, key) {
1872
+ const value = await sublevel.get(key);
1873
+ if (!value) {
1874
+ return null;
1875
+ }
1876
+ const parseResult = schema.safeParse(value);
1877
+ if (!parseResult.success) {
1878
+ this.logger.warn({
1879
+ msg: "failed to parse item, it will be deleted",
1880
+ error: parseResult.error,
1881
+ sublevel: sublevel.prefix,
1882
+ key
1883
+ });
1884
+ await sublevel.del(key);
1885
+ return null;
1886
+ }
1887
+ return parseResult.data;
1888
+ }
1889
+ validateItem(schema, item) {
1890
+ const parseResult = schema.safeParse(item);
1891
+ if (!parseResult.success) {
1892
+ this.logger.error({
1893
+ msg: "failed to validate item",
1894
+ error: parseResult.error,
1895
+ item
1896
+ });
1897
+ throw new Error(`Failed to validate item: ${parseResult.error.errors[0].message}`, {
1898
+ cause: parseResult.error
1899
+ });
1900
+ }
1901
+ }
1902
+ validateArray(schema, items) {
1903
+ for (const item of items) {
1904
+ this.validateItem(schema, item);
1905
+ }
1906
+ }
1907
+ getActiveOperationsSublevel() {
1908
+ return this.getJsonSublevel("activeOperations");
1909
+ }
1910
+ getProjectOperationsSublevel(projectId) {
1911
+ return this.getJsonSublevel(`projects/${projectId}/operations`);
1912
+ }
1913
+ getProjectCompositeInstancesSublevel(projectId) {
1914
+ return this.getJsonSublevel(`projects/${projectId}/compositeInstances`);
1915
+ }
1916
+ getProjectCompositeInstanceInputHashesSublevel(projectId) {
1917
+ return this.getStringSublevel(`projects/${projectId}/compositeInstanceInputHashes`);
1918
+ }
1919
+ getProjectInstanceStatesSublevel(projectId) {
1920
+ return this.getJsonSublevel(`projects/${projectId}/instanceStates`);
1921
+ }
1922
+ getOperationInstanceStatesSublevel(operationId) {
1923
+ return this.getJsonSublevel(`operations/${operationId}/instanceStates`);
1924
+ }
1925
+ getOperationInstanceLogsSublevel(operationId, instanceId) {
1926
+ return this.getStringSublevel(`operations/${operationId}/instances/${instanceId}/logs`);
1927
+ }
1928
+ getTerminalSessionsSublevel(projectId, instanceId) {
1929
+ return this.getJsonSublevel(`projects/${projectId}/instances/${instanceId}/terminalSessions`);
1930
+ }
1931
+ getTerminalSessionHistorySublevel(sessionId) {
1932
+ return this.getStringSublevel(`terminalSessions/${sessionId}/history`);
1933
+ }
1934
+ getStringSublevel(path) {
1935
+ return this.db.sublevel(path, { valueEncoding: "utf8" });
1936
+ }
1531
1937
  getJsonSublevel(path) {
1532
1938
  return this.db.sublevel(path, { valueEncoding: "json" });
1533
1939
  }
@@ -1655,19 +2061,22 @@ class RuntimeOperation {
1655
2061
  }
1656
2062
  abortController = new AbortController();
1657
2063
  instanceMap = /* @__PURE__ */ new Map();
2064
+ hubMap = /* @__PURE__ */ new Map();
2065
+ resolvedInstanceInputs = /* @__PURE__ */ new Map();
1658
2066
  compositeInstanceLock = new BetterLock();
1659
2067
  compositeInstanceMap = /* @__PURE__ */ new Map();
1660
2068
  initialStatusMap = /* @__PURE__ */ new Map();
1661
2069
  stateMap = /* @__PURE__ */ new Map();
1662
2070
  instancePromiseMap = /* @__PURE__ */ new Map();
1663
2071
  childrenStateMap = /* @__PURE__ */ new Map();
2072
+ dependentStateMap = /* @__PURE__ */ new Map();
1664
2073
  library;
1665
- inputHashCalculator;
2074
+ resolveInputHash;
1666
2075
  async operateSafe() {
1667
2076
  try {
1668
2077
  await this.operate();
1669
2078
  } catch (error) {
1670
- if (isAbortError(error)) {
2079
+ if (RuntimeOperation.isAbortError(error)) {
1671
2080
  this.logger.info("the operation was cancelled");
1672
2081
  this.operation.status = "cancelled";
1673
2082
  await this.updateOperation();
@@ -1692,27 +2101,75 @@ class RuntimeOperation {
1692
2101
  if (state.status !== "pending") {
1693
2102
  continue;
1694
2103
  }
1695
- const initialStatus = this.initialStatusMap.get(state.id);
2104
+ let initialStatus = this.initialStatusMap.get(state.id);
2105
+ let error = state.error;
2106
+ if (initialStatus === "destroying" || initialStatus === "pending" || initialStatus === "refreshing" || initialStatus === "updating") {
2107
+ initialStatus = "error";
2108
+ error ??= "unexpected progressing instance status";
2109
+ }
1696
2110
  if (initialStatus) {
1697
- this.updateInstanceState({ id: state.id, status: initialStatus });
2111
+ this.updateInstanceState({ id: state.id, status: initialStatus, error });
1698
2112
  }
1699
2113
  }
1700
2114
  }
1701
2115
  async operate() {
1702
2116
  this.logger.info("starting operation");
1703
- const allInstances = await this.projectBackend.getInstances(this.operation.projectId);
2117
+ const { instances: projectInstances, hubs } = await this.projectBackend.getProject(
2118
+ this.operation.projectId
2119
+ );
1704
2120
  this.abortController.signal.throwIfAborted();
1705
2121
  const states = await this.stateBackend.getInstanceStates(this.operation.projectId);
1706
2122
  this.abortController.signal.throwIfAborted();
2123
+ this.library = await this.libraryBackend.loadLibrary();
2124
+ this.logger.info("library ready");
2125
+ this.abortController.signal.throwIfAborted();
2126
+ const allInstances = projectInstances.filter((instance) => {
2127
+ return instance.type in this.library.components;
2128
+ });
2129
+ const inputResolverNodes = /* @__PURE__ */ new Map();
2130
+ for (const hub of hubs) {
2131
+ this.hubMap.set(hub.id, hub);
2132
+ inputResolverNodes.set(`hub:${hub.id}`, { kind: "hub", hub });
2133
+ }
1707
2134
  for (const instance of allInstances) {
1708
2135
  this.instanceMap.set(instance.id, instance);
2136
+ inputResolverNodes.set(`instance:${instance.id}`, {
2137
+ kind: "instance",
2138
+ instance,
2139
+ component: this.library.components[instance.type]
2140
+ });
2141
+ }
2142
+ const resolveInputs = createInputResolver(
2143
+ inputResolverNodes,
2144
+ this.logger.child({ resolver: "input-resolver" })
2145
+ );
2146
+ for (const instance of allInstances) {
2147
+ const output = await resolveInputs(`instance:${instance.id}`);
2148
+ if (output.kind !== "instance") {
2149
+ throw new Error("Unexpected output kind, expected instance");
2150
+ }
2151
+ this.resolvedInstanceInputs.set(instance.id, output.resolvedInputs);
1709
2152
  }
1710
- this.inputHashCalculator = new InputHashCalculator(this.instanceMap);
1711
2153
  for (const state of states) {
1712
2154
  this.stateMap.set(state.id, state);
1713
2155
  this.initialStatusMap.set(state.id, state.status);
1714
2156
  this.tryAddStateToParent(state);
2157
+ this.addDependentState(state);
2158
+ }
2159
+ const inputHashNodes = /* @__PURE__ */ new Map();
2160
+ for (const instance of allInstances) {
2161
+ inputHashNodes.set(instance.id, {
2162
+ instance,
2163
+ resolvedInputs: this.resolvedInstanceInputs.get(instance.id),
2164
+ state: this.stateMap.get(instance.id),
2165
+ sourceHash: void 0
2166
+ // TODO: implement source hash
2167
+ });
1715
2168
  }
2169
+ this.resolveInputHash = createInputHashResolver(
2170
+ inputHashNodes,
2171
+ this.logger.child({ resolver: "input-hash-resolver" })
2172
+ );
1716
2173
  if (this.operation.type === "update") {
1717
2174
  await this.extendWithNotCreatedDependencies();
1718
2175
  await this.updateOperation();
@@ -1726,26 +2183,25 @@ class RuntimeOperation {
1726
2183
  });
1727
2184
  if (this.operation.type === "evaluate") {
1728
2185
  this.logger.info("evaluating instances");
1729
- await this.evaluateCompositeInstances(allInstances);
2186
+ await this.evaluateCompositeInstances();
1730
2187
  } else {
1731
- this.library = await this.libraryBackend.loadLibrary();
1732
- this.logger.info("library ready");
1733
- this.abortController.signal.throwIfAborted();
1734
2188
  const promises = [];
1735
2189
  for (const instanceId of this.operation.instanceIds) {
1736
2190
  promises.push(this.getInstancePromiseForOperation(instanceId));
1737
2191
  }
1738
- this.logger.info("all units started");
2192
+ this.logger.info("all units operations started");
1739
2193
  this.operation.status = "running";
1740
2194
  await this.updateOperation();
1741
2195
  await Promise.all(promises);
1742
2196
  this.logger.info("all units completed");
1743
2197
  }
1744
2198
  this.operation.status = "completed";
2199
+ this.operation.error = null;
1745
2200
  await this.updateOperation();
1746
2201
  this.logger.info("operation completed");
1747
2202
  }
1748
2203
  cancel() {
2204
+ this.logger.info("cancelling operation");
1749
2205
  this.abortController.abort();
1750
2206
  }
1751
2207
  getInstancePromiseForOperation(instanceId) {
@@ -1771,7 +2227,7 @@ class RuntimeOperation {
1771
2227
  return this.recreateUnit(instance, component);
1772
2228
  }
1773
2229
  case "destroy": {
1774
- return this.destroyUnit(instance.id);
2230
+ return this.destroyUnit(instance.id, component);
1775
2231
  }
1776
2232
  case "refresh": {
1777
2233
  return this.refreshUnit(instance.id);
@@ -1809,7 +2265,7 @@ class RuntimeOperation {
1809
2265
  status: "created"
1810
2266
  });
1811
2267
  } catch (error) {
1812
- if (isAbortError(error)) {
2268
+ if (RuntimeOperation.isAbortError(error)) {
1813
2269
  this.updateInstanceState({
1814
2270
  id: instance.id,
1815
2271
  status: initialStatus
@@ -1841,7 +2297,7 @@ class RuntimeOperation {
1841
2297
  continue;
1842
2298
  }
1843
2299
  logger.info("waiting for dependency", { dependencyId: dependency.id });
1844
- dependencyPromises.push(this.updateUnit(dependency, component));
2300
+ dependencyPromises.push(this.getInstancePromiseForOperation(dependency.id));
1845
2301
  }
1846
2302
  await Promise.all(dependencyPromises);
1847
2303
  this.abortController.signal.throwIfAborted();
@@ -1869,13 +2325,46 @@ class RuntimeOperation {
1869
2325
  finalStatuses: ["created", "error"]
1870
2326
  });
1871
2327
  await this.watchStateStream(stream);
1872
- const inputHash = await this.inputHashCalculator.calculate(instance);
2328
+ const inputHash = await this.resolveInputHash(instance.id);
1873
2329
  this.updateInstanceState({ id: instance.id, inputHash });
1874
2330
  logger.debug("input hash after update", { inputHash });
1875
2331
  logger.info("unit updated");
1876
2332
  });
1877
2333
  }
1878
- async destroyUnit(instanceId) {
2334
+ async processBeforeDestroyTriggers(state, component, logger) {
2335
+ const instance = this.instanceMap.get(state.id);
2336
+ if (!instance) {
2337
+ throw new Error(`Instance not found: ${state.id}`);
2338
+ }
2339
+ const triggers = state.triggers.filter((trigger) => trigger.spec.type === "before-destroy");
2340
+ if (triggers.length === 0) {
2341
+ return;
2342
+ }
2343
+ const invokedTriggers = triggers.map((trigger) => ({ name: trigger.name }));
2344
+ logger.info("updating unit to process before-destroy triggers...");
2345
+ const secrets = await this.secretBackend.get(this.operation.projectId, instance.id);
2346
+ this.abortController.signal.throwIfAborted();
2347
+ logger.debug("secrets loaded", { count: Object.keys(secrets).length });
2348
+ await this.runnerBackend.update({
2349
+ projectId: this.operation.projectId,
2350
+ instanceType: instance.type,
2351
+ instanceName: instance.name,
2352
+ config: await this.prepareUnitConfig(instance, invokedTriggers),
2353
+ secrets: mapValues(secrets, (value) => valueToString(value)),
2354
+ source: this.resolveUnitSource(component),
2355
+ signal: this.abortController.signal
2356
+ });
2357
+ logger.debug("unit update requested");
2358
+ const stream = this.runnerBackend.watch({
2359
+ projectId: this.operation.projectId,
2360
+ instanceType: instance.type,
2361
+ instanceName: instance.name,
2362
+ finalStatuses: ["created", "error"]
2363
+ });
2364
+ await this.watchStateStream(stream);
2365
+ logger.debug("before-destroy triggers processed");
2366
+ }
2367
+ async destroyUnit(instanceId, component) {
1879
2368
  const logger = this.logger.child({ instanceId });
1880
2369
  return this.getInstancePromise(instanceId, async () => {
1881
2370
  this.updateInstanceState({
@@ -1891,12 +2380,13 @@ class RuntimeOperation {
1891
2380
  return;
1892
2381
  }
1893
2382
  const dependentPromises = [];
1894
- for (const dependentKey of state.dependentKeys ?? []) {
1895
- logger.info("destroying dependent unit", { dependentKey });
1896
- dependentPromises.push(this.destroyUnit(dependentKey));
2383
+ const dependents = this.dependentStateMap.get(instanceId) ?? [];
2384
+ for (const dependent of dependents) {
2385
+ dependentPromises.push(this.getInstancePromiseForOperation(dependent.id));
1897
2386
  }
1898
2387
  await Promise.all(dependentPromises);
1899
2388
  this.abortController.signal.throwIfAborted();
2389
+ await this.processBeforeDestroyTriggers(state, component, logger);
1900
2390
  logger.info("destroying unit...");
1901
2391
  const [type, name] = parseInstanceId(instanceId);
1902
2392
  await this.runnerBackend.destroy({
@@ -1926,6 +2416,12 @@ class RuntimeOperation {
1926
2416
  currentResourceCount: 0,
1927
2417
  totalResourceCount: 0
1928
2418
  });
2419
+ const state = this.stateMap.get(instance.id);
2420
+ if (!state) {
2421
+ logger.warn("state not found for unit, but recreate was requested");
2422
+ return;
2423
+ }
2424
+ await this.processBeforeDestroyTriggers(state, component, logger);
1929
2425
  logger.info("destroying unit...");
1930
2426
  await this.runnerBackend.destroy({
1931
2427
  projectId: this.operation.projectId,
@@ -2012,26 +2508,28 @@ class RuntimeOperation {
2012
2508
  throw new Error(`The operation on unit "${statePatch.id}" failed: ${statePatch.error}`);
2013
2509
  }
2014
2510
  }
2015
- async prepareUnitConfig(instance) {
2511
+ async prepareUnitConfig(instance, invokedTriggers = []) {
2016
2512
  const config = {};
2017
- for (const [key, value] of Object.entries(instance.args)) {
2513
+ for (const [key, value] of Object.entries(instance.args ?? {})) {
2018
2514
  config[key] = valueToString(value);
2019
2515
  }
2020
- for (const [key, value] of Object.entries(instance.inputs)) {
2021
- config[`input.${key}`] = JSON.stringify(await this.resolveInstanceInput(value));
2516
+ const instanceInputs = this.resolvedInstanceInputs.get(instance.id) ?? {};
2517
+ for (const [key, value] of Object.entries(instanceInputs)) {
2518
+ const resolvedInput = await this.resolveInstanceInput(value.map((input) => input.input));
2519
+ config[`input.${key}`] = JSON.stringify(resolvedInput);
2022
2520
  }
2521
+ config["$invokedTriggers"] = JSON.stringify(invokedTriggers);
2023
2522
  return config;
2024
2523
  }
2025
2524
  async resolveInstanceInput(input) {
2026
- if (Array.isArray(input)) {
2027
- return (await Promise.all(input.map((x) => this.resolveInstanceInput(x)))).flat();
2028
- }
2029
- return await this.resolveSingleInstanceInput(input);
2525
+ const promises = input.map((input2) => this.resolveSingleInstanceInput(input2));
2526
+ const results = await Promise.all(promises);
2527
+ return results.flat();
2030
2528
  }
2031
2529
  async resolveSingleInstanceInput(input) {
2032
2530
  const loadedInstance = this.instanceMap.get(input.instanceId);
2033
2531
  if (loadedInstance && !loadedInstance.parentId) {
2034
- return input;
2532
+ return [input];
2035
2533
  }
2036
2534
  const parentInstance = await this.loadCompositeInstance(input.instanceId);
2037
2535
  const parentOutput = parentInstance.instance.outputs?.[input.output];
@@ -2061,10 +2559,10 @@ class RuntimeOperation {
2061
2559
  return compositeInstance;
2062
2560
  });
2063
2561
  }
2064
- async evaluateCompositeInstances(allInstances) {
2562
+ async evaluateCompositeInstances() {
2065
2563
  await this.projectLock.lockInstances(this.operation.instanceIds, async () => {
2066
2564
  const compositeInstances = await this.libraryBackend.evaluateCompositeInstances(
2067
- allInstances,
2565
+ Array.from(this.instanceMap.values()),
2068
2566
  this.operation.instanceIds
2069
2567
  );
2070
2568
  this.abortController.signal.throwIfAborted();
@@ -2079,7 +2577,7 @@ class RuntimeOperation {
2079
2577
  await Promise.all(
2080
2578
  compositeInstances.map(async (instance) => ({
2081
2579
  ...instance,
2082
- inputHash: await this.inputHashCalculator.calculate(instance.instance)
2580
+ inputHash: await this.resolveInputHash(instance.instance.id)
2083
2581
  }))
2084
2582
  )
2085
2583
  );
@@ -2090,12 +2588,15 @@ class RuntimeOperation {
2090
2588
  await this.stateBackend.putOperation(this.operation);
2091
2589
  }
2092
2590
  updateInstanceState(patch) {
2093
- if (patch.message) {
2094
- this.persistLogs.call([patch.id, patch.message]);
2095
- this.instanceLogsEE.emit(`${this.operation.id}/${patch.id}`, patch.message);
2591
+ if (!patch.id) {
2592
+ throw new Error("The ID of the instance state is required.");
2593
+ }
2594
+ if (patch.logLine) {
2595
+ this.persistLogs.call([patch.id, patch.logLine]);
2596
+ this.instanceLogsEE.emit(`${this.operation.id}/${patch.id}`, patch.logLine);
2096
2597
  return;
2097
2598
  }
2098
- const state = applyInstanceStatePatch(this.stateMap, patch);
2599
+ const state = applyPartialInstanceState(this.stateMap, patch);
2099
2600
  this.persistStates.call(state);
2100
2601
  if (patch.secrets) {
2101
2602
  this.persistSecrets.call([patch.id, patch.secrets]);
@@ -2110,6 +2611,20 @@ class RuntimeOperation {
2110
2611
  children.push(state);
2111
2612
  this.childrenStateMap.set(state.parentId, children);
2112
2613
  }
2614
+ addDependentState(state) {
2615
+ const instance = this.instanceMap.get(state.id);
2616
+ if (!instance) {
2617
+ return;
2618
+ }
2619
+ const instanceInputs = this.resolvedInstanceInputs.get(state.id) ?? {};
2620
+ for (const inputs of Object.values(instanceInputs)) {
2621
+ for (const input of inputs) {
2622
+ const dependents = this.dependentStateMap.get(input.input.instanceId) ?? [];
2623
+ dependents.push(state);
2624
+ this.dependentStateMap.set(input.input.instanceId, dependents);
2625
+ }
2626
+ }
2627
+ }
2113
2628
  async extendWithNotCreatedDependencies() {
2114
2629
  const instanceIdsSet = /* @__PURE__ */ new Set();
2115
2630
  const traverse = async (instanceId) => {
@@ -2120,17 +2635,14 @@ class RuntimeOperation {
2120
2635
  if (!instance) {
2121
2636
  return;
2122
2637
  }
2123
- for (const input of Object.values(instance.inputs)) {
2124
- if (Array.isArray(input)) {
2125
- for (const { instanceId: instanceId2 } of input) {
2126
- await traverse(instanceId2);
2127
- }
2128
- } else {
2129
- await traverse(input.instanceId);
2638
+ const instanceInputs = this.resolvedInstanceInputs.get(instance.id) ?? {};
2639
+ for (const inputs of Object.values(instanceInputs)) {
2640
+ for (const input of inputs) {
2641
+ await traverse(input.input.instanceId);
2130
2642
  }
2131
2643
  }
2132
2644
  const state = this.stateMap.get(instance.id);
2133
- const expectedInputHash = await this.inputHashCalculator.calculate(instance);
2645
+ const expectedInputHash = await this.resolveInputHash(instance.id);
2134
2646
  if (state?.status !== "created" || state.inputHash !== expectedInputHash) {
2135
2647
  instanceIdsSet.add(instanceId);
2136
2648
  }
@@ -2151,8 +2663,9 @@ class RuntimeOperation {
2151
2663
  if (!state || state.status === "not_created") {
2152
2664
  return;
2153
2665
  }
2154
- for (const dependent of state.dependentKeys ?? []) {
2155
- traverse(dependent);
2666
+ const dependents = this.dependentStateMap.get(instanceKey) ?? [];
2667
+ for (const dependent of dependents) {
2668
+ traverse(dependent.id);
2156
2669
  instanceIdsSet.add(instanceKey);
2157
2670
  }
2158
2671
  };
@@ -2192,16 +2705,10 @@ class RuntimeOperation {
2192
2705
  }
2193
2706
  getInstanceDependencies(instance) {
2194
2707
  const dependencies = [];
2195
- for (const input of Object.values(instance.inputs)) {
2196
- if (Array.isArray(input)) {
2197
- for (const { instanceId } of input) {
2198
- const dependency = this.instanceMap.get(instanceId);
2199
- if (dependency) {
2200
- dependencies.push(dependency);
2201
- }
2202
- }
2203
- } else {
2204
- const dependency = this.instanceMap.get(input.instanceId);
2708
+ const instanceInputs = this.resolvedInstanceInputs.get(instance.id) ?? {};
2709
+ for (const inputs of Object.values(instanceInputs)) {
2710
+ for (const input of inputs) {
2711
+ const dependency = this.instanceMap.get(input.input.instanceId);
2205
2712
  if (dependency) {
2206
2713
  dependencies.push(dependency);
2207
2714
  }
@@ -2210,22 +2717,14 @@ class RuntimeOperation {
2210
2717
  return dependencies;
2211
2718
  }
2212
2719
  resolveUnitSource(component) {
2213
- if (!component.source) {
2720
+ if (!component.source || component.source.type === "local") {
2214
2721
  return {
2215
2722
  type: "local",
2216
- path: component.type.replaceAll(".", "/")
2723
+ // auto-generate path for local units
2724
+ path: component.source?.path ?? component.type.split(".").join("/")
2217
2725
  };
2218
2726
  }
2219
- return {
2220
- ...component.source,
2221
- path: component.source.path ?? component.type.replaceAll(".", "/")
2222
- };
2223
- }
2224
- static accumulator(values, value) {
2225
- if (!values) {
2226
- return [value];
2227
- }
2228
- return [...values, value];
2727
+ return component.source;
2229
2728
  }
2230
2729
  persistStates = funnel(
2231
2730
  (states) => {
@@ -2240,18 +2739,18 @@ class RuntimeOperation {
2240
2739
  minQuietPeriodMs: 100,
2241
2740
  maxBurstDurationMs: 1e3,
2242
2741
  triggerAt: "end",
2243
- reducer: RuntimeOperation.accumulator
2742
+ reducer: arrayAccumulator
2244
2743
  }
2245
2744
  );
2246
2745
  persistLogs = funnel(
2247
2746
  (entries) => {
2248
- this.logger.debug({ msg: "persisting logs", count: entries.length });
2747
+ this.logger.trace({ msg: "persisting logs", count: entries.length });
2249
2748
  void this.stateBackend.appendInstanceLogs(this.operation.id, entries);
2250
2749
  },
2251
2750
  {
2252
2751
  minQuietPeriodMs: 100,
2253
2752
  maxBurstDurationMs: 200,
2254
- reducer: RuntimeOperation.accumulator
2753
+ reducer: arrayAccumulator
2255
2754
  }
2256
2755
  );
2257
2756
  persistSecrets = funnel(
@@ -2267,9 +2766,22 @@ class RuntimeOperation {
2267
2766
  {
2268
2767
  minQuietPeriodMs: 100,
2269
2768
  maxBurstDurationMs: 200,
2270
- reducer: RuntimeOperation.accumulator
2769
+ reducer: arrayAccumulator
2271
2770
  }
2272
2771
  );
2772
+ static abortMessagePatterns = [
2773
+ "Operation aborted",
2774
+ "Command was killed with SIGINT"
2775
+ ];
2776
+ static isAbortError(err) {
2777
+ if (isAbortError(err)) {
2778
+ return true;
2779
+ }
2780
+ if (err instanceof Error) {
2781
+ return RuntimeOperation.abortMessagePatterns.some((pattern) => err.message.includes(pattern));
2782
+ }
2783
+ return false;
2784
+ }
2273
2785
  }
2274
2786
 
2275
2787
  class OperationManager {
@@ -2395,7 +2907,8 @@ class OperationManager {
2395
2907
  const activeOperations = await stateBackend.getActiveOperations();
2396
2908
  for (const operation of activeOperations) {
2397
2909
  logger.info({ msg: "relaunching operation", operationId: operation.id });
2398
- operator.startOperation(operation);
2910
+ operation.status = "cancelled";
2911
+ await stateBackend.putOperation(operation);
2399
2912
  }
2400
2913
  return operator;
2401
2914
  }
@@ -2420,7 +2933,8 @@ async function createServices({
2420
2933
  config ??= await loadConfig();
2421
2934
  logger ??= pino({
2422
2935
  name: config.HIGHSTATE_BACKEND_LOGGER_NAME,
2423
- level: config.HIGHSTATE_BACKEND_LOGGER_LEVEL
2936
+ level: config.HIGHSTATE_BACKEND_LOGGER_LEVEL,
2937
+ errorKey: "error"
2424
2938
  });
2425
2939
  const localPulumiHost = LocalPulumiHost.create(logger);
2426
2940
  const projectLockManager = new ProjectLockManager();
@@ -2430,7 +2944,7 @@ async function createServices({
2430
2944
  runnerBackend ??= await createRunnerBackend(config, localPulumiHost);
2431
2945
  projectBackend ??= await createProjectBackend(config);
2432
2946
  terminalBackend ??= createTerminalBackend(config, logger);
2433
- terminalManager ??= TerminalManager.create(terminalBackend, logger);
2947
+ terminalManager ??= TerminalManager.create(terminalBackend, stateBackend, runnerBackend, logger);
2434
2948
  operationManager ??= await OperationManager.create(
2435
2949
  runnerBackend,
2436
2950
  stateBackend,