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