@rigkit/engine 0.2.7 → 0.2.9

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/src/engine.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { isRigkitConfig, isProviderDefinition, isWorkflowNode } from "./authoring.ts";
5
+ import { runWithStepConsole, type ConsoleLevel, type StepConsoleSink } from "./console-intercept.ts";
5
6
  import { loadDotEnv } from "./env-file.ts";
6
7
  import { hash } from "./hash.ts";
7
8
  import {
@@ -36,8 +37,10 @@ import type {
36
37
  ProviderRuntimeMap,
37
38
  WorkflowInputFieldDefinition,
38
39
  WorkflowDefinition,
40
+ WorkflowCacheScope,
39
41
  WorkflowEvent,
40
42
  WorkflowLogStream,
43
+ WorkflowNodeKind,
41
44
  WorkflowNodeDefinition,
42
45
  WorkflowOperationDefinition,
43
46
  WorkflowPlan,
@@ -59,6 +62,7 @@ export type CreateDevMachineEngineOptions = {
59
62
  providers?: BaseProviderPlugin[];
60
63
  providerFactory?: ProviderFactory;
61
64
  stateFactory?: StateServiceFactory;
65
+ globalFragmentStateLocator?: GlobalFragmentStateLocator;
62
66
  hostStorageDir?: string;
63
67
  hostStorageFactory?: ProviderHostStorageFactory;
64
68
  interaction?: {
@@ -69,6 +73,18 @@ export type CreateDevMachineEngineOptions = {
69
73
 
70
74
  export type { InteractionPresenter, InteractionPresentationRequest };
71
75
 
76
+ export type GlobalFragmentStateLocator = (fragment: GlobalFragmentStateLocationInput) =>
77
+ | string
78
+ | { statePath: string };
79
+
80
+ export type GlobalFragmentStateLocationInput = {
81
+ hash: string;
82
+ workflow: string;
83
+ nodePath: string;
84
+ nodeName: string;
85
+ nodeKind: WorkflowNodeKind;
86
+ };
87
+
72
88
  export type EngineLoadResult = {
73
89
  workflow: LoadedWorkflow;
74
90
  workflows: LoadedWorkflow[];
@@ -85,6 +101,30 @@ export type EngineProjectInfo = {
85
101
  workflow?: WorkflowSummary;
86
102
  };
87
103
 
104
+ export type EngineCacheScope = WorkflowCacheScope;
105
+
106
+ export type EngineCacheEntry = {
107
+ scope: EngineCacheScope;
108
+ workflow: string;
109
+ nodePath: string;
110
+ nodeName: string;
111
+ nodeKind: string;
112
+ runId: string;
113
+ invalidated: boolean;
114
+ createdAt: string;
115
+ fragmentHash?: string;
116
+ };
117
+
118
+ export type EngineCacheList = {
119
+ entries: EngineCacheEntry[];
120
+ };
121
+
122
+ export type EngineCacheClearScope = EngineCacheScope | "all";
123
+
124
+ export type EngineCacheClearResult = {
125
+ deleted: number;
126
+ };
127
+
88
128
  export type WorkflowSummary = {
89
129
  name: string;
90
130
  providers: string[];
@@ -165,6 +205,15 @@ type EvaluationState = {
165
205
  type EvaluationPreviousTask = {
166
206
  name: string;
167
207
  path: string;
208
+ cache: EvaluationCacheTarget;
209
+ };
210
+
211
+ type EvaluationCacheTarget = {
212
+ scope: WorkflowCacheScope;
213
+ workflow: string;
214
+ nodePath: string;
215
+ state: StateService;
216
+ fragmentHash?: string;
168
217
  };
169
218
 
170
219
  type EvaluationResult = EvaluationState & {
@@ -177,10 +226,14 @@ type EvaluateNodeInput = {
177
226
  providers: ProviderControllers;
178
227
  providerFingerprint: string;
179
228
  mode: EvaluationMode;
229
+ cache: EvaluationCacheTarget;
230
+ configStack: JsonObject[];
180
231
  state: EvaluationState;
181
232
  prefix: string[];
233
+ cachePrefix: string[];
182
234
  root: boolean;
183
235
  suppressSequenceName?: string;
236
+ suppressCacheSequenceName?: string;
184
237
  planNodes: WorkflowPlanNode[];
185
238
  index: { value: number };
186
239
  };
@@ -221,6 +274,8 @@ class StepInvalidationRestart extends Error {
221
274
 
222
275
  let configImportCounter = 0;
223
276
 
277
+ // The engine owns the workflow graph, cache, and event emission for one
278
+ // project. The runtime daemon hosts a single long-lived instance per project.
224
279
  export class DevMachineEngine {
225
280
  private readonly projectDir: string;
226
281
  private readonly configPath: string;
@@ -229,6 +284,9 @@ export class DevMachineEngine {
229
284
  private providers: BaseProviderPlugin[];
230
285
  private readonly providerFactory: ProviderFactory;
231
286
  private readonly stateFactory: StateServiceFactory;
287
+ private readonly globalFragmentStateLocator: GlobalFragmentStateLocator;
288
+ private readonly globalFragmentStates = new Map<string, { input: GlobalFragmentStateLocationInput; state: StateService }>();
289
+ private evaluationFragmentHashes: Set<string> | undefined;
232
290
  private readonly hostStorageDir: string;
233
291
  private readonly hostStorageFactory: ProviderHostStorageFactory;
234
292
  private readonly providerHostStorage = new Map<string, ReturnType<ProviderHostStorageFactory>>();
@@ -247,6 +305,9 @@ export class DevMachineEngine {
247
305
  this.providers = options.providers ?? [];
248
306
  this.providerFactory = options.providerFactory ?? ((input) => this.createProviderFromPlugin(input));
249
307
  this.stateFactory = options.stateFactory ?? createStateStore;
308
+ this.globalFragmentStateLocator = options.globalFragmentStateLocator ?? ((fragment) => ({
309
+ statePath: join(this.projectDir, ".rigkit", "fragments", fragment.hash, "state.sqlite"),
310
+ }));
250
311
  this.hostStorageDir = options.hostStorageDir ? resolve(options.hostStorageDir) : defaultProviderHostStorageDir();
251
312
  this.hostStorageFactory = options.hostStorageFactory ?? createFileProviderHostStorage;
252
313
  this.interactionPresenter = options.interaction?.present ?? defaultInteractionPresenter;
@@ -268,7 +329,7 @@ export class DevMachineEngine {
268
329
 
269
330
  if (!existsSync(this.configPath)) {
270
331
  throw new Error(
271
- `No Rigkit config found at ${this.configPath}. Create one with "rig init" or pass --config <file>.`,
332
+ `No Rigkit config found at ${this.configPath}. Create one with "rig init" or pass -config=<file>.`,
272
333
  );
273
334
  }
274
335
 
@@ -534,6 +595,9 @@ export class DevMachineEngine {
534
595
  stringField({ name: "name", required: true }),
535
596
  ],
536
597
  cli: {
598
+ positionals: [
599
+ { name: "name", index: 0 },
600
+ ],
537
601
  options: [
538
602
  { name: "workflow", flag: "--workflow" },
539
603
  { name: "name", flag: "--name", required: true },
@@ -590,6 +654,124 @@ export class DevMachineEngine {
590
654
  return this.getStateService().listNodeRuns();
591
655
  }
592
656
 
657
+ async listCache(input: {
658
+ workflow?: string;
659
+ machine?: string;
660
+ includeUnreachable?: boolean;
661
+ } = {}): Promise<EngineCacheList> {
662
+ const workflow = this.getWorkflow(input.workflow ?? input.machine);
663
+ const providers = await this.createProviders(workflow);
664
+ const evaluated = await this.evaluate({
665
+ workflow,
666
+ providers,
667
+ mode: "plan",
668
+ });
669
+
670
+ // The plan tells us which row (by runId) would satisfy each cached node
671
+ // under the *current* code. Those are the only cache rows that matter.
672
+ const reachableRunIds = new Set<string>();
673
+ for (const node of evaluated.plan.nodes) {
674
+ if (node.status === "cached" && node.runId) reachableRunIds.add(node.runId);
675
+ }
676
+
677
+ if (input.includeUnreachable) {
678
+ const entries: EngineCacheEntry[] = [
679
+ ...this.getStateService()
680
+ .listNodeRuns()
681
+ .filter((run) => run.workflow === workflow.name)
682
+ .map((run) => cacheEntryForRun(run, "local")),
683
+ ];
684
+ for (const fragmentHash of evaluated.fragments) {
685
+ const fragment = this.globalFragmentStates.get(fragmentHash);
686
+ if (!fragment) continue;
687
+ entries.push(
688
+ ...fragment.state
689
+ .listNodeRuns()
690
+ .map((run) => cacheEntryForRun(run, "global", fragmentHash, workflow.name)),
691
+ );
692
+ }
693
+ entries.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
694
+ return { entries };
695
+ }
696
+
697
+ // Local state is per-project — anything not reachable is dead weight, so
698
+ // prune as a side effect. Global fragments are shared across projects;
699
+ // another project might still reach the row we'd consider unreachable
700
+ // here, so we only filter the display for globals — never delete.
701
+ const localRuns = this.getStateService().listNodeRuns()
702
+ .filter((run) => run.workflow === workflow.name);
703
+ const localStaleIds = localRuns
704
+ .filter((run) => !reachableRunIds.has(run.id))
705
+ .map((run) => run.id);
706
+ if (localStaleIds.length > 0) {
707
+ this.getStateService().deleteNodeRunsById(localStaleIds);
708
+ }
709
+
710
+ const entries: EngineCacheEntry[] = localRuns
711
+ .filter((run) => reachableRunIds.has(run.id))
712
+ .map((run) => cacheEntryForRun(run, "local"));
713
+
714
+ for (const fragmentHash of evaluated.fragments) {
715
+ const fragment = this.globalFragmentStates.get(fragmentHash);
716
+ if (!fragment) continue;
717
+ entries.push(
718
+ ...fragment.state
719
+ .listNodeRuns()
720
+ .filter((run) => reachableRunIds.has(run.id))
721
+ .map((run) => cacheEntryForRun(run, "global", fragmentHash, workflow.name)),
722
+ );
723
+ }
724
+
725
+ entries.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
726
+ return { entries };
727
+ }
728
+
729
+ async invalidateCache(input: {
730
+ workflow?: string;
731
+ machine?: string;
732
+ nodePaths?: readonly string[];
733
+ } = {}): Promise<{ invalidated: number }> {
734
+ const workflow = this.getWorkflow(input.workflow ?? input.machine);
735
+ const state = this.getStateService();
736
+ // If no explicit paths, invalidate every cached node for this workflow.
737
+ const paths = input.nodePaths && input.nodePaths.length > 0
738
+ ? [...input.nodePaths]
739
+ : [...new Set(state.listNodeRuns()
740
+ .filter((run) => run.workflow === workflow.name && !run.invalidated)
741
+ .map((run) => run.nodePath))];
742
+ if (paths.length === 0) return { invalidated: 0 };
743
+ const ids = state.invalidateNodeRuns({ workflow: workflow.name, nodePaths: paths });
744
+ return { invalidated: ids.length };
745
+ }
746
+
747
+ async clearCache(input: {
748
+ workflow?: string;
749
+ machine?: string;
750
+ scope?: EngineCacheClearScope;
751
+ } = {}): Promise<EngineCacheClearResult> {
752
+ const scope = input.scope ?? "all";
753
+ const workflow = this.getWorkflow(input.workflow ?? input.machine);
754
+ const providers = await this.createProviders(workflow);
755
+ const evaluated = await this.evaluate({
756
+ workflow,
757
+ providers,
758
+ mode: "plan",
759
+ });
760
+
761
+ let deleted = 0;
762
+ if (scope === "all" || scope === "local") {
763
+ deleted += this.getStateService().clearNodeRuns({ workflow: workflow.name });
764
+ }
765
+ if (scope === "all" || scope === "global") {
766
+ for (const fragmentHash of evaluated.fragments) {
767
+ const fragment = this.globalFragmentStates.get(fragmentHash);
768
+ if (!fragment) continue;
769
+ deleted += fragment.state.clearNodeRuns();
770
+ }
771
+ }
772
+ return { deleted };
773
+ }
774
+
593
775
  hasOperation(operationId: string): boolean {
594
776
  return this.listWorkflows().some((workflow) => workflow.operations.some((operation) => operation.id === operationId));
595
777
  }
@@ -620,14 +802,15 @@ export class DevMachineEngine {
620
802
  metadata,
621
803
  });
622
804
  const operationInput = this.resolveOperationInput(workflow, operation, input.input ?? {});
623
- const result = await operation.run({
805
+ const operationNodePath = `operation.${operation.id}`;
806
+ const result = await this.withStepConsole(operationNodePath, () => operation.run({
624
807
  ...runtime,
625
808
  input: Object.freeze(operationInput),
626
809
  providers: runtime,
627
810
  local: this.local,
628
811
  workflow: workflow.name,
629
- step: this.createStepRuntime(workflow.name, `operation.${operation.id}`, metadata),
630
- });
812
+ step: this.createStepRuntime(workflow.name, operationNodePath, metadata),
813
+ }));
631
814
  if (result !== undefined) assertJsonValue(result, `Operation ${operation.id} result`);
632
815
  return result ?? null;
633
816
  }
@@ -694,6 +877,8 @@ export class DevMachineEngine {
694
877
  }> {
695
878
  const workflow = this.getWorkflow(input.workflow ?? input.machine);
696
879
  const providers = await this.createProviders(workflow);
880
+ const startedAt = Date.now();
881
+ this.emit({ type: "workflow.apply.started", workflow: workflow.name });
697
882
  let result: { context: Record<string, JsonValue>; plan: WorkflowPlan } | undefined;
698
883
  const maxRestarts = 8;
699
884
  for (let attempt = 0; attempt <= maxRestarts; attempt++) {
@@ -716,6 +901,14 @@ export class DevMachineEngine {
716
901
  }
717
902
  if (!result) throw new Error(`Workflow ${workflow.name} did not produce an apply result`);
718
903
 
904
+ this.emit({
905
+ type: "workflow.apply.completed",
906
+ workflow: workflow.name,
907
+ nodeCount: result.plan.nodeCount,
908
+ cachedNodeCount: result.plan.cachedNodeCount,
909
+ durationMs: Date.now() - startedAt,
910
+ });
911
+
719
912
  return {
720
913
  context: result.context,
721
914
  plan: result.plan,
@@ -751,6 +944,7 @@ export class DevMachineEngine {
751
944
  };
752
945
 
753
946
  this.getStateService().saveWorkspace(workspace);
947
+ this.emit({ type: "workspace.create.started", workspaceName: input.name });
754
948
  try {
755
949
  await this.runWorkspaceCreate({
756
950
  workflow,
@@ -802,7 +996,10 @@ export class DevMachineEngine {
802
996
  const draft = cloneWorkspace(workspace);
803
997
  const workspaceRuntime = this.createWorkspaceRuntime(draft);
804
998
 
805
- await workflow.workspace.remove({
999
+ const removeNodePath = `workspace.${workspace.name}.remove`;
1000
+ const workspaceDef = workflow.workspace;
1001
+ this.emit({ type: "workspace.remove.started", workspaceName: workspace.name });
1002
+ await this.withStepConsole(removeNodePath, () => workspaceDef.remove({
806
1003
  ...runtime,
807
1004
  workflow: {
808
1005
  name: workflow.name,
@@ -811,10 +1008,11 @@ export class DevMachineEngine {
811
1008
  workspace: workspaceRuntime,
812
1009
  providers: runtime,
813
1010
  local: this.local,
814
- step: this.createStepRuntime(workflow.name, `workspace.${workspace.name}.remove`, metadata),
815
- });
1011
+ step: this.createStepRuntime(workflow.name, removeNodePath, metadata),
1012
+ }));
816
1013
 
817
1014
  this.getStateService().deleteWorkspace(input.workspace);
1015
+ this.emit({ type: "workspace.remove.completed", workspaceName: workspace.name });
818
1016
  return workspace;
819
1017
  }
820
1018
 
@@ -822,26 +1020,42 @@ export class DevMachineEngine {
822
1020
  workflow: LoadedWorkflow;
823
1021
  providers: ProviderControllers;
824
1022
  mode: EvaluationMode;
825
- }): Promise<{ context: Record<string, JsonValue>; plan: WorkflowPlan }> {
1023
+ }): Promise<{ context: Record<string, JsonValue>; plan: WorkflowPlan; fragments: Set<string> }> {
826
1024
  const providerFingerprint = providerFingerprintFor(input.workflow);
827
1025
  const planNodes: WorkflowPlanNode[] = [];
828
- const result = await this.evaluateNode({
829
- workflow: input.workflow,
830
- providers: input.providers,
831
- providerFingerprint,
832
- mode: input.mode,
833
- node: input.workflow.root,
834
- state: {
835
- context: {},
836
- upstreamRunIds: [],
837
- previousTasks: [],
838
- known: true,
839
- },
840
- prefix: [],
841
- root: true,
842
- planNodes,
843
- index: { value: 0 },
844
- });
1026
+ const previousEvaluationFragmentHashes = this.evaluationFragmentHashes;
1027
+ const fragments = new Set<string>();
1028
+ this.evaluationFragmentHashes = fragments;
1029
+ let result!: EvaluationResult;
1030
+ try {
1031
+ result = await this.evaluateNode({
1032
+ workflow: input.workflow,
1033
+ providers: input.providers,
1034
+ providerFingerprint,
1035
+ mode: input.mode,
1036
+ node: input.workflow.root,
1037
+ cache: {
1038
+ scope: "local",
1039
+ workflow: input.workflow.name,
1040
+ nodePath: "",
1041
+ state: this.getStateService(),
1042
+ },
1043
+ configStack: [],
1044
+ state: {
1045
+ context: {},
1046
+ upstreamRunIds: [],
1047
+ previousTasks: [],
1048
+ known: true,
1049
+ },
1050
+ prefix: [],
1051
+ cachePrefix: [],
1052
+ root: true,
1053
+ planNodes,
1054
+ index: { value: 0 },
1055
+ });
1056
+ } finally {
1057
+ this.evaluationFragmentHashes = previousEvaluationFragmentHashes;
1058
+ }
845
1059
  const cachedNodeCount = planNodes.filter((node) => node.status === "cached").length;
846
1060
  const plan: WorkflowPlan = {
847
1061
  workflow: input.workflow.name,
@@ -855,29 +1069,79 @@ export class DevMachineEngine {
855
1069
  return {
856
1070
  context: result.context,
857
1071
  plan,
1072
+ fragments,
858
1073
  };
859
1074
  }
860
1075
 
861
1076
  private async evaluateNode(input: EvaluateNodeInput): Promise<EvaluationResult> {
862
- if (input.node.nodeKind === "task") {
863
- return await this.evaluateTask(input as EvaluateNodeInput & { node: WorkflowTaskNode<any, any, any> });
1077
+ if (input.node.cacheScope === "global" && input.cache.scope !== "global") {
1078
+ const nodePath = nodeDisplayPath(input.node, input.prefix, input.root, input.suppressSequenceName);
1079
+ const fragmentHash = globalFragmentHashFor({
1080
+ node: input.node,
1081
+ providerFingerprint: input.providerFingerprint,
1082
+ });
1083
+ const fragmentState = await this.getGlobalFragmentState({
1084
+ hash: fragmentHash,
1085
+ workflow: input.workflow.name,
1086
+ nodePath,
1087
+ nodeName: input.node.name,
1088
+ nodeKind: input.node.nodeKind,
1089
+ });
1090
+ return await this.evaluateNode({
1091
+ ...input,
1092
+ cache: {
1093
+ scope: "global",
1094
+ workflow: `fragment:${fragmentHash}`,
1095
+ nodePath: "",
1096
+ state: fragmentState,
1097
+ fragmentHash,
1098
+ },
1099
+ cachePrefix: [],
1100
+ suppressCacheSequenceName: undefined,
1101
+ });
864
1102
  }
865
1103
 
866
- if (input.node.nodeKind === "parallel") {
867
- return await this.evaluateParallel(input);
1104
+ if (input.node.cacheScope === "local" && input.cache.scope !== "local") {
1105
+ return await this.evaluateNode({
1106
+ ...input,
1107
+ cache: {
1108
+ scope: "local",
1109
+ workflow: input.workflow.name,
1110
+ nodePath: "",
1111
+ state: this.getStateService(),
1112
+ },
1113
+ cachePrefix: input.prefix,
1114
+ suppressCacheSequenceName: input.suppressSequenceName,
1115
+ });
868
1116
  }
869
1117
 
870
- const sequencePrefix = input.root || input.suppressSequenceName === input.node.name
871
- ? input.prefix
872
- : [...input.prefix, input.node.name];
873
- let state = input.state;
1118
+ const configuredInput = input.node.config
1119
+ ? { ...input, configStack: [...input.configStack, input.node.config] }
1120
+ : input;
874
1121
 
875
- for (const child of sequenceChildren(input.node)) {
1122
+ if (configuredInput.node.nodeKind === "task") {
1123
+ return await this.evaluateTask(configuredInput as EvaluateNodeInput & { node: WorkflowTaskNode<any, any, any> });
1124
+ }
1125
+
1126
+ if (configuredInput.node.nodeKind === "parallel") {
1127
+ return await this.evaluateParallel(configuredInput);
1128
+ }
1129
+
1130
+ const sequencePrefix = configuredInput.root || configuredInput.suppressSequenceName === configuredInput.node.name
1131
+ ? configuredInput.prefix
1132
+ : [...configuredInput.prefix, configuredInput.node.name];
1133
+ const cacheSequencePrefix = configuredInput.root || configuredInput.suppressCacheSequenceName === configuredInput.node.name
1134
+ ? configuredInput.cachePrefix
1135
+ : [...configuredInput.cachePrefix, configuredInput.node.name];
1136
+ let state = configuredInput.state;
1137
+
1138
+ for (const child of sequenceChildren(configuredInput.node)) {
876
1139
  const result = await this.evaluateNode({
877
- ...input,
1140
+ ...configuredInput,
878
1141
  node: child,
879
1142
  state,
880
1143
  prefix: sequencePrefix,
1144
+ cachePrefix: cacheSequencePrefix,
881
1145
  root: false,
882
1146
  });
883
1147
  state = {
@@ -889,7 +1153,7 @@ export class DevMachineEngine {
889
1153
  };
890
1154
  }
891
1155
 
892
- return { ...state, planNodes: input.planNodes };
1156
+ return { ...state, planNodes: configuredInput.planNodes };
893
1157
  }
894
1158
 
895
1159
  private async evaluateParallel(input: EvaluateNodeInput): Promise<EvaluationResult> {
@@ -916,8 +1180,10 @@ export class DevMachineEngine {
916
1180
  blockedReason: input.state.blockedReason,
917
1181
  },
918
1182
  prefix: [...input.prefix, branchName],
1183
+ cachePrefix: [...input.cachePrefix, branchName],
919
1184
  root: false,
920
1185
  suppressSequenceName: branchName,
1186
+ suppressCacheSequenceName: branchName,
921
1187
  });
922
1188
 
923
1189
  if (branchState.known) {
@@ -942,13 +1208,14 @@ export class DevMachineEngine {
942
1208
 
943
1209
  private async evaluateTask(input: EvaluateNodeInput & { node: WorkflowTaskNode<any, any, any> }): Promise<EvaluationResult> {
944
1210
  const nodePath = [...input.prefix, input.node.name].join(".");
1211
+ const cacheNodePath = [...input.cachePrefix, input.node.name].join(".");
945
1212
  const upstreamRunIds = [...input.state.upstreamRunIds];
946
1213
  const nodeKey = hash({
947
- cache: "task-v3",
1214
+ cache: "task-v4",
948
1215
  kind: "task",
949
- path: nodePath,
1216
+ path: cacheNodePath,
950
1217
  name: input.node.name,
951
- version: input.node.options?.version ?? null,
1218
+ config: input.configStack,
952
1219
  handler: functionFingerprintFor(input.node.handler),
953
1220
  output: input.node.options?.output ?? null,
954
1221
  });
@@ -974,8 +1241,10 @@ export class DevMachineEngine {
974
1241
  }
975
1242
 
976
1243
  const cached = await this.findReusableTaskRun({
977
- workflow: input.workflow.name,
978
- nodePath,
1244
+ state: input.cache.state,
1245
+ workflow: input.cache.workflow,
1246
+ nodePath: cacheNodePath,
1247
+ displayNodePath: nodePath,
979
1248
  nodeKey,
980
1249
  providerFingerprint: input.providerFingerprint,
981
1250
  upstreamRunIds,
@@ -988,6 +1257,10 @@ export class DevMachineEngine {
988
1257
  const previousTasks = appendPreviousTask(input.state.previousTasks, {
989
1258
  name: input.node.name,
990
1259
  path: nodePath,
1260
+ cache: {
1261
+ ...input.cache,
1262
+ nodePath: cacheNodePath,
1263
+ },
991
1264
  });
992
1265
  this.emit({ type: "node.cached", nodePath, runId: cached.id });
993
1266
  input.planNodes.push({
@@ -1035,6 +1308,7 @@ export class DevMachineEngine {
1035
1308
  nodePath,
1036
1309
  metadata,
1037
1310
  });
1311
+ const config = Object.freeze(mergeConfigStack(input.configStack));
1038
1312
  const step = this.createStepRuntime(
1039
1313
  input.workflow.name,
1040
1314
  nodePath,
@@ -1042,16 +1316,26 @@ export class DevMachineEngine {
1042
1316
  input.state.context,
1043
1317
  input.state.previousTasks,
1044
1318
  );
1045
- const result = await input.node.handler({
1319
+ const result = await this.withStepConsole(nodePath, () => input.node.handler({
1046
1320
  ...runtime,
1047
1321
  providers: runtime,
1048
1322
  step,
1049
- });
1323
+ config,
1324
+ }));
1050
1325
  if (isStepInvalidation(result)) {
1051
- const invalidatedRunIds = this.getStateService().invalidateNodeRuns({
1052
- workflow: input.workflow.name,
1053
- nodePaths: [result.targetNodePath, nodePath],
1054
- });
1326
+ const targetTask = input.state.previousTasks.find((task) => task.path === result.targetNodePath);
1327
+ const invalidatedRunIds = targetTask
1328
+ ? this.invalidateTaskCaches([
1329
+ targetTask.cache,
1330
+ {
1331
+ ...input.cache,
1332
+ nodePath: cacheNodePath,
1333
+ },
1334
+ ])
1335
+ : this.invalidateTaskCaches([{
1336
+ ...input.cache,
1337
+ nodePath: cacheNodePath,
1338
+ }]);
1055
1339
  throw new StepInvalidationRestart({
1056
1340
  workflow: input.workflow.name,
1057
1341
  target: result.target,
@@ -1073,8 +1357,8 @@ export class DevMachineEngine {
1073
1357
  const artifacts = collectArtifacts(output);
1074
1358
  const record: WorkflowNodeRunRecord = {
1075
1359
  id: crypto.randomUUID(),
1076
- workflow: input.workflow.name,
1077
- nodePath,
1360
+ workflow: input.cache.workflow,
1361
+ nodePath: cacheNodePath,
1078
1362
  nodeName: input.node.name,
1079
1363
  nodeKind: input.node.nodeKind,
1080
1364
  nodeKey,
@@ -1087,7 +1371,7 @@ export class DevMachineEngine {
1087
1371
  metadata,
1088
1372
  };
1089
1373
 
1090
- this.getStateService().saveNodeRun(record);
1374
+ input.cache.state.saveNodeRun(record);
1091
1375
  for (const artifact of artifacts) {
1092
1376
  const providerId = providerIdOf(artifact);
1093
1377
  this.emit({
@@ -1103,6 +1387,10 @@ export class DevMachineEngine {
1103
1387
  const previousTasks = appendPreviousTask(input.state.previousTasks, {
1104
1388
  name: input.node.name,
1105
1389
  path: nodePath,
1390
+ cache: {
1391
+ ...input.cache,
1392
+ nodePath: cacheNodePath,
1393
+ },
1106
1394
  });
1107
1395
 
1108
1396
  return {
@@ -1115,8 +1403,10 @@ export class DevMachineEngine {
1115
1403
  }
1116
1404
 
1117
1405
  private async findReusableTaskRun(input: {
1406
+ state: StateService;
1118
1407
  workflow: string;
1119
1408
  nodePath: string;
1409
+ displayNodePath: string;
1120
1410
  nodeKey: string;
1121
1411
  providerFingerprint: string;
1122
1412
  upstreamRunIds: readonly string[];
@@ -1124,11 +1414,11 @@ export class DevMachineEngine {
1124
1414
  outputSchema?: OutputSchema;
1125
1415
  cacheTTL?: WorkflowTaskCacheTTL;
1126
1416
  }): Promise<WorkflowNodeRunRecord | undefined> {
1127
- const cached = this.getStateService().findReusableNodeRun(input);
1417
+ const cached = input.state.findReusableNodeRun(input);
1128
1418
  if (!cached) return undefined;
1129
1419
  if (!isCacheFresh(cached.createdAt, input.cacheTTL)) return undefined;
1130
1420
 
1131
- const parsed = normalizeTaskOutput(input.nodePath, cached.output, input.outputSchema, "cached");
1421
+ const parsed = normalizeTaskOutput(input.displayNodePath, cached.output, input.outputSchema, "cached");
1132
1422
  if (!parsed) return undefined;
1133
1423
 
1134
1424
  for (const artifact of cached.artifacts) {
@@ -1223,8 +1513,10 @@ export class DevMachineEngine {
1223
1513
  metadata,
1224
1514
  });
1225
1515
 
1516
+ const createNodePath = `workspace.${input.name}.create`;
1517
+ const workspaceDef = input.workflow.workspace;
1226
1518
  try {
1227
- const data = await input.workflow.workspace.create({
1519
+ const data = await this.withStepConsole(createNodePath, () => workspaceDef.create({
1228
1520
  ...providers,
1229
1521
  workflow: {
1230
1522
  name: input.workflow.name,
@@ -1235,8 +1527,8 @@ export class DevMachineEngine {
1235
1527
  },
1236
1528
  providers,
1237
1529
  local: this.local,
1238
- step: this.createStepRuntime(input.workflow.name, `workspace.${input.name}.create`, metadata),
1239
- });
1530
+ step: this.createStepRuntime(input.workflow.name, createNodePath, metadata),
1531
+ }));
1240
1532
  assertJsonValue(data, `Workflow ${input.workflow.name} workspace create result`);
1241
1533
  if (!isPlainObject(data)) {
1242
1534
  throw new Error(`Workflow ${input.workflow.name} workspace create result must be an object`);
@@ -1266,7 +1558,13 @@ export class DevMachineEngine {
1266
1558
  const workspace = this.createWorkspaceRuntime(draft);
1267
1559
  const operationInput = this.resolveOperationInput(input.workflow, input.operation, input.rawInput ?? {});
1268
1560
 
1269
- const result = await input.operation.run({
1561
+ const workspaceOperationNodePath = `workspace.${input.workspace.name}.${input.operation.id}`;
1562
+ this.emit({
1563
+ type: "workspace.operation.started",
1564
+ workspaceName: input.workspace.name,
1565
+ operationId: input.operation.id,
1566
+ });
1567
+ const result = await this.withStepConsole(workspaceOperationNodePath, () => input.operation.run({
1270
1568
  ...providers,
1271
1569
  workflow: {
1272
1570
  name: input.workflow.name,
@@ -1278,11 +1576,16 @@ export class DevMachineEngine {
1278
1576
  local: this.local,
1279
1577
  step: this.createStepRuntime(
1280
1578
  input.workflow.name,
1281
- `workspace.${input.workspace.name}.${input.operation.id}`,
1579
+ workspaceOperationNodePath,
1282
1580
  metadata,
1283
1581
  ),
1284
- });
1582
+ }));
1285
1583
  if (result !== undefined) assertJsonValue(result, `Workspace operation ${input.operation.id} result`);
1584
+ this.emit({
1585
+ type: "workspace.operation.completed",
1586
+ workspaceName: input.workspace.name,
1587
+ operationId: input.operation.id,
1588
+ });
1286
1589
  return result ?? null;
1287
1590
  }
1288
1591
 
@@ -1293,6 +1596,22 @@ export class DevMachineEngine {
1293
1596
  }) as WorkspaceRuntimeRecord<Data>;
1294
1597
  }
1295
1598
 
1599
+ // Wraps a user step handler in an AsyncLocalStorage scope so that any
1600
+ // console.log / debug / warn / error invoked inside (transitively, through
1601
+ // any helper or third-party SDK) is captured and emitted as a log.output
1602
+ // event tied to this step's node path.
1603
+ private withStepConsole<T>(nodePath: string, fn: () => Promise<T> | T): Promise<T> | T {
1604
+ const sink: StepConsoleSink = ({ level, message }) => {
1605
+ this.emit({
1606
+ type: "log.output",
1607
+ nodePath,
1608
+ stream: consoleLevelToLogStream(level),
1609
+ data: message,
1610
+ });
1611
+ };
1612
+ return runWithStepConsole(sink, fn);
1613
+ }
1614
+
1296
1615
  private createStepRuntime<Context extends JsonObject = JsonObject>(
1297
1616
  workflow: string,
1298
1617
  nodePath: string,
@@ -1307,15 +1626,6 @@ export class DevMachineEngine {
1307
1626
  metadata: (value: JsonObject) => {
1308
1627
  Object.assign(metadata, value);
1309
1628
  },
1310
- log: (data: string, options: { stream?: WorkflowLogStream; label?: string } = {}) => {
1311
- this.emit({
1312
- type: "log.output",
1313
- nodePath,
1314
- stream: options.stream ?? "info",
1315
- label: options.label,
1316
- data,
1317
- });
1318
- },
1319
1629
  invalidate: <Target extends string>(target: Target) => {
1320
1630
  const matches = previousTasks.filter((task) => task.name === target || task.path === target);
1321
1631
  if (matches.length === 0) {
@@ -1459,6 +1769,51 @@ export class DevMachineEngine {
1459
1769
  return this.state;
1460
1770
  }
1461
1771
 
1772
+ private async getGlobalFragmentState(input: GlobalFragmentStateLocationInput): Promise<StateService> {
1773
+ this.evaluationFragmentHashes?.add(input.hash);
1774
+ const existing = this.globalFragmentStates.get(input.hash);
1775
+ if (existing) return existing.state;
1776
+
1777
+ const located = this.globalFragmentStateLocator(input);
1778
+ const statePath = typeof located === "string" ? located : located.statePath;
1779
+ const state = this.stateFactory({
1780
+ projectDir: this.projectDir,
1781
+ configPath: this.configPath,
1782
+ statePath,
1783
+ source: {
1784
+ kind: "global-fragment",
1785
+ hash: input.hash,
1786
+ workflow: input.workflow,
1787
+ nodePath: input.nodePath,
1788
+ nodeName: input.nodeName,
1789
+ nodeKind: input.nodeKind,
1790
+ },
1791
+ });
1792
+ await state.syncSchema();
1793
+ this.globalFragmentStates.set(input.hash, { input, state });
1794
+ return state;
1795
+ }
1796
+
1797
+ private invalidateTaskCaches(targets: EvaluationCacheTarget[]): string[] {
1798
+ const grouped = new Map<string, { target: EvaluationCacheTarget; nodePaths: Set<string> }>();
1799
+ for (const target of targets) {
1800
+ const key = `${target.state.path}\0${target.workflow}`;
1801
+ const existing = grouped.get(key);
1802
+ if (existing) {
1803
+ existing.nodePaths.add(target.nodePath);
1804
+ } else {
1805
+ grouped.set(key, { target, nodePaths: new Set([target.nodePath]) });
1806
+ }
1807
+ }
1808
+
1809
+ return [...grouped.values()].flatMap(({ target, nodePaths }) =>
1810
+ target.state.invalidateNodeRuns({
1811
+ workflow: target.workflow,
1812
+ nodePaths: [...nodePaths],
1813
+ })
1814
+ );
1815
+ }
1816
+
1462
1817
  private async createProviders(workflow: LoadedWorkflow): Promise<ProviderControllers> {
1463
1818
  const entries = await Promise.all(
1464
1819
  Object.entries(workflow.providers).map(async ([name, provider]) => {
@@ -1703,6 +2058,88 @@ function providerPluginFingerprint(plugin: unknown): unknown {
1703
2058
  };
1704
2059
  }
1705
2060
 
2061
+ function globalFragmentHashFor(input: {
2062
+ node: WorkflowNodeDefinition<any, any, any>;
2063
+ providerFingerprint: string;
2064
+ }): string {
2065
+ return `sha256-${hash({
2066
+ cache: "fragment-v1",
2067
+ graph: graphFingerprintFor(input.node),
2068
+ providerFingerprint: input.providerFingerprint,
2069
+ })}`;
2070
+ }
2071
+
2072
+ function graphFingerprintFor(node: WorkflowNodeDefinition<any, any, any>): unknown {
2073
+ if (node.nodeKind === "task") {
2074
+ const task = node as WorkflowTaskNode<any, any, any>;
2075
+ return {
2076
+ kind: "task",
2077
+ name: task.name,
2078
+ scope: task.cacheScope ?? null,
2079
+ config: task.config ?? null,
2080
+ handler: functionFingerprintFor(task.handler),
2081
+ output: task.options?.output ?? null,
2082
+ cacheTTL: task.options?.cacheTTL ?? null,
2083
+ };
2084
+ }
2085
+
2086
+ if (node.nodeKind === "parallel") {
2087
+ return {
2088
+ kind: "parallel",
2089
+ name: node.name,
2090
+ scope: node.cacheScope ?? null,
2091
+ config: node.config ?? null,
2092
+ branches: Object.fromEntries(
2093
+ Object.entries(parallelBranches(node)).map(([name, branch]) => [name, graphFingerprintFor(branch)]),
2094
+ ),
2095
+ };
2096
+ }
2097
+
2098
+ return {
2099
+ kind: "sequence",
2100
+ name: node.name,
2101
+ scope: node.cacheScope ?? null,
2102
+ config: node.config ?? null,
2103
+ children: sequenceChildren(node).map((child) => graphFingerprintFor(child)),
2104
+ };
2105
+ }
2106
+
2107
+ function nodeDisplayPath(
2108
+ node: WorkflowNodeDefinition<any, any, any>,
2109
+ prefix: string[],
2110
+ root: boolean,
2111
+ suppressSequenceName?: string,
2112
+ ): string {
2113
+ if (node.nodeKind === "task") return [...prefix, node.name].join(".");
2114
+ if (node.nodeKind === "sequence") {
2115
+ return (root || suppressSequenceName === node.name ? prefix : [...prefix, node.name]).join(".");
2116
+ }
2117
+ return [...prefix, node.name].join(".");
2118
+ }
2119
+
2120
+ function cacheEntryForRun(
2121
+ run: WorkflowNodeRunRecord,
2122
+ scope: WorkflowCacheScope,
2123
+ fragmentHash?: string,
2124
+ workflow?: string,
2125
+ ): EngineCacheEntry {
2126
+ return {
2127
+ scope,
2128
+ workflow: workflow ?? run.workflow,
2129
+ nodePath: run.nodePath,
2130
+ nodeName: run.nodeName,
2131
+ nodeKind: run.nodeKind,
2132
+ runId: run.id,
2133
+ invalidated: run.invalidated,
2134
+ createdAt: run.createdAt,
2135
+ ...(fragmentHash ? { fragmentHash } : {}),
2136
+ };
2137
+ }
2138
+
2139
+ function mergeConfigStack(configStack: readonly JsonObject[]): JsonObject {
2140
+ return Object.assign({}, ...configStack);
2141
+ }
2142
+
1706
2143
  function functionFingerprintFor(fn: Function): { name: string; length: number; source: string } {
1707
2144
  return {
1708
2145
  name: fn.name,
@@ -2154,3 +2591,14 @@ async function defaultInteractionPresenter(request: InteractionPresentationReque
2154
2591
  if (request.instructions) console.error(request.instructions);
2155
2592
  console.error(`Open ${request.url}`);
2156
2593
  }
2594
+
2595
+ function consoleLevelToLogStream(level: ConsoleLevel): WorkflowLogStream {
2596
+ switch (level) {
2597
+ case "debug": return "debug";
2598
+ case "warn": return "warn";
2599
+ case "error": return "stderr";
2600
+ case "info":
2601
+ case "log":
2602
+ default: return "info";
2603
+ }
2604
+ }