taskflow-core 0.2.8 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +53 -12
  2. package/dist/build-info.json +2 -2
  3. package/dist/detached-runner.js +4 -1
  4. package/dist/detached-runner.js.map +1 -1
  5. package/dist/exec/driver.d.ts +6 -0
  6. package/dist/exec/driver.d.ts.map +1 -1
  7. package/dist/exec/driver.js +4 -0
  8. package/dist/exec/driver.js.map +1 -1
  9. package/dist/exec/step-kinds.d.ts.map +1 -1
  10. package/dist/exec/step-kinds.js +10 -3
  11. package/dist/exec/step-kinds.js.map +1 -1
  12. package/dist/exec/step.d.ts +9 -0
  13. package/dist/exec/step.d.ts.map +1 -1
  14. package/dist/exec/step.js +23 -4
  15. package/dist/exec/step.js.map +1 -1
  16. package/dist/final-output.d.ts +6 -5
  17. package/dist/final-output.d.ts.map +1 -1
  18. package/dist/final-output.js +14 -10
  19. package/dist/final-output.js.map +1 -1
  20. package/dist/flowir/compile.d.ts.map +1 -1
  21. package/dist/flowir/compile.js +1 -0
  22. package/dist/flowir/compile.js.map +1 -1
  23. package/dist/flowir/phasefp.d.ts.map +1 -1
  24. package/dist/flowir/phasefp.js +1 -0
  25. package/dist/flowir/phasefp.js.map +1 -1
  26. package/dist/library/search.d.ts.map +1 -1
  27. package/dist/library/search.js +2 -2
  28. package/dist/library/search.js.map +1 -1
  29. package/dist/resources/baseline.d.ts +1 -1
  30. package/dist/resources/baseline.d.ts.map +1 -1
  31. package/dist/resources/baseline.js +1 -1
  32. package/dist/resources/baseline.js.map +1 -1
  33. package/dist/resume.d.ts.map +1 -1
  34. package/dist/resume.js +2 -0
  35. package/dist/resume.js.map +1 -1
  36. package/dist/runner-core.d.ts +17 -1
  37. package/dist/runner-core.d.ts.map +1 -1
  38. package/dist/runner-core.js +33 -12
  39. package/dist/runner-core.js.map +1 -1
  40. package/dist/runtime/phases/script.d.ts +13 -0
  41. package/dist/runtime/phases/script.d.ts.map +1 -1
  42. package/dist/runtime/phases/script.js +25 -1
  43. package/dist/runtime/phases/script.js.map +1 -1
  44. package/dist/runtime.d.ts +17 -5
  45. package/dist/runtime.d.ts.map +1 -1
  46. package/dist/runtime.js +60 -22
  47. package/dist/runtime.js.map +1 -1
  48. package/dist/schema.d.ts +1 -0
  49. package/dist/schema.d.ts.map +1 -1
  50. package/dist/schema.js +4 -0
  51. package/dist/schema.js.map +1 -1
  52. package/dist/store.d.ts +24 -14
  53. package/dist/store.d.ts.map +1 -1
  54. package/dist/store.js +606 -93
  55. package/dist/store.js.map +1 -1
  56. package/package.json +2 -2
package/dist/store.js CHANGED
@@ -765,90 +765,466 @@ function cleanupRunArtifactsIfSnapshotMatches(runsRoot, entry) {
765
765
  function userFlowsDir() {
766
766
  return path.join(getAgentDir(), "taskflows");
767
767
  }
768
+ function canonicalDiscoveryPath(input) {
769
+ const absolute = path.resolve(input);
770
+ try {
771
+ return fs.realpathSync.native(absolute);
772
+ }
773
+ catch {
774
+ return absolute;
775
+ }
776
+ }
777
+ function sameDiscoveryPath(a, b) {
778
+ if (process.platform === "win32")
779
+ return a.toLowerCase() === b.toLowerCase();
780
+ return a === b;
781
+ }
768
782
  function findProjectFlowsDirInternal(cwd, create = false) {
769
783
  // Prefer an existing .pi dir up the tree; else use cwd/.pi when creating.
770
- // **Never treat `~/.pi/` as a project flow dir** — the home directory is
771
- // the user-scope boundary, and the user's `~/.pi/` is the agent dir, not a
772
- // project. We skip the home entry entirely during the walk-up, so even a
773
- // deeply nested cwd under home will return null (create=false) when no
774
- // project `.pi` exists on the path.
775
- const home = os.homedir();
776
- let dir = cwd;
784
+ // **Never inherit `~/.pi/` or the shared OS temp root's `.pi/` while walking
785
+ // ancestors.** Resolve physical paths first so relative cwd values and symlink
786
+ // aliases cannot bypass either boundary. An explicit create at cwd still uses
787
+ // cwd/.pi; only ancestor discovery stops at these user/shared boundaries.
788
+ const home = canonicalDiscoveryPath(os.homedir());
789
+ const tempRoot = canonicalDiscoveryPath(os.tmpdir());
790
+ const canonicalCwd = canonicalDiscoveryPath(cwd);
791
+ let dir = canonicalCwd;
777
792
  while (true) {
778
- if (dir !== home) {
779
- const candidate = path.join(dir, ".pi");
780
- if (fs.existsSync(candidate))
781
- return path.join(candidate, "taskflows");
782
- }
793
+ if (sameDiscoveryPath(dir, home) || sameDiscoveryPath(dir, tempRoot))
794
+ break;
795
+ const candidate = path.join(dir, ".pi");
796
+ if (fs.existsSync(candidate))
797
+ return path.join(candidate, "taskflows");
783
798
  const parent = path.dirname(dir);
784
799
  if (parent === dir)
785
800
  break;
786
801
  dir = parent;
787
802
  }
788
- return create ? path.join(cwd, ".pi", "taskflows") : null;
803
+ return create ? path.join(canonicalCwd, ".pi", "taskflows") : null;
804
+ }
805
+ const MAX_FLOW_DEFINITION_BYTES = 1_048_576; // 1 MiB per JSON/JSONC/defineFile
806
+ const MAX_DISCOVERY_TOTAL_BYTES = 8_388_608; // 8 MiB across user + project discovery
807
+ function sameFileStat(a, b) {
808
+ return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
809
+ }
810
+ function sameDirectoryIdentityValue(a, b) {
811
+ return !!a && !!b && a.canonicalPath === b.canonicalPath && a.device === b.device && a.inode === b.inode;
812
+ }
813
+ /** Read a bounded regular file through one descriptor and bind parsed content to
814
+ * a canonical path + parent identity. Symlink leaves and files changed during
815
+ * the read fail closed. */
816
+ function loadStableSource(filePath, parse, budget, allowedRootReal) {
817
+ const allowedRoots = allowedRootReal === undefined
818
+ ? []
819
+ : (typeof allowedRootReal === "string" ? [allowedRootReal] : [...allowedRootReal]);
820
+ const isAllowedSource = (candidate) => allowedRoots.length === 0 || allowedRoots.some((root) => isPhysicallyContained(root, candidate));
821
+ let fd;
822
+ try {
823
+ const lexicalStat = fs.lstatSync(filePath, { bigint: true });
824
+ if (lexicalStat.isSymbolicLink() || !lexicalStat.isFile()) {
825
+ throw new Error("definition must be a regular non-symlink file");
826
+ }
827
+ const canonicalBefore = fs.realpathSync.native(filePath);
828
+ if (allowedRoots.length > 0 && (!sameDiscoveryPath(canonicalBefore, path.resolve(filePath)) ||
829
+ !isAllowedSource(canonicalBefore))) {
830
+ throw new Error("definition escaped or changed its canonical saved-flow root");
831
+ }
832
+ const sourceDirBefore = directoryIdentity(path.dirname(canonicalBefore));
833
+ if (!sourceDirBefore)
834
+ throw new Error("definition parent directory identity is unavailable");
835
+ const noFollow = process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW;
836
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
837
+ const before = fs.fstatSync(fd, { bigint: true });
838
+ if (!before.isFile())
839
+ throw new Error("definition must remain a regular file");
840
+ if (before.size > BigInt(MAX_FLOW_DEFINITION_BYTES)) {
841
+ if (budget)
842
+ budget.exceeded = true;
843
+ throw new Error(`definition exceeds ${MAX_FLOW_DEFINITION_BYTES} byte limit`);
844
+ }
845
+ const byteLength = Number(before.size);
846
+ if (budget && budget.bytes + byteLength > MAX_DISCOVERY_TOTAL_BYTES) {
847
+ budget.exceeded = true;
848
+ throw new Error(`flow discovery exceeds ${MAX_DISCOVERY_TOTAL_BYTES} cumulative byte limit`);
849
+ }
850
+ if (budget)
851
+ budget.bytes += byteLength;
852
+ const buffer = Buffer.allocUnsafe(byteLength + 1);
853
+ let bytesRead = 0;
854
+ while (bytesRead < buffer.length) {
855
+ const count = fs.readSync(fd, buffer, bytesRead, buffer.length - bytesRead, null);
856
+ if (count === 0)
857
+ break;
858
+ bytesRead += count;
859
+ }
860
+ const after = fs.fstatSync(fd, { bigint: true });
861
+ if (bytesRead !== byteLength || !sameFileStat(before, after)) {
862
+ throw new Error("definition changed while it was being read");
863
+ }
864
+ const lexicalAfter = fs.lstatSync(filePath, { bigint: true });
865
+ if (lexicalAfter.isSymbolicLink() || !sameFileStat(after, lexicalAfter)) {
866
+ throw new Error("definition path identity changed while it was being read");
867
+ }
868
+ const canonicalAfter = fs.realpathSync.native(filePath);
869
+ const sourceDirAfter = directoryIdentity(path.dirname(canonicalAfter));
870
+ if (canonicalAfter !== canonicalBefore ||
871
+ (allowedRoots.length > 0 && !isAllowedSource(canonicalAfter)) ||
872
+ !sameDirectoryIdentityValue(sourceDirBefore, sourceDirAfter)) {
873
+ throw new Error("definition parent directory changed while it was being read");
874
+ }
875
+ const raw = buffer.subarray(0, bytesRead).toString("utf8");
876
+ return {
877
+ ok: true,
878
+ value: {
879
+ value: parse(raw),
880
+ filePath: canonicalAfter,
881
+ sourceDirIdentity: sourceDirAfter,
882
+ byteLength,
883
+ },
884
+ };
885
+ }
886
+ catch (error) {
887
+ const code = error.code;
888
+ return {
889
+ ok: false,
890
+ reason: code === "ENOENT" || code === "EACCES" ? "missing" : "unparseable",
891
+ path: filePath,
892
+ detail: errMessage(error),
893
+ };
894
+ }
895
+ finally {
896
+ if (fd !== undefined) {
897
+ try {
898
+ fs.closeSync(fd);
899
+ }
900
+ catch { /* best effort */ }
901
+ }
902
+ }
903
+ }
904
+ /** Stable defineFile loader used by execution hosts that need source provenance. */
905
+ export function readDefineFileWithSource(filePath, allowedRootReal) {
906
+ const loaded = loadStableSource(filePath, (raw) => parseStrict(raw, { allowFence: true }), undefined, allowedRootReal);
907
+ if (!loaded.ok)
908
+ return loaded;
909
+ return {
910
+ ok: true,
911
+ value: {
912
+ value: loaded.value.value,
913
+ filePath: loaded.value.filePath,
914
+ sourceDirIdentity: loaded.value.sourceDirIdentity,
915
+ },
916
+ };
789
917
  }
790
- /**
791
- * Read a flow definition from a file on disk. Supports raw JSON or a Markdown
792
- * document with a fenced ```json block. Used by the `defineFile` parameter so
793
- * verify/compile/run can share one persisted draft (e.g. in the OS temp dir)
794
- * without saving it into the project's .pi/taskflows.
795
- *
796
- * Returns a `LoadResult`: `{ ok: true, value }` on success, or
797
- * `{ ok: false, reason: "missing" | "unparseable", ... }` on failure. Callers
798
- * surface an explicit error via `describeLoadFailure`.
799
- */
800
918
  export function readDefineFile(filePath) {
801
- return loadFile(filePath, (raw) => parseStrict(raw, { allowFence: true }));
919
+ const loaded = readDefineFileWithSource(filePath);
920
+ return loaded.ok ? { ok: true, value: loaded.value.value } : loaded;
802
921
  }
803
- function readFlowFile(filePath, scope) {
804
- const r = loadFile(filePath, (raw) => parseJsonc(raw));
922
+ function readFlowFile(filePath, scope, budget, allowedRootReal) {
923
+ const r = loadStableSource(filePath, (raw) => parseJsonc(raw), budget, allowedRootReal);
805
924
  if (!r.ok)
806
925
  return r;
807
- if (!r.value?.name) {
926
+ if (!r.value.value?.name) {
808
927
  return { ok: false, reason: "unparseable", path: filePath, detail: "parsed OK but missing required field: name" };
809
928
  }
810
- return { ok: true, value: { name: r.value.name, scope, filePath, def: r.value } };
929
+ return {
930
+ ok: true,
931
+ value: {
932
+ name: r.value.value.name,
933
+ scope,
934
+ filePath: r.value.filePath,
935
+ sourceDirIdentity: r.value.sourceDirIdentity,
936
+ def: r.value.value,
937
+ },
938
+ };
939
+ }
940
+ const NESTED_FLOWS_DIR = "flows";
941
+ const MAX_NESTED_FLOW_DEPTH = 16;
942
+ const MAX_DISCOVERY_FILES = 1_000;
943
+ const MAX_DISCOVERY_ENTRIES = 10_000;
944
+ const MAX_DISCOVERY_DIRS = 512;
945
+ function codePointCompare(a, b) {
946
+ const left = Array.from(a);
947
+ const right = Array.from(b);
948
+ const length = Math.min(left.length, right.length);
949
+ for (let i = 0; i < length; i++) {
950
+ const l = left[i].codePointAt(0);
951
+ const r = right[i].codePointAt(0);
952
+ if (l !== r)
953
+ return l - r;
954
+ }
955
+ return left.length - right.length;
956
+ }
957
+ function isFlowDefinitionFile(name) {
958
+ return name.endsWith(".json") && !name.endsWith(".meta.json") && !name.endsWith(".flowir.json");
959
+ }
960
+ /** The 0.2.9 top-level scanner excluded only metadata sidecars. In particular,
961
+ * a valid flow named `release.flowir` is stored as `release.flowir.json` and
962
+ * must remain discoverable. The new nested convention can still reserve that
963
+ * suffix for generated FlowIR artifacts. */
964
+ function isLegacyFlowDefinitionFile(name) {
965
+ return name.endsWith(".json") && !name.endsWith(".meta.json");
966
+ }
967
+ function isPhysicallyContained(rootReal, candidateReal) {
968
+ const relative = path.relative(rootReal, candidateReal);
969
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
970
+ }
971
+ /** Validate every directory component from a trusted boundary (`.pi` for a
972
+ * project, agent root for user flows) through the taskflows storage root.
973
+ * Only an explicitly configured user agent boundary may itself be a symlink. */
974
+ function validateStorageRoot(root, boundary, allowBoundarySymlink = false) {
975
+ const rootAbs = path.resolve(root);
976
+ const boundaryAbs = path.resolve(boundary);
977
+ const lexicalRelative = path.relative(boundaryAbs, rootAbs);
978
+ if (lexicalRelative.startsWith("..") || path.isAbsolute(lexicalRelative))
979
+ return undefined;
980
+ // The configured trust boundary itself may be a symlink (for example a
981
+ // user moving ~/.pi/agent to another disk). Preserve that historical setup,
982
+ // while still rejecting every symlink component *below* the boundary.
983
+ let current = rootAbs;
984
+ for (;;) {
985
+ const atBoundary = sameDiscoveryPath(current, boundaryAbs);
986
+ if (atBoundary && allowBoundarySymlink)
987
+ break;
988
+ try {
989
+ const stat = fs.lstatSync(current);
990
+ if (stat.isSymbolicLink() || !stat.isDirectory())
991
+ return undefined;
992
+ }
993
+ catch {
994
+ return undefined;
995
+ }
996
+ if (atBoundary)
997
+ break;
998
+ const parent = path.dirname(current);
999
+ if (parent === current)
1000
+ return undefined;
1001
+ current = parent;
1002
+ }
1003
+ try {
1004
+ const boundaryReal = fs.realpathSync.native(boundaryAbs);
1005
+ const rootReal = fs.realpathSync.native(rootAbs);
1006
+ if (!fs.statSync(boundaryReal).isDirectory() || !fs.statSync(rootReal).isDirectory())
1007
+ return undefined;
1008
+ return isPhysicallyContained(boundaryReal, rootReal) ? rootReal : undefined;
1009
+ }
1010
+ catch {
1011
+ return undefined;
1012
+ }
1013
+ }
1014
+ function readDirectoryBounded(dir, budget) {
1015
+ if (budget.exceeded || budget.directories >= MAX_DISCOVERY_DIRS) {
1016
+ budget.exceeded = true;
1017
+ return [];
1018
+ }
1019
+ budget.directories++;
1020
+ let handle;
1021
+ try {
1022
+ handle = fs.opendirSync(dir);
1023
+ }
1024
+ catch {
1025
+ return [];
1026
+ }
1027
+ const entries = [];
1028
+ try {
1029
+ for (;;) {
1030
+ const entry = handle.readSync();
1031
+ if (!entry)
1032
+ break;
1033
+ budget.entries++;
1034
+ if (budget.entries > MAX_DISCOVERY_ENTRIES) {
1035
+ budget.exceeded = true;
1036
+ break;
1037
+ }
1038
+ entries.push(entry);
1039
+ }
1040
+ }
1041
+ catch {
1042
+ // Preserve the legacy listFlows contract: an unreadable or concurrently
1043
+ // replaced directory is skipped rather than escaping as a process-level
1044
+ // exception. Discard a partial read so precedence never depends on where
1045
+ // the failure happened.
1046
+ return [];
1047
+ }
1048
+ finally {
1049
+ try {
1050
+ handle.closeSync();
1051
+ }
1052
+ catch { /* unreadable/replaced directory: skip */ }
1053
+ }
1054
+ return entries.sort((a, b) => codePointCompare(a.name, b.name));
1055
+ }
1056
+ function reserveFlowCandidate(budget) {
1057
+ budget.files++;
1058
+ if (budget.files > MAX_DISCOVERY_FILES) {
1059
+ budget.exceeded = true;
1060
+ return false;
1061
+ }
1062
+ return true;
1063
+ }
1064
+ function canonicalRegularFile(candidate, rootReal) {
1065
+ try {
1066
+ const stat = fs.lstatSync(candidate);
1067
+ if (stat.isSymbolicLink() || !stat.isFile())
1068
+ return undefined;
1069
+ const real = fs.realpathSync.native(candidate);
1070
+ return isPhysicallyContained(rootReal, real) ? real : undefined;
1071
+ }
1072
+ catch {
1073
+ return undefined;
1074
+ }
1075
+ }
1076
+ function listLegacyFlowFiles(rootReal, budget) {
1077
+ const files = [];
1078
+ for (const entry of readDirectoryBounded(rootReal, budget)) {
1079
+ if (budget.exceeded)
1080
+ break;
1081
+ // Legacy 0.2.9 discovery accepted hidden top-level JSON definitions. Keep
1082
+ // that compatibility at the storage root; only the new recursive `flows/`
1083
+ // convention skips hidden entries/directories.
1084
+ if (entry.isSymbolicLink() || !entry.isFile() || !isLegacyFlowDefinitionFile(entry.name))
1085
+ continue;
1086
+ const real = canonicalRegularFile(path.join(rootReal, entry.name), rootReal);
1087
+ if (!real || !reserveFlowCandidate(budget))
1088
+ continue;
1089
+ files.push(real);
1090
+ }
1091
+ return files;
1092
+ }
1093
+ /** Deterministically discover ordinary JSON files below `<root>/flows/` under
1094
+ * one shared user+project budget. Every path component below the validated
1095
+ * storage root must remain a regular non-symlink directory/file. */
1096
+ function listNestedFlowFiles(rootReal, budget) {
1097
+ const conventionRoot = path.join(rootReal, NESTED_FLOWS_DIR);
1098
+ let conventionReal;
1099
+ try {
1100
+ const stat = fs.lstatSync(conventionRoot);
1101
+ if (stat.isSymbolicLink() || !stat.isDirectory())
1102
+ return [];
1103
+ conventionReal = fs.realpathSync.native(conventionRoot);
1104
+ if (!isPhysicallyContained(rootReal, conventionReal))
1105
+ return [];
1106
+ }
1107
+ catch {
1108
+ return [];
1109
+ }
1110
+ const found = [];
1111
+ const visit = (dir, depth) => {
1112
+ if (budget.exceeded || depth > MAX_NESTED_FLOW_DEPTH) {
1113
+ budget.exceeded = true;
1114
+ return;
1115
+ }
1116
+ for (const entry of readDirectoryBounded(dir, budget)) {
1117
+ if (budget.exceeded)
1118
+ break;
1119
+ if (entry.name.startsWith(".") || entry.isSymbolicLink())
1120
+ continue;
1121
+ const candidate = path.join(dir, entry.name);
1122
+ if (entry.isDirectory()) {
1123
+ try {
1124
+ const stat = fs.lstatSync(candidate);
1125
+ if (stat.isSymbolicLink() || !stat.isDirectory())
1126
+ continue;
1127
+ const real = fs.realpathSync.native(candidate);
1128
+ if (!isPhysicallyContained(conventionReal, real))
1129
+ continue;
1130
+ visit(real, depth + 1);
1131
+ }
1132
+ catch {
1133
+ // Entry disappeared or changed; skip safely.
1134
+ }
1135
+ }
1136
+ else if (entry.isFile() && isFlowDefinitionFile(entry.name)) {
1137
+ const real = canonicalRegularFile(candidate, conventionReal);
1138
+ if (!real || !reserveFlowCandidate(budget))
1139
+ continue;
1140
+ found.push(real);
1141
+ }
1142
+ }
1143
+ };
1144
+ visit(conventionReal, 0);
1145
+ return found.sort(codePointCompare);
1146
+ }
1147
+ function discoveryLimitDetail() {
1148
+ return `${MAX_DISCOVERY_FILES} flows, ${MAX_DISCOVERY_ENTRIES} entries, ` +
1149
+ `${MAX_DISCOVERY_DIRS} directories, ${MAX_DISCOVERY_TOTAL_BYTES} bytes, ` +
1150
+ `${MAX_FLOW_DEFINITION_BYTES} bytes/file, depth ${MAX_NESTED_FLOW_DEPTH}`;
1151
+ }
1152
+ function warnDiscoveryLimit() {
1153
+ console.warn(`[taskflow] Saved flow discovery failed closed after exceeding a safety limit (${discoveryLimitDetail()}).`);
811
1154
  }
812
- /** List all saved flows (project overrides user on name collision). */
813
1155
  /** Internal-but-exported for tests: walk-up `.pi` finder with home-dir stop. */
814
1156
  export function findProjectFlowsDir(cwd, create = false) {
815
1157
  return findProjectFlowsDirInternal(cwd, create);
816
1158
  }
817
- export function listFlows(cwd) {
1159
+ /** One bounded discovery pass shared by list/get/diagnosed lookup. */
1160
+ function discoverFlows(cwd) {
818
1161
  const map = new Map();
819
- const dirs = [{ dir: userFlowsDir(), scope: "user" }];
1162
+ const scopedFlows = [];
1163
+ const failures = [];
1164
+ const diagnostics = [];
1165
+ const budget = { entries: 0, directories: 0, files: 0, bytes: 0, exceeded: false };
1166
+ const agentRoot = getAgentDir();
1167
+ const dirs = [
1168
+ { dir: userFlowsDir(), boundary: agentRoot, scope: "user" },
1169
+ ];
820
1170
  const projDir = findProjectFlowsDir(cwd);
821
1171
  if (projDir)
822
- dirs.push({ dir: projDir, scope: "project" });
823
- for (const { dir, scope } of dirs) {
824
- if (!fs.existsSync(dir))
825
- continue;
826
- let entries;
827
- try {
828
- entries = fs.readdirSync(dir);
829
- }
830
- catch {
1172
+ dirs.push({ dir: projDir, boundary: path.dirname(projDir), scope: "project" });
1173
+ for (const { dir, boundary, scope } of dirs) {
1174
+ const rootReal = validateStorageRoot(dir, boundary, scope === "user");
1175
+ if (!rootReal)
831
1176
  continue;
832
- }
833
- for (const name of entries) {
834
- if (!name.endsWith(".json"))
835
- continue;
836
- // A1: sidecar .meta.json must never be scanned as a candidate flow.
837
- if (name.endsWith(".meta.json"))
838
- continue;
839
- const r = readFlowFile(path.join(dir, name), scope);
1177
+ const legacyFiles = listLegacyFlowFiles(rootReal, budget);
1178
+ const nestedFiles = listNestedFlowFiles(rootReal, budget);
1179
+ if (budget.exceeded)
1180
+ break;
1181
+ const scopeFlows = new Map();
1182
+ for (const filePath of [...legacyFiles, ...nestedFiles]) {
1183
+ const r = readFlowFile(filePath, scope, budget, rootReal);
1184
+ if (budget.exceeded)
1185
+ break;
840
1186
  if (r.ok) {
841
- map.set(r.value.name, r.value); // project after user → overrides
1187
+ // Candidates are precedence-ordered: legacy top-level first, then
1188
+ // Unicode-scalar sorted nested paths. The first same-scope definition wins.
1189
+ const existing = scopeFlows.get(r.value.name);
1190
+ if (!existing) {
1191
+ scopeFlows.set(r.value.name, r.value);
1192
+ }
1193
+ else {
1194
+ diagnostics.push(`[taskflow] duplicate saved flow name '${r.value.name}' in ${scope} scope; ` +
1195
+ `using ${path.relative(rootReal, existing.filePath)} and ignoring ${path.relative(rootReal, filePath)}`);
1196
+ }
842
1197
  }
843
1198
  else if (r.reason === "unparseable") {
844
- // A corrupt saved flow used to be silently dropped here, so `getFlow`
845
- // would later report "not found" for a file that clearly exists.
846
- // Surface it loudly instead — the detail carries line/column.
847
- console.warn(`[taskflow] saved flow is corrupt and was excluded from the list: ${name} — ${r.detail}`);
1199
+ failures.push({ scope, filePath, result: r });
1200
+ diagnostics.push(`[taskflow] saved flow is corrupt and was excluded from the list: ${path.relative(rootReal, filePath)} — ${r.detail}`);
848
1201
  }
849
1202
  }
1203
+ if (budget.exceeded)
1204
+ break;
1205
+ for (const flow of scopeFlows.values()) {
1206
+ scopedFlows.push(flow);
1207
+ map.set(flow.name, flow);
1208
+ }
1209
+ }
1210
+ return {
1211
+ flows: Array.from(map.values()).sort((a, b) => codePointCompare(a.name, b.name)),
1212
+ scopedFlows,
1213
+ failures,
1214
+ diagnostics,
1215
+ exceeded: budget.exceeded,
1216
+ };
1217
+ }
1218
+ /** List all saved flows (project overrides user on name collision). */
1219
+ export function listFlows(cwd) {
1220
+ const discovery = discoverFlows(cwd);
1221
+ for (const diagnostic of discovery.diagnostics)
1222
+ console.warn(diagnostic);
1223
+ if (discovery.exceeded) {
1224
+ warnDiscoveryLimit();
1225
+ return [];
850
1226
  }
851
- return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name));
1227
+ return discovery.flows;
852
1228
  }
853
1229
  export function getFlow(cwd, name) {
854
1230
  return listFlows(cwd).find((f) => f.name === name) ?? null;
@@ -856,40 +1232,128 @@ export function getFlow(cwd, name) {
856
1232
  /**
857
1233
  * Resolve a saved flow by name with diagnosable failure. Unlike `getFlow`
858
1234
  * (which returns `null` for both "no such flow" and "file exists but corrupt",
859
- * because corrupt files are excluded from `listFlows`), this re-reads the
860
- * candidate file directly so callers can report *why* a name didn't resolve.
1235
+ * because corrupt files are excluded from `listFlows`), this consumes the same
1236
+ * bounded discovery snapshot and reports a matching corrupt candidate without
1237
+ * rescanning the namespace.
861
1238
  */
862
1239
  export function getFlowDiagnosed(cwd, name) {
863
- const found = getFlow(cwd, name);
1240
+ const discovery = discoverFlows(cwd);
1241
+ if (discovery.exceeded) {
1242
+ return {
1243
+ ok: false,
1244
+ reason: "unparseable",
1245
+ path: name,
1246
+ detail: `saved flow discovery exceeded a safety limit (${discoveryLimitDetail()})`,
1247
+ };
1248
+ }
1249
+ const found = discovery.flows.find((flow) => flow.name === name);
864
1250
  if (found)
865
1251
  return { ok: true, value: found };
866
- // Not in the list — check whether a file exists for this name but is corrupt.
867
- const candidates = [
868
- { dir: userFlowsDir(), scope: "user" },
869
- ];
870
- const projDir = findProjectFlowsDir(cwd);
871
- if (projDir)
872
- candidates.push({ dir: projDir, scope: "project" });
873
- for (const { dir, scope } of candidates) {
874
- const filePath = path.join(dir, `${safeFlowDirName(name)}.json`);
875
- if (fs.existsSync(filePath)) {
876
- const r = readFlowFile(filePath, scope);
877
- if (!r.ok)
878
- return r; // unparseable (or a missing-in-race) — surface detail
879
- }
880
- }
1252
+ // Preserve the old filename-based diagnosis, but consume the failures already
1253
+ // captured by the same bounded pass. Project scope wins over user scope.
1254
+ const expectedFilename = `${safeFlowDirName(name)}.json`;
1255
+ const matching = discovery.failures
1256
+ .filter((failure) => path.basename(failure.filePath) === expectedFilename)
1257
+ .sort((a, b) => (a.scope === b.scope ? 0 : a.scope === "project" ? -1 : 1))[0];
1258
+ if (matching)
1259
+ return matching.result;
881
1260
  return { ok: false, reason: "missing", path: name, detail: `no saved flow named '${name}'` };
882
1261
  }
883
1262
  let _piCreationHinted = false;
1263
+ function ensureProjectStorageRoot(root, boundary) {
1264
+ const rootAbs = path.resolve(root);
1265
+ const boundaryAbs = path.resolve(boundary);
1266
+ if (!sameDiscoveryPath(path.dirname(rootAbs), boundaryAbs)) {
1267
+ throw new Error("unsafe saved-flow storage: project root is not directly below .pi");
1268
+ }
1269
+ const ensurePlainDirectory = (dir, parentIdentity) => {
1270
+ try {
1271
+ const stat = fs.lstatSync(dir);
1272
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
1273
+ throw new Error("unsafe saved-flow storage: project storage boundary must be a non-symlink directory");
1274
+ }
1275
+ }
1276
+ catch (error) {
1277
+ if (error.code !== "ENOENT")
1278
+ throw error;
1279
+ if (!sameDirectoryIdentityValue(parentIdentity, directoryIdentity(path.dirname(dir)))) {
1280
+ throw new Error("unsafe saved-flow storage: project storage parent changed before creation");
1281
+ }
1282
+ try {
1283
+ fs.mkdirSync(dir);
1284
+ }
1285
+ catch (mkdirError) {
1286
+ if (mkdirError.code !== "EEXIST")
1287
+ throw mkdirError;
1288
+ }
1289
+ const created = fs.lstatSync(dir);
1290
+ if (created.isSymbolicLink() || !created.isDirectory()) {
1291
+ throw new Error("unsafe saved-flow storage: created project storage component is not a plain directory");
1292
+ }
1293
+ }
1294
+ const identity = directoryIdentity(dir);
1295
+ if (!identity || !sameDirectoryIdentityValue(parentIdentity, directoryIdentity(path.dirname(dir)))) {
1296
+ throw new Error("unsafe saved-flow storage: project storage changed during creation");
1297
+ }
1298
+ return identity;
1299
+ };
1300
+ const boundaryParent = path.dirname(boundaryAbs);
1301
+ const boundaryParentIdentity = directoryIdentity(boundaryParent);
1302
+ if (!boundaryParentIdentity) {
1303
+ throw new Error("unsafe saved-flow storage: project directory identity is unavailable");
1304
+ }
1305
+ const boundaryIdentity = ensurePlainDirectory(boundaryAbs, boundaryParentIdentity);
1306
+ ensurePlainDirectory(rootAbs, boundaryIdentity);
1307
+ }
1308
+ /** Preserve the path of an already-discovered flow in the requested scope.
1309
+ * New definitions retain the legacy top-level save location. */
1310
+ function resolveFlowSaveTarget(cwd, flowName, scope) {
1311
+ const discovery = discoverFlows(cwd);
1312
+ if (discovery.exceeded) {
1313
+ throw new Error(`cannot safely resolve saved flow path: discovery exceeded a safety limit (${discoveryLimitDetail()})`);
1314
+ }
1315
+ const existing = discovery.scopedFlows.find((flow) => flow.scope === scope && flow.name === flowName);
1316
+ if (existing) {
1317
+ return {
1318
+ dir: path.dirname(existing.filePath),
1319
+ filePath: existing.filePath,
1320
+ expectedDirIdentity: existing.sourceDirIdentity,
1321
+ };
1322
+ }
1323
+ const requestedDir = scope === "user" ? userFlowsDir() : (findProjectFlowsDir(cwd, true) ?? path.join(cwd, ".pi", "taskflows"));
1324
+ const boundary = scope === "user" ? getAgentDir() : path.dirname(requestedDir);
1325
+ if (scope === "project")
1326
+ ensureProjectStorageRoot(requestedDir, boundary);
1327
+ else
1328
+ fs.mkdirSync(requestedDir, { recursive: true });
1329
+ const dir = validateStorageRoot(requestedDir, boundary, scope === "user");
1330
+ if (!dir)
1331
+ throw new Error("unsafe saved-flow storage: target is outside a trusted storage root");
1332
+ const expectedDirIdentity = directoryIdentity(dir);
1333
+ if (!expectedDirIdentity)
1334
+ throw new Error("unsafe saved-flow storage: target directory identity is unavailable");
1335
+ return {
1336
+ dir,
1337
+ filePath: path.join(dir, `${safeFlowDirName(flowName)}.json`),
1338
+ expectedDirIdentity,
1339
+ };
1340
+ }
1341
+ function assertFlowSaveTarget(target) {
1342
+ if (!sameDirectoryIdentityValue(target.expectedDirIdentity, directoryIdentity(target.dir))) {
1343
+ throw new Error("saved flow parent directory changed before write");
1344
+ }
1345
+ }
884
1346
  export function saveFlow(cwd, def, scope = "project") {
885
- const dir = scope === "user" ? userFlowsDir() : (findProjectFlowsDir(cwd, true) ?? path.join(cwd, ".pi", "taskflows"));
886
1347
  if (!def.name || def.name.trim().length === 0)
887
1348
  throw new Error("Flow name must not be empty");
888
- fs.mkdirSync(dir, { recursive: true });
889
- const safe = safeFlowDirName(def.name);
890
- const filePath = path.join(dir, `${safe}.json`);
1349
+ const target = resolveFlowSaveTarget(cwd, def.name, scope);
1350
+ const { dir, filePath } = target;
1351
+ assertFlowSaveTarget(target);
891
1352
  const fileLockPath = filePath + ".lock";
892
- withLock(fileLockPath, () => { writeFileAtomic(filePath, `${JSON.stringify(def, null, 2)}\n`); });
1353
+ withLock(fileLockPath, () => {
1354
+ assertFlowSaveTarget(target);
1355
+ writeFileAtomic(filePath, `${JSON.stringify(def, null, 2)}\n`, () => assertFlowSaveTarget(target));
1356
+ });
893
1357
  // One-shot: let the user know about .pi/ directory on first save (Finding 8).
894
1358
  if (!_piCreationHinted) {
895
1359
  _piCreationHinted = true;
@@ -916,7 +1380,14 @@ function sidecarPathIn(flowFilePath) {
916
1380
  /** Read a flow's library sidecar. Returns a `LoadResult`; missing/unparseable
917
1381
  * are discriminated by `reason`. */
918
1382
  export function readMeta(cwd, flowName) {
919
- // Try project scope first, then user — mirrors getFlow's resolution.
1383
+ // A discovered flow owns the sidecar adjacent to its actual definition file.
1384
+ // This preserves metadata for nested convention-directory flows and avoids
1385
+ // accidentally pairing one with a stale top-level sidecar of the same name.
1386
+ const saved = getFlow(cwd, flowName);
1387
+ if (saved)
1388
+ return readMetaNextTo(saved.filePath);
1389
+ // No flow resolved (for example, a caller is about to save a new definition):
1390
+ // preserve the legacy ability to recover an orphaned top-level sidecar.
920
1391
  for (const scope of ["project", "user"]) {
921
1392
  const p = sidecarPathFor(cwd, flowName, scope);
922
1393
  if (!fs.existsSync(p))
@@ -951,17 +1422,18 @@ export function readMetaNextTo(flowFilePath) {
951
1422
  * Embedding (Phase 2) is computed by the caller BEFORE this call and passed in
952
1423
  * via meta; this function does only synchronous I/O under the lock (R2R3). */
953
1424
  export function saveFlowWithMeta(cwd, def, meta, scope = "project") {
954
- const dir = scope === "user" ? userFlowsDir() : (findProjectFlowsDir(cwd, true) ?? path.join(cwd, ".pi", "taskflows"));
955
1425
  if (!def.name || def.name.trim().length === 0)
956
1426
  throw new Error("Flow name must not be empty");
957
- fs.mkdirSync(dir, { recursive: true });
958
- const safe = safeFlowDirName(def.name);
959
- const filePath = path.join(dir, `${safe}.json`);
960
- const metaPath = path.join(dir, `${safe}.meta.json`);
1427
+ const target = resolveFlowSaveTarget(cwd, def.name, scope);
1428
+ const { filePath } = target;
1429
+ const metaPath = sidecarPathIn(filePath);
1430
+ assertFlowSaveTarget(target);
961
1431
  const fileLockPath = filePath + ".lock"; // shared lock key for flow+sidecar (R2R5)
962
1432
  withLock(fileLockPath, () => {
963
- writeFileAtomic(filePath, `${JSON.stringify(def, null, 2)}\n`);
964
- writeFileAtomic(metaPath, `${JSON.stringify(meta, null, 2)}\n`);
1433
+ assertFlowSaveTarget(target);
1434
+ writeFileAtomic(filePath, `${JSON.stringify(def, null, 2)}\n`, () => assertFlowSaveTarget(target));
1435
+ assertFlowSaveTarget(target);
1436
+ writeFileAtomic(metaPath, `${JSON.stringify(meta, null, 2)}\n`, () => assertFlowSaveTarget(target));
965
1437
  });
966
1438
  return { filePath, metaPath };
967
1439
  }
@@ -974,9 +1446,16 @@ export function bumpReuseInSidecar(cwd, flowName) {
974
1446
  const saved = getFlow(cwd, flowName);
975
1447
  if (!saved)
976
1448
  return null;
1449
+ const target = {
1450
+ dir: path.dirname(saved.filePath),
1451
+ filePath: saved.filePath,
1452
+ expectedDirIdentity: saved.sourceDirIdentity,
1453
+ };
977
1454
  const metaPath = sidecarPathIn(saved.filePath);
1455
+ assertFlowSaveTarget(target);
978
1456
  const lockPath = saved.filePath + ".lock";
979
1457
  return withLock(lockPath, () => {
1458
+ assertFlowSaveTarget(target);
980
1459
  const existingR = readMetaNextTo(saved.filePath);
981
1460
  const existing = existingR.ok ? existingR.value : undefined;
982
1461
  const now = Date.now();
@@ -994,7 +1473,8 @@ export function bumpReuseInSidecar(cwd, flowName) {
994
1473
  version: 1,
995
1474
  embedding: null,
996
1475
  };
997
- writeFileAtomic(metaPath, `${JSON.stringify(updated, null, 2)}\n`);
1476
+ assertFlowSaveTarget(target);
1477
+ writeFileAtomic(metaPath, `${JSON.stringify(updated, null, 2)}\n`, () => assertFlowSaveTarget(target));
998
1478
  return updated.reuseCount;
999
1479
  });
1000
1480
  }
@@ -1262,21 +1742,54 @@ export function isProcessAlive(pid) {
1262
1742
  * then rename over the target (rename is atomic on the same filesystem). Prevents
1263
1743
  * a crash or concurrent write from leaving a half-written, corrupt JSON file.
1264
1744
  */
1265
- export function writeFileAtomic(filePath, data) {
1745
+ export function writeFileAtomic(filePath, data, guard) {
1266
1746
  // Ensure parent directory exists.
1747
+ guard?.();
1267
1748
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
1749
+ guard?.();
1268
1750
  const tmp = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
1751
+ let fd;
1269
1752
  try {
1270
- fs.writeFileSync(tmp, data, "utf-8");
1753
+ guard?.();
1754
+ // Open the unique temp path without following a pre-existing leaf and write
1755
+ // through the descriptor. If the parent is renamed/replaced after open,
1756
+ // the descriptor remains bound to the original directory's file instead of
1757
+ // redirecting content through a new symlinked lexical path.
1758
+ const noFollow = process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW;
1759
+ fd = fs.openSync(tmp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow, 0o666);
1760
+ // Close the final pre-open race: if the lexical parent changed between the
1761
+ // prior guard and openSync, fail before any definition bytes reach the fd.
1762
+ guard?.();
1763
+ fs.writeFileSync(fd, data, "utf-8");
1764
+ fs.closeSync(fd);
1765
+ fd = undefined;
1766
+ // The directory can be renamed/replaced while the temp file is written.
1767
+ // Revalidate immediately before the externally visible rename; on failure
1768
+ // the guarded path is left untouched and no file is promoted.
1769
+ guard?.();
1271
1770
  fs.renameSync(tmp, filePath);
1272
1771
  }
1273
1772
  catch (e) {
1274
- try {
1275
- if (fs.existsSync(tmp))
1276
- fs.unlinkSync(tmp);
1773
+ if (fd !== undefined) {
1774
+ try {
1775
+ fs.closeSync(fd);
1776
+ }
1777
+ catch { /* ignore close failure */ }
1277
1778
  }
1278
- catch {
1279
- /* ignore cleanup failure */
1779
+ // A guarded caller is protecting a directory-identity boundary. Once a
1780
+ // guard or write fails, the lexical temp path may already resolve through
1781
+ // a replacement directory/symlink; unlinking it could delete an unrelated
1782
+ // external same-name file. Fail closed and leave any temp artifact in the
1783
+ // original (possibly displaced) directory. Unguarded legacy callers keep
1784
+ // the original best-effort cleanup behavior.
1785
+ if (!guard) {
1786
+ try {
1787
+ if (fs.existsSync(tmp))
1788
+ fs.unlinkSync(tmp);
1789
+ }
1790
+ catch {
1791
+ /* ignore cleanup failure */
1792
+ }
1280
1793
  }
1281
1794
  throw e;
1282
1795
  }