pi-ultracode 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,6 +33,7 @@ export interface ParsedWorkflow {
33
33
  export const WORKFLOW_CHECKPOINT_IDENTIFIER = "__ultracodeCheckpoint";
34
34
  const WORKFLOW_LOOP_IDENTIFIER = "__ultracodeLoop";
35
35
  const WORKFLOW_INVOKE_IDENTIFIER = "__ultracodeInvoke";
36
+ const WORKFLOW_SAFE_MAP_IDENTIFIER = "__ultracodeMapThunks";
36
37
  const WORKFLOW_INTERNAL_CALLS = {
37
38
  agent: "__ultracodeAgent",
38
39
  parallel: "__ultracodeParallel",
@@ -43,6 +44,7 @@ const WORKFLOW_RESERVED_IDENTIFIERS = new Set([
43
44
  WORKFLOW_CHECKPOINT_IDENTIFIER,
44
45
  WORKFLOW_LOOP_IDENTIFIER,
45
46
  WORKFLOW_INVOKE_IDENTIFIER,
47
+ WORKFLOW_SAFE_MAP_IDENTIFIER,
46
48
  ...Object.values(WORKFLOW_INTERNAL_CALLS),
47
49
  ]);
48
50
  export const DEFAULT_WORKFLOW_CHECKPOINT_LIMIT = 1_000_000;
@@ -249,10 +251,18 @@ function assertDeterministicAst(
249
251
  if (isUnsupportedClassEagerOrchestration(node, orchestrationBindings)) {
250
252
  throw new Error("Workflow classes may call orchestration only from ordinary methods, not constructors, fields, or static blocks.");
251
253
  }
252
- if (isUnsupportedFactoryProperty(node) || isUnsupportedAccessorForwarding(node, orchestrationBindings)) {
253
- throw new Error("Workflow orchestration helper properties must use static methods; accessor forwarding is unsupported.");
254
+ if (
255
+ isUnsupportedFactoryProperty(node)
256
+ || isUnsupportedAccessorForwarding(node, orchestrationBindings)
257
+ || isAmbiguousOrchestrationContainer(node)
258
+ ) {
259
+ throw new Error("Workflow orchestration helper properties must use unique static methods without spreads; accessor forwarding is unsupported.");
254
260
  }
255
- if (isUnsupportedCompositeFactory(node)) {
261
+ if (
262
+ isUnsupportedCompositeFactory(node, orchestrationBindings)
263
+ || isUnsupportedAnonymousHelperExtraction(node)
264
+ || isUnsupportedHelperDestructuringAssignment(node, orchestrationBindings)
265
+ ) {
256
266
  throw new Error("Workflow orchestration helper factories must use a direct function, object, or class declaration.");
257
267
  }
258
268
  if (isUnsupportedHelperAssignment(node, orchestrationBindings)) {
@@ -267,10 +277,11 @@ function assertDeterministicAst(
267
277
  isDynamicMethodCall(node)
268
278
  || isDynamicOrchestrationMember(node, orchestrationBindings)
269
279
  || isOrchestrationDestructure(node, orchestrationBindings)
270
- || isOrchestrationArgumentEscape(node, orchestrationBindings)
280
+ || isOrchestrationArgumentEscape(node, orchestrationBindings, parent)
271
281
  || isOrchestrationFunctionAlias(node, parent)
272
282
  || isOrchestrationMemberAlias(node, parent, orchestrationBindings)
273
283
  || isOrchestrationBindingEscape(node, parent, orchestrationBindings)
284
+ || isTaintedTaggedCall(node, orchestrationBindings)
274
285
  ) {
275
286
  throw new Error(
276
287
  "Workflow scripts must use a static method name; dynamic method calls can bypass deterministic promise checks.",
@@ -279,6 +290,9 @@ function assertDeterministicAst(
279
290
  if (node.type === "Identifier" && node.name === "globalThis") {
280
291
  throw new Error("Workflow scripts must not access globalThis; use the declared workflow globals directly.");
281
292
  }
293
+ if (node.type === "Identifier" && node.name === "process" && isIdentifierReference(node, parent)) {
294
+ throw new Error("Workflow scripts do not expose process; use the declared cwd global directly.");
295
+ }
282
296
  if (
283
297
  node.type === "MemberExpression"
284
298
  && node.object?.type === "ThisExpression"
@@ -335,10 +349,21 @@ function collectCallSiteEdits(
335
349
  ): void {
336
350
  const orchestrationBindings = collectOrchestrationBindings(root);
337
351
  const localFunctions = collectLocalFunctionNames(root, orchestrationBindings);
338
- const stack: Array<{ node: AnyNode; loops: string[] }> = [{ node: root, loops: [] }];
352
+ const stack: Array<{ node: AnyNode; parent?: AnyNode; loops: string[] }> = [{ node: root, loops: [] }];
339
353
  while (stack.length > 0) {
340
- const { node, loops } = stack.pop()!;
341
- if (
354
+ const { node, parent, loops } = stack.pop()!;
355
+ if (isSafeParallelMap(node, parent, orchestrationBindings)) {
356
+ const openOffset = source.slice(node.callee.end, node.end).indexOf("(");
357
+ if (openOffset < 0) throw new Error("unable to instrument safe parallel map");
358
+ const openParen = node.callee.end + openOffset;
359
+ const receiver = source.slice(node.callee.object.start, node.callee.object.end);
360
+ replacements.push({
361
+ start: node.callee.start,
362
+ end: node.callee.end,
363
+ text: WORKFLOW_SAFE_MAP_IDENTIFIER,
364
+ });
365
+ insertions.push({ pos: openParen + 1, text: `${receiver},` });
366
+ } else if (
342
367
  node.type === "CallExpression"
343
368
  && node.callee?.type === "Identifier"
344
369
  && Object.hasOwn(WORKFLOW_INTERNAL_CALLS, node.callee.name)
@@ -359,7 +384,8 @@ function collectCallSiteEdits(
359
384
  text: `${JSON.stringify(callSite)}${node.arguments.length > 0 ? "," : ""}`,
360
385
  });
361
386
  } else if (node.type === "CallExpression" && (
362
- (node.callee?.type === "Identifier" && localFunctions.has(node.callee.name))
387
+ (node.callee?.type === "Identifier"
388
+ && (localFunctions.has(node.callee.name) || orchestrationBindings.has(node.callee.name)))
363
389
  || (node.callee?.type === "MemberExpression"
364
390
  && isTaintedStaticMember(node.callee, orchestrationBindings))
365
391
  )) {
@@ -394,6 +420,7 @@ function collectCallSiteEdits(
394
420
  for (const child of astChildren(node)) {
395
421
  stack.push({
396
422
  node: child,
423
+ parent: node,
397
424
  loops: body && loopSite && child === body ? [...loops, loopSite] : loops,
398
425
  });
399
426
  }
@@ -521,8 +548,8 @@ function collectOrchestrationBindings(root: AnyNode): Set<string> {
521
548
  ? containsDirectOrchestration(definition.value)
522
549
  : containsUnconsumedOrchestration(definition.value);
523
550
  if (tainted) seeds.add(definition.name);
524
- if (definition.value.type === "ObjectExpression") {
525
- for (const member of objectFactoryMembers(definition.name, definition.value)) addMemberSeed(seeds, member);
551
+ if (definition.value.type === "ObjectExpression" || definition.value.type === "ArrayExpression") {
552
+ for (const member of containerFactoryMembers(definition.name, definition.value)) addMemberSeed(seeds, member);
526
553
  }
527
554
  for (const alias of collectStaticAliases(definition.name, definition.value)) {
528
555
  const targets = aliases.get(alias.source) ?? new Set<string>();
@@ -620,18 +647,28 @@ function collectReferencedIdentifiers(root: AnyNode): Set<string> {
620
647
  return references;
621
648
  }
622
649
 
623
- function objectFactoryMembers(name: string, node: AnyNode): string[] {
650
+ function containerFactoryMembers(name: string, node: AnyNode): string[] {
624
651
  const members: string[] = [];
625
- for (const property of node.properties ?? []) {
626
- if (property.type !== "Property") continue;
627
- const key = property.computed
628
- ? staticStringOf(property.key)
629
- : property.key?.name ?? (typeof property.key?.value === "string" ? property.key.value : undefined);
630
- if (!key) continue;
652
+ const entries: Array<{ key: string; value: AnyNode }> = [];
653
+ if (node.type === "ObjectExpression") {
654
+ for (const property of node.properties ?? []) {
655
+ if (property.type !== "Property") continue;
656
+ const key = property.computed
657
+ ? staticStringOf(property.key)
658
+ : property.key?.name ?? (typeof property.key?.value === "string" ? property.key.value : undefined);
659
+ if (key !== undefined) entries.push({ key, value: property.value });
660
+ }
661
+ } else if (node.type === "ArrayExpression") {
662
+ for (let index = 0; index < (node.elements?.length ?? 0); index++) {
663
+ const value = node.elements[index];
664
+ if (value && value.type !== "SpreadElement") entries.push({ key: String(index), value });
665
+ }
666
+ }
667
+ for (const { key, value } of entries) {
631
668
  const member = `${name}.${key}`;
632
- if (isFunctionNode(property.value) && containsDirectOrchestration(property.value)) members.push(member);
633
- if (property.value?.type === "ObjectExpression") {
634
- members.push(...objectFactoryMembers(member, property.value));
669
+ if (isFunctionNode(value) && containsDirectOrchestration(value)) members.push(member);
670
+ if (value.type === "ObjectExpression" || value.type === "ArrayExpression") {
671
+ members.push(...containerFactoryMembers(member, value));
635
672
  }
636
673
  }
637
674
  return members;
@@ -759,7 +796,11 @@ function isDynamicOrchestrationMember(node: AnyNode, bindings: ReadonlySet<strin
759
796
  && isOrchestrationExpression(node.object, bindings);
760
797
  }
761
798
 
762
- function isOrchestrationArgumentEscape(node: AnyNode, bindings: ReadonlySet<string>): boolean {
799
+ function isOrchestrationArgumentEscape(
800
+ node: AnyNode,
801
+ bindings: ReadonlySet<string>,
802
+ parent?: AnyNode,
803
+ ): boolean {
763
804
  if (node.type === "TaggedTemplateExpression") {
764
805
  return (node.quasi?.expressions ?? []).some((expression: AnyNode) =>
765
806
  isOrchestrationExpression(expression, bindings)
@@ -769,19 +810,98 @@ function isOrchestrationArgumentEscape(node: AnyNode, bindings: ReadonlySet<stri
769
810
  node.type === "CallExpression"
770
811
  && node.callee?.type === "Identifier"
771
812
  && ORCHESTRATION_FUNCTIONS.has(node.callee.name)
772
- ) return false;
813
+ ) {
814
+ return node.arguments.some((argument: AnyNode) =>
815
+ containsEagerOrchestration(
816
+ argument.type === "SpreadElement" ? argument.argument : argument,
817
+ bindings,
818
+ ));
819
+ }
820
+ if (isSafeParallelMap(node, parent, bindings)) return false;
773
821
  return (node.type === "CallExpression" || node.type === "NewExpression")
774
- && node.arguments.some((argument: AnyNode) =>
775
- argument.type === "SpreadElement"
776
- ? isOrchestrationExpression(argument.argument, bindings)
777
- : isOrchestrationExpression(argument, bindings)
778
- );
822
+ && node.arguments.some((argument: AnyNode) => {
823
+ const value = argument.type === "SpreadElement" ? argument.argument : argument;
824
+ return isOrchestrationExpression(value, bindings)
825
+ || containsEscapingOrchestration(value, bindings);
826
+ });
827
+ }
828
+
829
+ function containsEagerOrchestration(root: AnyNode, bindings: ReadonlySet<string>): boolean {
830
+ const stack = [root];
831
+ while (stack.length > 0) {
832
+ const node = stack.pop()!;
833
+ if (isFunctionNode(node)) continue;
834
+ if (node.type === "CallExpression" && isOrchestrationExpression(node, bindings)) return true;
835
+ if (node.type === "Identifier" && bindings.has(node.name)) return true;
836
+ if (node.type === "MemberExpression" && isTaintedStaticMember(node, bindings)) return true;
837
+ stack.push(...astChildren(node));
838
+ }
839
+ return false;
840
+ }
841
+
842
+ /** Find raw orchestration capabilities passed to another call; awaited results are plain data. */
843
+ function containsEscapingOrchestration(root: AnyNode, bindings: ReadonlySet<string>): boolean {
844
+ const stack: Array<{ node: AnyNode; parent?: AnyNode }> = [{ node: root }];
845
+ while (stack.length > 0) {
846
+ const { node, parent } = stack.pop()!;
847
+ if (node.type === "AwaitExpression") continue;
848
+ if (isFunctionNode(node) && containsOrchestrationCall(node, bindings)) return true;
849
+ if (node.type === "CallExpression" && isOrchestrationExpression(node, bindings)) return true;
850
+ if (node.type === "MemberExpression" && propertyNameOf(node) !== undefined) {
851
+ if (isOrchestrationExpression(node, bindings)) return true;
852
+ continue;
853
+ }
854
+ if (
855
+ node.type === "Identifier"
856
+ && isIdentifierReference(node, parent)
857
+ && (bindings.has(node.name) || hasTaintedMember(bindings, node.name))
858
+ ) return true;
859
+ for (const child of astChildren(node)) stack.push({ node: child, parent: node });
860
+ }
861
+ return false;
862
+ }
863
+
864
+ function isSafeParallelMap(
865
+ node: AnyNode,
866
+ parent: AnyNode | undefined,
867
+ bindings: ReadonlySet<string>,
868
+ ): boolean {
869
+ if (
870
+ node.type !== "CallExpression"
871
+ || node.callee?.type !== "MemberExpression"
872
+ || propertyNameOf(node.callee) !== "map"
873
+ || parent?.type !== "CallExpression"
874
+ || parent.callee?.type !== "Identifier"
875
+ || parent.callee.name !== "parallel"
876
+ || !parent.arguments.includes(node)
877
+ || node.arguments?.length !== 1
878
+ || containsOrchestrationCall(node.callee.object, bindings)
879
+ ) return false;
880
+ const mapper = node.arguments[0];
881
+ if (
882
+ !mapper
883
+ || (mapper.type !== "ArrowFunctionExpression" && mapper.type !== "FunctionExpression")
884
+ || mapper.async
885
+ || mapper.generator
886
+ || (mapper.params ?? []).some((parameter: AnyNode) => containsOrchestrationCall(parameter, bindings))
887
+ ) return false;
888
+ const expressions = mapper.body?.type === "SequenceExpression"
889
+ ? mapper.body.expressions
890
+ : [mapper.body];
891
+ const thunk = expressions?.at(-1);
892
+ if (!isFunctionNode(thunk)) return false;
893
+ return expressions.slice(0, -1).every((expression: AnyNode) =>
894
+ !containsOrchestrationCall(expression, bindings));
779
895
  }
780
896
 
781
897
  function isUnsupportedHelperCall(node: AnyNode, bindings: ReadonlySet<string>): boolean {
782
898
  if (node.type !== "CallExpression") return false;
783
899
  if (node.callee?.type === "MemberExpression") {
784
- if (node.callee.object?.type === "ThisExpression" || node.callee.object?.type === "AwaitExpression") return true;
900
+ if (
901
+ node.callee.object?.type === "ThisExpression"
902
+ || node.callee.object?.type === "AwaitExpression"
903
+ || node.callee.object?.type === "Super"
904
+ ) return true;
785
905
  return (node.callee.object?.type === "CallExpression" || node.callee.object?.type === "NewExpression")
786
906
  && isOrchestrationExpression(node.callee.object, bindings);
787
907
  }
@@ -844,10 +964,97 @@ function isUnsupportedAccessorForwarding(node: AnyNode, bindings: ReadonlySet<st
844
964
  return containsDirectOrchestration(value);
845
965
  }
846
966
 
847
- function isUnsupportedCompositeFactory(node: AnyNode): boolean {
967
+ function isAmbiguousOrchestrationContainer(node: AnyNode): boolean {
968
+ if (node.type !== "ObjectExpression" || !containsDirectOrchestration(node)) return false;
969
+ const seen = new Set<string>();
970
+ for (const property of node.properties ?? []) {
971
+ if (property.type !== "Property") return true;
972
+ const key = property.computed ? staticStringOf(property.key) : propertyKeyName(property.key);
973
+ if (key === undefined || key === "__proto__" || seen.has(key)) return true;
974
+ seen.add(key);
975
+ }
976
+ return false;
977
+ }
978
+
979
+ function isUnsupportedCompositeFactory(node: AnyNode, bindings: ReadonlySet<string>): boolean {
848
980
  if (node.type !== "VariableDeclarator" || !node.init) return false;
849
- return ["ConditionalExpression", "LogicalExpression", "SequenceExpression"].includes(node.init.type)
850
- && containsDirectOrchestration(node.init);
981
+ if (["ConditionalExpression", "LogicalExpression", "SequenceExpression"].includes(node.init.type)) {
982
+ return containsDirectOrchestration(node.init);
983
+ }
984
+ const init = node.init.type === "ChainExpression" ? node.init.expression : node.init;
985
+ if (init.type !== "CallExpression") return false;
986
+ if (init.callee?.type === "Identifier") return bindings.has(init.callee.name);
987
+ if (isFunctionNode(init.callee)) return containsOrchestrationCall(init.callee, bindings);
988
+ if (init.callee?.type !== "MemberExpression") {
989
+ return containsOrchestrationCall(init.callee, bindings)
990
+ || isOrchestrationExpression(init.callee, bindings);
991
+ }
992
+ if (isTaintedStaticMember(init.callee, bindings)) return true;
993
+ return init.callee.object?.type === "Identifier"
994
+ && hasTaintedMember(bindings, init.callee.object.name);
995
+ }
996
+
997
+ function isUnsupportedAnonymousHelperExtraction(node: AnyNode): boolean {
998
+ if (node.type !== "VariableDeclarator" || !node.init) return false;
999
+ const init = node.init.type === "ChainExpression" ? node.init.expression : node.init;
1000
+ if (node.id?.type === "ObjectPattern" || node.id?.type === "ArrayPattern") {
1001
+ return destructureSelectsOrchestration(node.id, init);
1002
+ }
1003
+ if (node.id?.type !== "Identifier" || init.type !== "MemberExpression") return false;
1004
+ const selected = selectedLiteralMember(init);
1005
+ return selected ? containsDirectOrchestration(selected) : containsDirectOrchestration(init.object);
1006
+ }
1007
+
1008
+ function isUnsupportedHelperDestructuringAssignment(
1009
+ node: AnyNode,
1010
+ bindings: ReadonlySet<string>,
1011
+ ): boolean {
1012
+ return node.type === "AssignmentExpression"
1013
+ && (node.left?.type === "ObjectPattern" || node.left?.type === "ArrayPattern")
1014
+ && (
1015
+ destructureSelectsOrchestration(node.left, node.right)
1016
+ || isOrchestrationExpression(node.right, bindings)
1017
+ );
1018
+ }
1019
+
1020
+ function destructureSelectsOrchestration(pattern: AnyNode, value: AnyNode): boolean {
1021
+ if (pattern.type === "ObjectPattern" && value.type === "ObjectExpression") {
1022
+ for (const selected of pattern.properties ?? []) {
1023
+ if (selected.type !== "Property") return true;
1024
+ const key = propertyKeyName(selected.key);
1025
+ if (key === undefined) return true;
1026
+ const match = findLastStaticProperty(value.properties ?? [], key);
1027
+ if (match && containsDirectOrchestration(match.value)) return true;
1028
+ }
1029
+ return false;
1030
+ }
1031
+ if (pattern.type === "ArrayPattern" && value.type === "ArrayExpression") {
1032
+ return (pattern.elements ?? []).some((selected: AnyNode | null, index: number) =>
1033
+ !!selected && !!value.elements?.[index] && containsDirectOrchestration(value.elements[index]));
1034
+ }
1035
+ return containsDirectOrchestration(value);
1036
+ }
1037
+
1038
+ function selectedLiteralMember(member: AnyNode): AnyNode | undefined {
1039
+ const key = propertyNameOf(member);
1040
+ if (key === undefined) return undefined;
1041
+ if (member.object?.type === "ObjectExpression") {
1042
+ return findLastStaticProperty(member.object.properties ?? [], key)?.value;
1043
+ }
1044
+ if (member.object?.type === "ArrayExpression" && /^\d+$/.test(key)) {
1045
+ return member.object.elements?.[Number(key)] ?? undefined;
1046
+ }
1047
+ return undefined;
1048
+ }
1049
+
1050
+ function findLastStaticProperty(properties: AnyNode[], key: string): AnyNode | undefined {
1051
+ for (let index = properties.length - 1; index >= 0; index--) {
1052
+ const property = properties[index];
1053
+ if (property.type !== "Property") continue;
1054
+ const candidate = property.computed ? staticStringOf(property.key) : propertyKeyName(property.key);
1055
+ if (candidate === key) return property;
1056
+ }
1057
+ return undefined;
851
1058
  }
852
1059
 
853
1060
  function isUnsupportedHelperAssignment(node: AnyNode, bindings: ReadonlySet<string>): boolean {
@@ -886,6 +1093,12 @@ function isOrchestrationBindingEscape(
886
1093
  }
887
1094
 
888
1095
  function isTaintedStaticMember(node: AnyNode, bindings: ReadonlySet<string>): boolean {
1096
+ const selected = selectedLiteralMember(node);
1097
+ if (selected) {
1098
+ return isFunctionNode(selected)
1099
+ ? containsOrchestrationCall(selected, bindings)
1100
+ : isOrchestrationExpression(selected, bindings);
1101
+ }
889
1102
  const member = staticMemberReference(node);
890
1103
  if (!member) return isOrchestrationExpression(node.object, bindings);
891
1104
  if (bindings.has(member) || hasTaintedMember(bindings, member)) return true;
@@ -933,6 +1146,14 @@ function isForbiddenPromiseDestructure(node: AnyNode, parent?: AnyNode): boolean
933
1146
  && ["then", "catch", "finally"].includes(propertyNameOf(node) ?? propertyKeyName(node.key) ?? "");
934
1147
  }
935
1148
 
1149
+ function isTaintedTaggedCall(node: AnyNode, bindings: ReadonlySet<string>): boolean {
1150
+ if (node.type !== "TaggedTemplateExpression") return false;
1151
+ if (node.tag?.type === "Identifier") return bindings.has(node.tag.name);
1152
+ if (node.tag?.type === "MemberExpression") return isTaintedStaticMember(node.tag, bindings);
1153
+ return node.tag?.type === "ChainExpression"
1154
+ && isOrchestrationExpression(node.tag.expression, bindings);
1155
+ }
1156
+
936
1157
  function isDynamicMethodCall(node: AnyNode): boolean {
937
1158
  return node.type === "CallExpression"
938
1159
  && node.callee?.type === "MemberExpression"
@@ -953,13 +1174,8 @@ function isForbiddenPromiseCall(node: AnyNode): boolean {
953
1174
  && ["all", "allSettled", "race", "any"].includes(propertyNameOf(node.callee) ?? "");
954
1175
  }
955
1176
 
956
- function isForbiddenPromiseMember(node: AnyNode, parent?: AnyNode): boolean {
957
- if (node.type !== "MemberExpression") return false;
958
- const property = propertyNameOf(node);
959
- if (property === "Promise") return true;
960
- return ["all", "allSettled", "race", "any"].includes(property ?? "")
961
- && parent?.type === "CallExpression"
962
- && parent.callee === node;
1177
+ function isForbiddenPromiseMember(node: AnyNode, _parent?: AnyNode): boolean {
1178
+ return node.type === "MemberExpression" && propertyNameOf(node) === "Promise";
963
1179
  }
964
1180
 
965
1181
  function isAllowedPromiseReference(node: AnyNode, parent?: AnyNode, grandparent?: AnyNode): boolean {
@@ -1,7 +1,9 @@
1
1
  /**
2
- * Process-local registry of workflow runs, so the `/workflows` command can list
3
- * recent and in-flight runs and show live progress. Snapshots are updated in place
4
- * by the workflow tool as a run progresses.
2
+ * Process-local registry for one Pi extension session.
3
+ *
4
+ * A registry instance is permanently scoped by ownership: callers create one per
5
+ * extension/session and inject that same instance into the workflow tool and
6
+ * commands. No mutable process-global "current session" exists.
5
7
  */
6
8
 
7
9
  import * as fs from "node:fs";
@@ -16,15 +18,10 @@ export interface RunHandle {
16
18
  details?: WorkflowRunDetails;
17
19
  }
18
20
 
19
- interface RegistryScope {
20
- runs: Map<string, RunHandle>;
21
- order: string[];
22
- }
23
-
24
21
  export class WorkflowRegistry {
25
- private readonly scopes = new Map<string, RegistryScope>();
22
+ private readonly runs = new Map<string, RunHandle>();
23
+ private order: string[] = [];
26
24
  private readonly listeners = new Set<() => void>();
27
- private scopeDir?: string;
28
25
 
29
26
  register(
30
27
  runId: string,
@@ -32,13 +29,11 @@ export class WorkflowRegistry {
32
29
  abort: () => void,
33
30
  details?: WorkflowRunDetails,
34
31
  ): RunHandle {
35
- const scope = this.currentScope();
36
32
  const handle: RunHandle = { snapshot, abort, startedAt: Date.now(), details };
37
- scope.runs.set(runId, handle);
38
- scope.order = scope.order.filter((id) => id !== runId);
39
- scope.order.push(runId);
40
- // Keep at most the 50 most recent runs per session without evicting active work.
41
- this.trimRuns(scope);
33
+ this.runs.set(runId, handle);
34
+ this.order = this.order.filter((id) => id !== runId);
35
+ this.order.push(runId);
36
+ this.trimRuns();
42
37
  this.notify();
43
38
  return handle;
44
39
  }
@@ -58,10 +53,8 @@ export class WorkflowRegistry {
58
53
  }
59
54
  }
60
55
 
61
- /** Restore the newest task-detail manifests for the current Pi session. */
56
+ /** Restore the newest task-detail manifests owned by this registry session. */
62
57
  restoreRuns(runsDir: string): number {
63
- this.setScope(runsDir);
64
- const scope = this.currentScope();
65
58
  let files: string[];
66
59
  try {
67
60
  files = fs.readdirSync(runsDir)
@@ -72,10 +65,11 @@ export class WorkflowRegistry {
72
65
  } catch {
73
66
  return 0;
74
67
  }
68
+
75
69
  let restored = 0;
76
70
  for (const file of files) {
77
71
  const loaded = WorkflowRunDetails.restore(file);
78
- if (!loaded || scope.runs.has(loaded.snapshot.runId ?? loaded.details.runId)) continue;
72
+ if (!loaded || this.runs.has(loaded.snapshot.runId ?? loaded.details.runId)) continue;
79
73
  const runId = loaded.snapshot.runId ?? loaded.details.runId;
80
74
  if (loaded.snapshot.status === "running") {
81
75
  loaded.snapshot.status = "aborted";
@@ -83,7 +77,10 @@ export class WorkflowRegistry {
83
77
  if (agent.status !== "running") continue;
84
78
  agent.status = "cancelled";
85
79
  agent.currentTurn = undefined;
86
- loaded.details.finishTask(agent.id, { status: "cancelled", error: "Session ended before the task completed." });
80
+ loaded.details.finishTask(agent.id, {
81
+ status: "cancelled",
82
+ error: "Session ended before the task completed.",
83
+ });
87
84
  }
88
85
  loaded.snapshot = recompute(loaded.snapshot);
89
86
  loaded.details.persist(loaded.snapshot);
@@ -94,85 +91,65 @@ export class WorkflowRegistry {
94
91
  } catch {
95
92
  // Keep a deterministic in-memory fallback if the manifest disappears.
96
93
  }
97
- const handle: RunHandle = {
94
+ this.runs.set(runId, {
98
95
  snapshot: loaded.snapshot,
99
96
  abort: () => {},
100
97
  startedAt,
101
98
  details: loaded.details,
102
- };
103
- scope.runs.set(runId, handle);
104
- scope.order.push(runId);
99
+ });
100
+ this.order.push(runId);
105
101
  restored++;
106
102
  }
107
- this.trimRuns(scope);
103
+ this.trimRuns();
108
104
  if (restored) this.notify();
109
105
  return restored;
110
106
  }
111
107
 
112
- /** Select a session without discarding live handles from other sessions. */
113
- setScope(runsDir: string): void {
114
- const resolved = path.resolve(runsDir);
115
- if (this.scopeDir === resolved) return;
116
- this.scopeDir = resolved;
117
- this.currentScope();
118
- this.notify();
119
- }
120
-
121
108
  get(runId: string): RunHandle | undefined {
122
- return this.currentScope().runs.get(runId);
109
+ const handle = this.runs.get(runId);
110
+ return handle ? publicHandle(handle) : undefined;
123
111
  }
124
112
 
125
113
  list(): RunHandle[] {
126
- const scope = this.currentScope();
127
- return scope.order
128
- .map((id) => scope.runs.get(id))
114
+ return this.order
115
+ .map((id) => this.runs.get(id))
129
116
  .filter((handle): handle is RunHandle => Boolean(handle))
117
+ .map(publicHandle)
130
118
  .sort((left, right) => right.startedAt - left.startedAt);
131
119
  }
132
120
 
133
121
  active(): RunHandle[] {
134
- return this.list().filter((h) => h.snapshot.status === "running");
122
+ return this.list().filter((handle) => handle.snapshot.status === "running");
135
123
  }
136
124
 
137
125
  abortAll(): void {
138
- for (const handle of this.currentScope().runs.values()) {
139
- if (handle.snapshot.status === "running") {
140
- try {
141
- handle.abort();
142
- } catch {
143
- // ignore
144
- }
126
+ for (const handle of this.runs.values()) {
127
+ if (handle.snapshot.status !== "running") continue;
128
+ try {
129
+ handle.abort();
130
+ } catch {
131
+ // A failing callback must not prevent cancellation of sibling runs.
145
132
  }
146
133
  }
147
134
  }
148
135
 
149
- private currentScope(): RegistryScope {
150
- const key = this.scopeDir ?? "<default>";
151
- let scope = this.scopes.get(key);
152
- if (!scope) {
153
- scope = { runs: new Map(), order: [] };
154
- this.scopes.set(key, scope);
155
- }
156
- return scope;
157
- }
158
-
159
- private trimRuns(scope: RegistryScope): void {
160
- while (scope.runs.size > 50) {
161
- const candidate = [...scope.runs.entries()].sort((left, right) => {
136
+ private trimRuns(): void {
137
+ while (this.runs.size > 50) {
138
+ const candidate = [...this.runs.entries()].sort((left, right) => {
162
139
  const leftActive = left[1].snapshot.status === "running" ? 1 : 0;
163
140
  const rightActive = right[1].snapshot.status === "running" ? 1 : 0;
164
141
  return leftActive - rightActive || left[1].startedAt - right[1].startedAt;
165
142
  })[0];
166
143
  if (!candidate) break;
167
- scope.runs.delete(candidate[0]);
168
- scope.order = scope.order.filter((id) => id !== candidate[0]);
144
+ this.runs.delete(candidate[0]);
145
+ this.order = this.order.filter((id) => id !== candidate[0]);
169
146
  }
170
147
  }
171
148
  }
172
149
 
173
- let singleton: WorkflowRegistry | undefined;
174
-
175
- export function getRegistry(): WorkflowRegistry {
176
- if (!singleton) singleton = new WorkflowRegistry();
177
- return singleton;
150
+ function publicHandle(handle: RunHandle): RunHandle {
151
+ return {
152
+ ...handle,
153
+ snapshot: structuredClone(handle.snapshot),
154
+ };
178
155
  }