@llblab/pi-kit 0.18.2 → 0.19.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.
- package/CHANGELOG.md +6 -0
- package/README.md +3 -1
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +35 -37
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +5 -3
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +19 -1
- package/node_modules/@llblab/pi-state-flow/README.md +102 -48
- package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +6 -9
- package/node_modules/@llblab/pi-state-flow/dist/index.js +6 -9
- package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.d.ts +2 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.js +21 -8
- package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.d.ts +40 -24
- package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.js +93 -63
- package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.d.ts +4 -3
- package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.js +9 -9
- package/node_modules/@llblab/pi-state-flow/dist/lib/config.d.ts +1 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/config.js +6 -5
- package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +8 -7
- package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +56 -58
- package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +12 -24
- package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +7 -18
- package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +47 -78
- package/node_modules/@llblab/pi-state-flow/dist/lib/extension.d.ts +4 -6
- package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +241 -437
- package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +2 -72
- package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +120 -499
- package/node_modules/@llblab/pi-state-flow/dist/lib/history.d.ts +2 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/history.js +4 -3
- package/node_modules/@llblab/pi-state-flow/dist/lib/json.d.ts +3 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/json.js +43 -22
- package/node_modules/@llblab/pi-state-flow/dist/lib/logging.d.ts +1 -5
- package/node_modules/@llblab/pi-state-flow/dist/lib/logging.js +0 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/memory.d.ts +1 -14
- package/node_modules/@llblab/pi-state-flow/dist/lib/memory.js +5 -37
- package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +1 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +13 -32
- package/node_modules/@llblab/pi-state-flow/dist/lib/query.d.ts +6 -6
- package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +25 -21
- package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.d.ts +3 -3
- package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.js +15 -12
- package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.d.ts +2 -4
- package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.js +5 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +26 -88
- package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +159 -255
- package/node_modules/@llblab/pi-state-flow/dist/lib/skills.d.ts +0 -3
- package/node_modules/@llblab/pi-state-flow/dist/lib/skills.js +1 -47
- package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +16 -33
- package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +48 -137
- package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +16 -11
- package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +13 -14
- package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +1 -9
- package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +15 -43
- package/node_modules/@llblab/pi-state-flow/dist/lib/storage.d.ts +5 -9
- package/node_modules/@llblab/pi-state-flow/dist/lib/storage.js +13 -45
- package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +1 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +15 -7
- package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +104 -24
- package/node_modules/@llblab/pi-state-flow/dist/lib/transition.d.ts +0 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +11 -14
- package/node_modules/@llblab/pi-state-flow/dist/package.json +9 -6
- package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +11 -17
- package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +8 -8
- package/node_modules/@llblab/pi-state-flow/docs/README.md +2 -2
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +64 -67
- package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +24 -6
- package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +12 -15
- package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +22 -27
- package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +25 -41
- package/node_modules/@llblab/pi-state-flow/docs/performance.md +38 -421
- package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +33 -46
- package/node_modules/@llblab/pi-state-flow/docs/usage.md +37 -61
- package/node_modules/@llblab/pi-state-flow/index.ts +7 -71
- package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +26 -11
- package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +116 -88
- package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +12 -10
- package/node_modules/@llblab/pi-state-flow/lib/config.ts +6 -6
- package/node_modules/@llblab/pi-state-flow/lib/context.ts +52 -59
- package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +11 -20
- package/node_modules/@llblab/pi-state-flow/lib/durable.ts +44 -86
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +237 -460
- package/node_modules/@llblab/pi-state-flow/lib/git.ts +110 -552
- package/node_modules/@llblab/pi-state-flow/lib/history.ts +4 -3
- package/node_modules/@llblab/pi-state-flow/lib/json.ts +39 -23
- package/node_modules/@llblab/pi-state-flow/lib/logging.ts +1 -7
- package/node_modules/@llblab/pi-state-flow/lib/memory.ts +5 -44
- package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +14 -25
- package/node_modules/@llblab/pi-state-flow/lib/query.ts +26 -22
- package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +17 -11
- package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +7 -5
- package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +154 -254
- package/node_modules/@llblab/pi-state-flow/lib/skills.ts +1 -49
- package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +58 -142
- package/node_modules/@llblab/pi-state-flow/lib/state.ts +27 -19
- package/node_modules/@llblab/pi-state-flow/lib/status.ts +16 -54
- package/node_modules/@llblab/pi-state-flow/lib/storage.ts +12 -40
- package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +1 -1
- package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +104 -22
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +16 -26
- package/node_modules/@llblab/pi-state-flow/package.json +9 -6
- package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +11 -17
- package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +8 -8
- package/package.json +2 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/discovery.d.ts +0 -21
- package/node_modules/@llblab/pi-state-flow/dist/lib/discovery.js +0 -125
- package/node_modules/@llblab/pi-state-flow/dist/lib/maintenance.d.ts +0 -36
- package/node_modules/@llblab/pi-state-flow/dist/lib/maintenance.js +0 -98
- package/node_modules/@llblab/pi-state-flow/dist/lib/migration.d.ts +0 -13
- package/node_modules/@llblab/pi-state-flow/dist/lib/migration.js +0 -167
- package/node_modules/@llblab/pi-state-flow/dist/lib/publication.d.ts +0 -86
- package/node_modules/@llblab/pi-state-flow/dist/lib/publication.js +0 -437
- package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +0 -133
- package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +0 -147
- package/node_modules/@llblab/pi-state-flow/lib/migration.ts +0 -171
- package/node_modules/@llblab/pi-state-flow/lib/publication.ts +0 -458
|
@@ -1,16 +1,10 @@
|
|
|
1
|
-
// Domain:
|
|
2
|
-
import {
|
|
3
|
-
import { closeSync, lstatSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
// Domain: optional settled-turn backup of already-accepted canonical State Flow files.
|
|
2
|
+
import { closeSync, constants, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
3
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { dirname, relative, resolve, sep } from "node:path";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { planLegacyStorageMigration } from "./migration.js";
|
|
10
|
-
import { sameJson } from "./json.js";
|
|
11
|
-
import { validateTemporalState } from "./temporal.js";
|
|
12
|
-
import { createSessionRuntime, parseSessionRuntime, serializeSessionRuntime } from "./snapshot.js";
|
|
13
|
-
import { acquirePublicationLock, assertTemporalFileBase, loadTemporalFileRevision, planTemporalPublication, temporalFileReceipts, withStoragePublicationLock } from "./storage.js";
|
|
4
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { captureOwnedFileBases, isStateFlowOwnedPath } from "./durable.js";
|
|
7
|
+
import { acquirePublicationLock, withStoragePublicationLock } from "./storage.js";
|
|
14
8
|
const GIT_TIMEOUT_MS = 15_000;
|
|
15
9
|
const STATE_FLOW_COMMIT_TRAILER = "State-Flow-Durable: v1";
|
|
16
10
|
function git(repositoryRoot, args, options = {}) {
|
|
@@ -18,17 +12,12 @@ function git(repositoryRoot, args, options = {}) {
|
|
|
18
12
|
encoding: "utf8",
|
|
19
13
|
timeout: GIT_TIMEOUT_MS,
|
|
20
14
|
input: options.input,
|
|
21
|
-
...
|
|
22
|
-
env: {
|
|
23
|
-
...process.env,
|
|
24
|
-
GIT_TERMINAL_PROMPT: "0",
|
|
25
|
-
...options.env,
|
|
26
|
-
},
|
|
15
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...options.env },
|
|
27
16
|
});
|
|
28
17
|
const status = result.status ?? 1;
|
|
29
18
|
const stdout = result.stdout ?? "";
|
|
30
19
|
const stderr = result.error?.message ?? result.stderr ?? "";
|
|
31
|
-
if (status !== 0 && !options.allowFailure) {
|
|
20
|
+
if (result.error || (status !== 0 && !options.allowFailure)) {
|
|
32
21
|
const detail = stderr.trim() || stdout.trim() || `exit status ${status}`;
|
|
33
22
|
throw new Error(`Git command failed (${args.join(" ")}): ${detail}`);
|
|
34
23
|
}
|
|
@@ -39,20 +28,9 @@ function assertDirectoryPath(path) {
|
|
|
39
28
|
if (parent !== path)
|
|
40
29
|
assertDirectoryPath(parent);
|
|
41
30
|
const stat = lstatSync(path, { throwIfNoEntry: false });
|
|
42
|
-
if (stat && (!stat.isDirectory() || stat.isSymbolicLink()))
|
|
31
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) {
|
|
43
32
|
throw new Error(`State Flow repository path is not a regular directory: ${path}`);
|
|
44
|
-
}
|
|
45
|
-
/** Explicit start only: initialize this exact root, preserving existing files and ancestor Git. */
|
|
46
|
-
export function initializeGitRepository(repositoryRoot) {
|
|
47
|
-
const root = resolve(repositoryRoot);
|
|
48
|
-
assertDirectoryPath(root);
|
|
49
|
-
if (lstatSync(resolve(root, ".git"), { throwIfNoEntry: false })) {
|
|
50
|
-
assertRepositoryRoot(root);
|
|
51
|
-
return;
|
|
52
33
|
}
|
|
53
|
-
mkdirSync(root, { recursive: true });
|
|
54
|
-
git(root, ["init"]);
|
|
55
|
-
assertRepositoryRoot(root);
|
|
56
34
|
}
|
|
57
35
|
function assertRepositoryRoot(repositoryRoot) {
|
|
58
36
|
const expected = resolve(repositoryRoot);
|
|
@@ -61,27 +39,80 @@ function assertRepositoryRoot(repositoryRoot) {
|
|
|
61
39
|
throw new Error(`State Flow Git metadata must not be a symlink: ${expected}`);
|
|
62
40
|
}
|
|
63
41
|
const actual = resolve(git(expected, ["rev-parse", "--show-toplevel"]).stdout.trim());
|
|
64
|
-
if (actual !== expected)
|
|
65
|
-
throw new Error(`State Flow
|
|
66
|
-
}
|
|
42
|
+
if (actual !== expected)
|
|
43
|
+
throw new Error(`State Flow backup repository root mismatch: expected ${expected}, found ${actual}`);
|
|
67
44
|
return expected;
|
|
68
45
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
46
|
+
function withBackupLock(repositoryRoot, action) {
|
|
47
|
+
const root = assertRepositoryRoot(repositoryRoot);
|
|
48
|
+
const common = resolve(root, git(root, ["rev-parse", "--git-common-dir"]).stdout.trim());
|
|
49
|
+
const path = resolve(common, "state-flow-backup.lock");
|
|
50
|
+
const descriptor = acquirePublicationLock(path, (cause) => new Error(`State Flow backup lock is unavailable at ${path}; retry on a later settled turn`, { cause }));
|
|
51
|
+
try {
|
|
52
|
+
writeFileSync(descriptor, `${process.pid}\n`);
|
|
53
|
+
return action(root);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
closeSync(descriptor);
|
|
57
|
+
rmSync(path);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Inventory only the bounded canonical namespace, never artifact sources or unrelated directory trees. */
|
|
61
|
+
function captureBackupFiles(root, tracked) {
|
|
62
|
+
const paths = new Set([...tracked].map((path) => resolve(root, path)));
|
|
63
|
+
const visit = (directory, depth) => {
|
|
64
|
+
assertDirectoryPath(directory);
|
|
65
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
66
|
+
const path = join(directory, entry.name);
|
|
67
|
+
if (isStateFlowOwnedPath(path, root)) {
|
|
68
|
+
if (!entry.isFile())
|
|
69
|
+
throw new Error(`State Flow backup source is not a regular file: ${path}`);
|
|
70
|
+
paths.add(path);
|
|
71
|
+
}
|
|
72
|
+
else if (depth < 2 && isStateFlowOwnedPath(join(path, "checkpoint.json"), root)) {
|
|
73
|
+
if (entry.isSymbolicLink())
|
|
74
|
+
throw new Error(`State Flow backup namespace is not a regular directory: ${path}`);
|
|
75
|
+
if (entry.isDirectory())
|
|
76
|
+
visit(path, depth + 1);
|
|
77
|
+
}
|
|
83
78
|
}
|
|
84
|
-
}
|
|
79
|
+
};
|
|
80
|
+
visit(root, 0);
|
|
81
|
+
return captureOwnedFileBases([...paths].sort(), root).map((file) => ({
|
|
82
|
+
path: relative(root, file.path).split(sep).join("/"),
|
|
83
|
+
bytes: file.bytes,
|
|
84
|
+
mode: file.bytes === undefined ? 0o600 : lstatSync(file.path).mode,
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
/** Stage captured bytes through native Git filters without rereading live canonical files. */
|
|
88
|
+
function prepareBackupWorktree(root, worktree, files) {
|
|
89
|
+
mkdirSync(worktree);
|
|
90
|
+
const directories = new Set();
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
if (file.bytes === undefined)
|
|
93
|
+
continue;
|
|
94
|
+
const target = join(worktree, file.path);
|
|
95
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
96
|
+
writeFileSync(target, file.bytes, { mode: file.mode & 0o777 });
|
|
97
|
+
let directory = dirname(file.path);
|
|
98
|
+
while (true) {
|
|
99
|
+
directories.add(directory);
|
|
100
|
+
if (directory === ".")
|
|
101
|
+
break;
|
|
102
|
+
directory = dirname(directory);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Git policy is not semantic input and is read outside the canonical lock.
|
|
106
|
+
for (const directory of directories) {
|
|
107
|
+
const source = join(root, directory, ".gitattributes");
|
|
108
|
+
assertDirectoryPath(dirname(source));
|
|
109
|
+
const stat = lstatSync(source, { throwIfNoEntry: false });
|
|
110
|
+
if (!stat)
|
|
111
|
+
continue;
|
|
112
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
113
|
+
throw new Error(`State Flow backup attributes are not a regular file: ${source}`);
|
|
114
|
+
writeFileSync(join(worktree, directory, ".gitattributes"), readFileSync(source, { flag: constants.O_RDONLY | constants.O_NOFOLLOW }), { mode: 0o600 });
|
|
115
|
+
}
|
|
85
116
|
}
|
|
86
117
|
function currentHead(repositoryRoot) {
|
|
87
118
|
const result = git(repositoryRoot, ["rev-parse", "--verify", "HEAD"], { allowFailure: true });
|
|
@@ -89,205 +120,55 @@ function currentHead(repositoryRoot) {
|
|
|
89
120
|
}
|
|
90
121
|
function currentBranchRef(repositoryRoot) {
|
|
91
122
|
const result = git(repositoryRoot, ["symbolic-ref", "--quiet", "HEAD"], { allowFailure: true });
|
|
92
|
-
if (result.status !== 0 || result.stdout.trim().length === 0)
|
|
93
|
-
throw new Error("State Flow
|
|
94
|
-
}
|
|
123
|
+
if (result.status !== 0 || result.stdout.trim().length === 0)
|
|
124
|
+
throw new Error("State Flow backup requires an attached Git branch");
|
|
95
125
|
return result.stdout.trim();
|
|
96
126
|
}
|
|
97
|
-
|
|
98
|
-
function revisionFileReader(repositoryRoot, revision, paths) {
|
|
99
|
-
const relativePaths = new Map(paths.map((path) => [path, relativeOwnedPath(path, repositoryRoot)]));
|
|
100
|
-
if (relativePaths.size === 0)
|
|
101
|
-
throw new Error("Git revision reader requires owned paths");
|
|
102
|
-
const selected = new Set(relativePaths.values());
|
|
103
|
-
const listing = git(repositoryRoot, ["ls-tree", "-z", revision, "--", ...selected], { env: {
|
|
104
|
-
GIT_LITERAL_PATHSPECS: "1", GIT_GLOB_PATHSPECS: "0", GIT_NOGLOB_PATHSPECS: "0", GIT_ICASE_PATHSPECS: "0",
|
|
105
|
-
} }).stdout;
|
|
106
|
-
if (listing.length > 0 && !listing.endsWith("\0"))
|
|
107
|
-
throw new Error("Historical Git tree listing is incomplete");
|
|
108
|
-
const entries = new Map();
|
|
109
|
-
for (const entry of listing.split("\0").filter(Boolean)) {
|
|
110
|
-
const separator = entry.indexOf("\t");
|
|
111
|
-
if (separator < 0)
|
|
112
|
-
throw new Error("Historical Git tree listing is malformed");
|
|
113
|
-
const path = entry.slice(separator + 1);
|
|
114
|
-
if (!selected.has(path))
|
|
115
|
-
continue;
|
|
116
|
-
entries.set(path, entries.has(path) ? null : entry.slice(0, separator));
|
|
117
|
-
}
|
|
118
|
-
const files = new Map();
|
|
119
|
-
return (path) => {
|
|
120
|
-
const relativePath = relativePaths.get(path);
|
|
121
|
-
if (relativePath === undefined)
|
|
122
|
-
throw new Error(`Git revision reader did not select path: ${path}`);
|
|
123
|
-
const cached = files.get(path);
|
|
124
|
-
if (cached)
|
|
125
|
-
return cached;
|
|
126
|
-
const entry = entries.get(relativePath);
|
|
127
|
-
if (entry === null)
|
|
128
|
-
throw new Error(`Historical Git tree has duplicate path: ${relativePath}`);
|
|
129
|
-
if (entry === undefined) {
|
|
130
|
-
const missing = { path, identity: "missing" };
|
|
131
|
-
files.set(path, missing);
|
|
132
|
-
return missing;
|
|
133
|
-
}
|
|
134
|
-
if (!/^100(?:644|755) blob [0-9a-f]+$/.test(entry)) {
|
|
135
|
-
throw new Error(`Historical State Flow file is not a regular blob: ${path}`);
|
|
136
|
-
}
|
|
137
|
-
// Selected state/runtime blobs have no semantic byte cap; Node's default pipe budget is only 1 MiB.
|
|
138
|
-
const content = git(repositoryRoot, ["show", `${revision}:${relativePath}`], { maxBuffer: Infinity }).stdout;
|
|
139
|
-
const file = { path, identity: `sha256:${createHash("sha256").update(content).digest("hex")}`, content };
|
|
140
|
-
files.set(path, file);
|
|
141
|
-
return file;
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
function revisionFile(repositoryRoot, revision, path) {
|
|
145
|
-
return revisionFileReader(repositoryRoot, revision, [path])(path);
|
|
146
|
-
}
|
|
147
|
-
function assertReadableRevision(root, revision) {
|
|
148
|
-
if (!/^[0-9a-f]{40,64}$/.test(revision)
|
|
149
|
-
|| git(root, ["cat-file", "-e", `${revision}^{commit}`], { allowFailure: true }).status !== 0) {
|
|
150
|
-
throw new Error(`State Flow revision is not a readable Git commit: ${revision}`);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
export function captureTemporalGitBase(cwd, sessionId, repositoryRoot, sessionKey = sessionId) {
|
|
154
|
-
return withPublicationLock(repositoryRoot, (root) => captureTemporalBaseUnderLock(cwd, sessionId, root, sessionKey));
|
|
155
|
-
}
|
|
156
|
-
function captureTemporalBaseUnderLock(cwd, sessionId, root, sessionKey = sessionId) {
|
|
157
|
-
return { head: currentHead(root), files: captureTemporalFileBases(cwd, sessionId, root, sessionKey) };
|
|
158
|
-
}
|
|
159
|
-
function revisionScopeFiles(read, paths) {
|
|
160
|
-
return {
|
|
161
|
-
paths,
|
|
162
|
-
checkpoint: read(paths.checkpoint),
|
|
163
|
-
patches: read(paths.patches),
|
|
164
|
-
meta: read(paths.meta),
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
/** Cold scope-stream reconstruction from canonical revision paths only. */
|
|
168
|
-
export function loadTemporalRevision(cwd, sessionId, repositoryRoot, revision, sessionKey = sessionId) {
|
|
169
|
-
const root = assertRepositoryRoot(repositoryRoot);
|
|
170
|
-
assertReadableRevision(root, revision);
|
|
171
|
-
const scopePaths = ["global", "cwd", "session"].map((scope) => ({
|
|
172
|
-
scope, paths: temporalScopePaths(cwd, sessionId, scope, root, sessionKey),
|
|
173
|
-
}));
|
|
174
|
-
const canonicalRuntime = sessionRuntimePaths(cwd, sessionId, root, sessionKey);
|
|
175
|
-
const readFile = revisionFileReader(root, revision, [
|
|
176
|
-
...scopePaths.flatMap(({ paths }) => [paths.checkpoint, paths.patches, paths.meta]),
|
|
177
|
-
canonicalRuntime.config, canonicalRuntime.runtime,
|
|
178
|
-
]);
|
|
179
|
-
const files = [];
|
|
180
|
-
const scopes = {};
|
|
181
|
-
const provenance = { global: {}, cwd: {}, session: {} };
|
|
182
|
-
for (const { scope, paths } of scopePaths) {
|
|
183
|
-
const selected = revisionScopeFiles(readFile, paths);
|
|
184
|
-
files.push(selected.checkpoint, selected.patches, selected.meta);
|
|
185
|
-
provenance[scope] = parseScopeProvenance(selected.meta.content, selected.meta.path);
|
|
186
|
-
try {
|
|
187
|
-
scopes[scope] = parseScopeStream(selected.checkpoint.content, selected.patches.content, scope, scope === "cwd" ? cwd : undefined, selected.meta.content);
|
|
188
|
-
}
|
|
189
|
-
catch (error) {
|
|
190
|
-
throw new Error(`Invalid historical State Flow ${scope} scope`, { cause: error });
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
const readRuntime = (paths) => ({
|
|
194
|
-
paths, config: readFile(paths.config), runtime: readFile(paths.runtime), meta: readFile(paths.meta),
|
|
195
|
-
});
|
|
196
|
-
const { paths: runtimePaths, config, runtime, meta } = readRuntime(canonicalRuntime);
|
|
197
|
-
files.push(config, runtime);
|
|
198
|
-
const document = parseSessionRuntime(config.content, runtime.content, cwd, sessionId, meta.content);
|
|
199
|
-
if (document === undefined)
|
|
200
|
-
return { base: { head: revision, files }, scopes, provenance };
|
|
201
|
-
if (Object.keys(provenance.session).length === 0 && document.meta.artifacts !== undefined) {
|
|
202
|
-
provenance.session = parseArtifactProvenanceRegistry(document.meta.artifacts, "State Flow session artifact provenance");
|
|
203
|
-
}
|
|
204
|
-
const runtimeOwnerPath = runtime.content === undefined ? runtimePaths.meta : runtimePaths.runtime;
|
|
205
|
-
const owner = git(root, ["log", "-1", "--format=%H", revision, "--",
|
|
206
|
-
relativeOwnedPath(runtimePaths.config, root), relativeOwnedPath(runtimeOwnerPath, root),
|
|
207
|
-
]).stdout.trim();
|
|
208
|
-
assertReadableRevision(root, owner);
|
|
209
|
-
let temporalRevision = document.meta.temporalRevision === undefined || document.meta.temporalRevision === "self"
|
|
210
|
-
? owner : document.meta.temporalRevision;
|
|
211
|
-
if (temporalRevision !== revision) {
|
|
212
|
-
const ancestor = git(root, ["merge-base", "--is-ancestor", temporalRevision, revision], { allowFailure: true }).status === 0;
|
|
213
|
-
const parents = git(root, ["rev-list", "--parents", "-n", "1", revision]).stdout.trim().split(/\s+/);
|
|
214
|
-
// A store-wide semantic migration intentionally replaces all prior history with one root.
|
|
215
|
-
// Its complete tree owns every scope, so predecessor temporal pointers collapse to self.
|
|
216
|
-
if (!ancestor && parents.length === 1)
|
|
217
|
-
temporalRevision = owner;
|
|
218
|
-
else if (!ancestor) {
|
|
219
|
-
throw new Error("Temporal revision must be an ancestor of its runtime owner");
|
|
220
|
-
}
|
|
221
|
-
const selected = loadTemporalRevision(cwd, sessionId, root, temporalRevision, sessionKey);
|
|
222
|
-
Object.assign(scopes, selected.scopes);
|
|
223
|
-
Object.assign(provenance, selected.provenance);
|
|
224
|
-
}
|
|
225
|
-
if (scopes.global === undefined || scopes.cwd === undefined || scopes.session === undefined) {
|
|
226
|
-
throw new Error("Session runtime has incomplete temporal scope storage");
|
|
227
|
-
}
|
|
228
|
-
validateTemporalState({ lineage: document.meta.lineage, scopes: { global: scopes.global, cwd: scopes.cwd, session: scopes.session } });
|
|
229
|
-
return { base: { head: revision, files }, scopes, runtime: { document, revision: owner }, provenance };
|
|
230
|
-
}
|
|
231
|
-
function relativeOwnedPath(path, repositoryRoot) {
|
|
232
|
-
const output = relative(repositoryRoot, path);
|
|
233
|
-
if (output === "" || output === ".." || output.startsWith(`..${sep}`) || !isStateFlowOwnedPath(path, repositoryRoot)) {
|
|
234
|
-
throw new Error(`Git publication received a non-State Flow path: ${path}`);
|
|
235
|
-
}
|
|
236
|
-
return output.split(sep).join("/");
|
|
237
|
-
}
|
|
238
|
-
function commitOwnedFiles(repositoryRoot, updates, expectedHead, scopes, operation = "persist", rootCommit = false) {
|
|
239
|
-
assertOwnedFileUpdates(updates, repositoryRoot);
|
|
127
|
+
function commitCurrentOwnedFiles(repositoryRoot, expectedHead) {
|
|
240
128
|
const branchRef = currentBranchRef(repositoryRoot);
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
const temporary = mkdtempSync(`${tmpdir()}${sep}state-flow-index-`);
|
|
246
|
-
const indexPath = `${temporary}${sep}index`;
|
|
247
|
-
const env = { GIT_INDEX_FILE: indexPath };
|
|
129
|
+
if (currentHead(repositoryRoot) !== expectedHead)
|
|
130
|
+
throw new Error("State Flow backup Git base changed concurrently");
|
|
131
|
+
const temporary = mkdtempSync(`${tmpdir()}${sep}state-flow-backup-index-`);
|
|
132
|
+
const env = { GIT_INDEX_FILE: `${temporary}${sep}index` };
|
|
248
133
|
try {
|
|
249
134
|
if (expectedHead === undefined)
|
|
250
135
|
git(repositoryRoot, ["read-tree", "--empty"], { env });
|
|
251
136
|
else
|
|
252
137
|
git(repositoryRoot, ["read-tree", expectedHead], { env });
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
138
|
+
const owned = (paths) => paths.split("\0").filter((path) => path.length > 0 && isStateFlowOwnedPath(resolve(repositoryRoot, path), repositoryRoot));
|
|
139
|
+
const headPaths = new Set(expectedHead === undefined ? [] : owned(git(repositoryRoot, ["ls-tree", "-r", "--name-only", "-z", expectedHead]).stdout));
|
|
140
|
+
const tracked = new Set([...headPaths, ...owned(git(repositoryRoot, ["ls-files", "--cached", "-z"]).stdout)]);
|
|
141
|
+
// No Git command, filter, staging write, or ref update runs inside this short capture lock.
|
|
142
|
+
const snapshot = withStoragePublicationLock(repositoryRoot, (root) => captureBackupFiles(root, tracked));
|
|
143
|
+
const present = snapshot.filter((file) => file.bytes !== undefined).map((file) => file.path);
|
|
144
|
+
const ignore = present.length === 0 ? { status: 1, stdout: "", stderr: "" }
|
|
145
|
+
: git(repositoryRoot, ["check-ignore", "--no-index", "--stdin", "-z"], { input: `${present.join("\0")}\0`, allowFailure: true });
|
|
146
|
+
if (ignore.status !== 0 && (ignore.status !== 1 || ignore.stderr.trim()))
|
|
147
|
+
throw new Error(`Git ignore selection failed: ${ignore.stderr || ignore.status}`);
|
|
148
|
+
const ignored = new Set(ignore.stdout.split("\0"));
|
|
149
|
+
const candidates = snapshot.filter((file) => file.bytes === undefined
|
|
150
|
+
? headPaths.has(file.path) : tracked.has(file.path) || !ignored.has(file.path));
|
|
151
|
+
if (candidates.length === 0)
|
|
152
|
+
return undefined;
|
|
153
|
+
const pathspec = `${candidates.map((file) => file.path).join("\0")}\0`;
|
|
154
|
+
const worktree = join(temporary, "worktree");
|
|
155
|
+
prepareBackupWorktree(repositoryRoot, worktree, candidates);
|
|
156
|
+
git(repositoryRoot, ["--literal-pathspecs", `--work-tree=${worktree}`, "add", "-A", "-f", "--pathspec-from-file=-", "--pathspec-file-nul"], { env, input: pathspec });
|
|
270
157
|
const tree = git(repositoryRoot, ["write-tree"], { env }).stdout.trim();
|
|
271
|
-
if (expectedHead !== undefined &&
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
commitArgs.push("-p", expectedHead);
|
|
280
|
-
const commit = git(repositoryRoot, commitArgs, { input: message }).stdout.trim();
|
|
281
|
-
const zero = "0".repeat(40);
|
|
282
|
-
assertOwnedFileUpdates(updates, repositoryRoot);
|
|
283
|
-
git(repositoryRoot, ["update-ref", branchRef, commit, expectedHead ?? zero]);
|
|
158
|
+
if (expectedHead !== undefined && tree === git(repositoryRoot, ["rev-parse", `${expectedHead}^{tree}`]).stdout.trim())
|
|
159
|
+
return undefined;
|
|
160
|
+
const args = ["commit-tree", tree];
|
|
161
|
+
if (expectedHead !== undefined)
|
|
162
|
+
args.push("-p", expectedHead);
|
|
163
|
+
const commit = git(repositoryRoot, args, { input: `state-flow: settled canonical backup\n\n${STATE_FLOW_COMMIT_TRAILER}\n` }).stdout.trim();
|
|
164
|
+
const previous = expectedHead ?? "0".repeat(commit.length);
|
|
165
|
+
git(repositoryRoot, ["update-ref", branchRef, commit, previous]);
|
|
284
166
|
try {
|
|
285
|
-
// Align
|
|
286
|
-
|
|
287
|
-
git(repositoryRoot, ["read-tree", commit]);
|
|
167
|
+
// Align only owned entries; the caller's unrelated stages and index-only data remain intact.
|
|
168
|
+
git(repositoryRoot, ["--literal-pathspecs", "reset", "--quiet", "--no-refresh", commit, "--pathspec-from-file=-", "--pathspec-file-nul"], { input: pathspec });
|
|
288
169
|
}
|
|
289
170
|
catch (error) {
|
|
290
|
-
git(repositoryRoot, ["update-ref", branchRef,
|
|
171
|
+
git(repositoryRoot, ["update-ref", branchRef, previous, commit]);
|
|
291
172
|
throw error;
|
|
292
173
|
}
|
|
293
174
|
return commit;
|
|
@@ -296,267 +177,7 @@ function commitOwnedFiles(repositoryRoot, updates, expectedHead, scopes, operati
|
|
|
296
177
|
rmSync(temporary, { recursive: true, force: true });
|
|
297
178
|
}
|
|
298
179
|
}
|
|
299
|
-
/**
|
|
300
|
-
export function
|
|
301
|
-
return
|
|
302
|
-
}
|
|
303
|
-
function migrateLegacyStorageUnderLock(cwd, sessionId, root, sessionKey = sessionId) {
|
|
304
|
-
const plan = planLegacyStorageMigration(cwd, sessionId, root, undefined, sessionKey);
|
|
305
|
-
if (plan.updates.length === 0)
|
|
306
|
-
return { scopes: [] };
|
|
307
|
-
const current = captureOwnedFileBases(plan.bases.map(({ path }) => path), root);
|
|
308
|
-
if (current.some((base, index) => base.identity !== plan.bases[index].identity)) {
|
|
309
|
-
throw new Error("State Flow storage changed concurrently while planning migration");
|
|
310
|
-
}
|
|
311
|
-
return { scopes: plan.scopes, ...publishOwnedCohort(root, plan.updates, plan.bases, currentHead(root), plan.scopes, "migrate", false, true) };
|
|
312
|
-
}
|
|
313
|
-
function publishOwnedCohort(root, updates, bases, head, scopes, operation, push = true, rootCommit = false) {
|
|
314
|
-
let published = false;
|
|
315
|
-
try {
|
|
316
|
-
writeOwnedFileUpdates(updates, bases, root);
|
|
317
|
-
published = true;
|
|
318
|
-
const commit = commitOwnedFiles(root, updates, head, scopes, operation, rootCommit);
|
|
319
|
-
return commit === undefined ? {} : push ? { commit, push: pushGitCommit(root, commit) } : { commit };
|
|
320
|
-
}
|
|
321
|
-
catch (error) {
|
|
322
|
-
if (published) {
|
|
323
|
-
try {
|
|
324
|
-
const touched = new Set(updates.map(({ path }) => path));
|
|
325
|
-
restoreDurableFileBases(bases.filter(({ path }) => touched.has(path)), root, updates);
|
|
326
|
-
}
|
|
327
|
-
catch (rollbackError) {
|
|
328
|
-
throw new AggregateError([error, rollbackError], "State Flow publication and rollback failed");
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
throw error;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
/** Explicit current-file adoption, not reconstruction or invention of pre-Git history. */
|
|
335
|
-
export function adoptFileStateToGit(cwd, sessionId, repositoryRoot, revision, snapshot, sessionKey = sessionId, push = true) {
|
|
336
|
-
const selected = loadTemporalFileRevision(cwd, sessionId, repositoryRoot, revision, sessionKey);
|
|
337
|
-
if (snapshot.meta.step !== selected.runtime.meta.step)
|
|
338
|
-
throw new Error("Git adoption must preserve the semantic step");
|
|
339
|
-
const runtime = createSessionRuntime(snapshot, cwd, sessionId, selected.view.lineage, "unconfirmed", selected.provenance.session);
|
|
340
|
-
runtime.meta.temporalRevision = "self";
|
|
341
|
-
const sources = serializeSessionRuntime(runtime, cwd, sessionId);
|
|
342
|
-
initializeGitRepository(repositoryRoot);
|
|
343
|
-
return withPublicationLock(repositoryRoot, (root) => {
|
|
344
|
-
const current = captureTemporalBaseUnderLock(cwd, sessionId, root, sessionKey);
|
|
345
|
-
assertTemporalFileBase(selected.base, current);
|
|
346
|
-
const paths = sessionRuntimePaths(cwd, sessionId, root, sessionKey);
|
|
347
|
-
const provenancePaths = new Set([
|
|
348
|
-
temporalScopePaths(cwd, sessionId, "global", root, sessionKey).meta,
|
|
349
|
-
temporalScopePaths(cwd, sessionId, "cwd", root, sessionKey).meta,
|
|
350
|
-
temporalScopePaths(cwd, sessionId, "session", root, sessionKey).meta,
|
|
351
|
-
]);
|
|
352
|
-
// Preserve exact valid scope bytes; only runtime provenance changes representation.
|
|
353
|
-
const updates = current.files.filter(({ path, identity }) => path.endsWith("checkpoint.json")
|
|
354
|
-
|| path.endsWith("patches.jsonl")
|
|
355
|
-
|| (provenancePaths.has(path) && identity !== "missing"))
|
|
356
|
-
.map(({ path, content }) => ({ path, content: content }));
|
|
357
|
-
updates.push({ path: paths.config, content: sources.config }, { path: paths.runtime, content: sources.runtime });
|
|
358
|
-
const existing = current.head && updates.every(({ path, content }) => revisionFile(root, current.head, path).content === content)
|
|
359
|
-
? loadTemporalRevision(cwd, sessionId, root, current.head, sessionKey) : undefined;
|
|
360
|
-
if (existing && (!existing.runtime || !sameJson({ lineage: existing.runtime.document.meta.lineage, scopes: existing.scopes }, selected.view))) {
|
|
361
|
-
throw new Error("Existing Git runtime does not anchor the selected file cohort");
|
|
362
|
-
}
|
|
363
|
-
const publication = publishOwnedCohort(root, updates, current.files, current.head, [], "persist", push);
|
|
364
|
-
const target = publication.commit ?? existing.runtime.revision;
|
|
365
|
-
const remote = push ? publication.push ?? pushGitCommit(root, target) : undefined;
|
|
366
|
-
return { ...publication, revision: target, ...(remote === undefined ? {} : { push: remote }), view: selected.view, provenance: selected.provenance,
|
|
367
|
-
base: { head: publication.commit ?? current.head, files: temporalFileReceipts(current, updates) } };
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
/** Working-tree equality alone cannot prove that a new commit contains the selected cohort. */
|
|
371
|
-
function includeUncommittedCohort(cwd, sessionId, root, current, updates, changedScopes, scopes, runtime, sessionKey = sessionId) {
|
|
372
|
-
const targets = new Map(updates.map((update) => [update.path, update]));
|
|
373
|
-
const desired = (paths) => paths.map((path) => targets.get(path) ?? { path, content: current.files.find((file) => file.path === path).content });
|
|
374
|
-
const pairs = ["global", "cwd", "session"].map((scope) => {
|
|
375
|
-
const paths = temporalScopePaths(cwd, sessionId, scope, root, sessionKey);
|
|
376
|
-
return { scope, pair: desired([paths.checkpoint, paths.patches]) };
|
|
377
|
-
});
|
|
378
|
-
const runtimePaths = runtime ? sessionRuntimePaths(cwd, sessionId, root, sessionKey) : undefined;
|
|
379
|
-
const runtimePair = runtimePaths ? desired([runtimePaths.config, runtimePaths.runtime]) : [];
|
|
380
|
-
const readFile = current.head === undefined ? undefined : revisionFileReader(root, current.head, [...pairs.flatMap(({ pair }) => pair), ...runtimePair].map(({ path }) => path));
|
|
381
|
-
const absentFromHead = (files) => files.some(({ path, content }) => readFile === undefined || readFile(path).content !== content);
|
|
382
|
-
for (const { scope, pair } of pairs) {
|
|
383
|
-
if (!absentFromHead(pair))
|
|
384
|
-
continue;
|
|
385
|
-
if (!scopes.includes(scope))
|
|
386
|
-
throw new Error(`Temporal scope update omitted an uncommitted stream: ${scope}`);
|
|
387
|
-
for (const update of pair)
|
|
388
|
-
targets.set(update.path, update);
|
|
389
|
-
if (!changedScopes.includes(scope))
|
|
390
|
-
changedScopes.push(scope);
|
|
391
|
-
}
|
|
392
|
-
if (absentFromHead(runtimePair))
|
|
393
|
-
for (const update of runtimePair)
|
|
394
|
-
targets.set(update.path, update);
|
|
395
|
-
updates.splice(0, updates.length, ...targets.values());
|
|
396
|
-
}
|
|
397
|
-
/** Persist selected streams from one validated view, never derive checkpoints from current state. */
|
|
398
|
-
export function publishTemporalStateToGit(cwd, sessionId, view, scopes, base, repositoryRoot, runtime, sessionKey = sessionId, push = true, provenance) {
|
|
399
|
-
return withPublicationLock(repositoryRoot, (root) => {
|
|
400
|
-
const current = captureTemporalBaseUnderLock(cwd, sessionId, root, sessionKey);
|
|
401
|
-
assertTemporalFileBase(base, current);
|
|
402
|
-
if (runtime?.meta.publication === "files")
|
|
403
|
-
throw new Error("Git publication requires explicit Git provenance");
|
|
404
|
-
const runtimePaths = sessionRuntimePaths(cwd, sessionId, root, sessionKey);
|
|
405
|
-
const previousRuntime = parseSessionRuntime(current.files.find(({ path }) => path === runtimePaths.config)?.content, current.files.find(({ path }) => path === runtimePaths.runtime)?.content, cwd, sessionId, current.files.find(({ path }) => path === runtimePaths.meta)?.content);
|
|
406
|
-
if (previousRuntime?.meta.publication === "files")
|
|
407
|
-
throw new Error("File-only storage requires explicit full-cohort Git adoption");
|
|
408
|
-
const runtimeOnly = runtime?.meta.temporalRevision !== undefined && runtime.meta.temporalRevision !== "self";
|
|
409
|
-
if (runtimeOnly) {
|
|
410
|
-
if (scopes.length !== 0)
|
|
411
|
-
throw new Error("Runtime-only publication cannot write semantic scopes");
|
|
412
|
-
const selected = loadTemporalRevision(cwd, sessionId, root, runtime.meta.temporalRevision, sessionKey);
|
|
413
|
-
if (!sameJson(selected.scopes, view.scopes))
|
|
414
|
-
throw new Error("Runtime temporal reference does not match selected streams");
|
|
415
|
-
}
|
|
416
|
-
const { updates, changedScopes } = planTemporalPublication(cwd, sessionId, view, scopes, current, root, runtime, runtimeOnly, sessionKey, provenance);
|
|
417
|
-
if (!runtimeOnly)
|
|
418
|
-
includeUncommittedCohort(cwd, sessionId, root, current, updates, changedScopes, scopes, runtime !== undefined, sessionKey);
|
|
419
|
-
if (updates.length === 0)
|
|
420
|
-
return { base: current };
|
|
421
|
-
const publication = publishOwnedCohort(root, updates, current.files, current.head, changedScopes, "persist", push);
|
|
422
|
-
const nextFiles = temporalFileReceipts(current, updates);
|
|
423
|
-
return { ...publication, base: { head: publication.commit ?? current.head, files: nextFiles } };
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
export function resolveGitPushDestination(repositoryRoot) {
|
|
427
|
-
const root = assertRepositoryRoot(repositoryRoot);
|
|
428
|
-
const common = git(root, ["rev-parse", "--git-common-dir"]).stdout.trim();
|
|
429
|
-
const gitCommonDir = resolve(root, common);
|
|
430
|
-
const destination = pushDestination(root);
|
|
431
|
-
return destination === undefined ? undefined : { gitCommonDir, ...destination };
|
|
432
|
-
}
|
|
433
|
-
function pushDestination(repositoryRoot) {
|
|
434
|
-
const branchRef = currentBranchRef(repositoryRoot);
|
|
435
|
-
const branch = branchRef.slice("refs/heads/".length);
|
|
436
|
-
const configuredRemote = git(repositoryRoot, ["config", "--get", `branch.${branch}.remote`], { allowFailure: true });
|
|
437
|
-
if (configuredRemote.status > 1)
|
|
438
|
-
throw new Error(configuredRemote.stderr || "Cannot inspect Git remote configuration");
|
|
439
|
-
let remote = configuredRemote.status === 0 ? configuredRemote.stdout.trim() : "";
|
|
440
|
-
if (remote.length === 0) {
|
|
441
|
-
const remotes = git(repositoryRoot, ["remote"]).stdout.trim().split(/\s+/).filter(Boolean);
|
|
442
|
-
if (remotes.length === 0)
|
|
443
|
-
return undefined;
|
|
444
|
-
if (remotes.includes("origin"))
|
|
445
|
-
remote = "origin";
|
|
446
|
-
else if (remotes.length === 1)
|
|
447
|
-
remote = remotes[0];
|
|
448
|
-
else
|
|
449
|
-
throw new Error("State Flow durable publication cannot select a Git remote");
|
|
450
|
-
}
|
|
451
|
-
if (remote === ".")
|
|
452
|
-
throw new Error("State Flow durable publication requires a non-local Git remote");
|
|
453
|
-
const configuredMerge = git(repositoryRoot, ["config", "--get", `branch.${branch}.merge`], { allowFailure: true });
|
|
454
|
-
const ref = configuredMerge.status === 0 && configuredMerge.stdout.trim().length > 0
|
|
455
|
-
? configuredMerge.stdout.trim()
|
|
456
|
-
: branchRef;
|
|
457
|
-
return { remote, ref };
|
|
458
|
-
}
|
|
459
|
-
/** Read-only publication policy; configuration errors are not local-only success. */
|
|
460
|
-
export function isLocalGitRepository(repositoryRoot) {
|
|
461
|
-
return pushDestination(assertRepositoryRoot(repositoryRoot)) === undefined;
|
|
462
|
-
}
|
|
463
|
-
/** Push an already-created commit exactly; failures are publication state, not transition rejection. */
|
|
464
|
-
export function isGitCommitAncestor(repositoryRoot, ancestor, descendant) {
|
|
465
|
-
const root = assertRepositoryRoot(repositoryRoot);
|
|
466
|
-
assertReadableRevision(root, ancestor);
|
|
467
|
-
assertReadableRevision(root, descendant);
|
|
468
|
-
const result = git(root, ["merge-base", "--is-ancestor", ancestor, descendant], { allowFailure: true });
|
|
469
|
-
if (result.status === 0)
|
|
470
|
-
return true;
|
|
471
|
-
if (result.status === 1)
|
|
472
|
-
return false;
|
|
473
|
-
throw new Error(`Cannot inspect Git commit ancestry: ${result.stderr || `exit ${result.status}`}`);
|
|
474
|
-
}
|
|
475
|
-
/** Own one non-interactive exact-target push; cancellation is not confirmation of child exit. */
|
|
476
|
-
export function pushGitTarget(repositoryRoot, destination, commit, signal) {
|
|
477
|
-
if (typeof commit !== "string" || !/^[0-9a-f]{40,64}$/.test(commit))
|
|
478
|
-
return Promise.reject(new Error("Git push target must be an exact commit"));
|
|
479
|
-
if (signal?.aborted)
|
|
480
|
-
return Promise.reject(new Error("Git push aborted"));
|
|
481
|
-
return new Promise((resolve, reject) => {
|
|
482
|
-
const child = spawn("git", ["-C", repositoryRoot, "push", "--", destination.remote, `${commit}:${destination.ref}`], {
|
|
483
|
-
detached: process.platform !== "win32", // Own the POSIX group, not a detached daemon; retain the child handle.
|
|
484
|
-
stdio: ["ignore", "ignore", "pipe"],
|
|
485
|
-
windowsHide: true,
|
|
486
|
-
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" },
|
|
487
|
-
});
|
|
488
|
-
let stderr = "";
|
|
489
|
-
let failure;
|
|
490
|
-
let settled = false;
|
|
491
|
-
function terminate(error) {
|
|
492
|
-
failure ??= error;
|
|
493
|
-
if (child.exitCode !== null || child.signalCode !== null) {
|
|
494
|
-
child.stderr?.destroy(); // Do not wait indefinitely for an outliving helper's inherited pipe.
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
if (!child.pid)
|
|
498
|
-
return;
|
|
499
|
-
try {
|
|
500
|
-
if (process.platform === "win32")
|
|
501
|
-
child.kill("SIGKILL");
|
|
502
|
-
else
|
|
503
|
-
process.kill(-child.pid, "SIGKILL");
|
|
504
|
-
}
|
|
505
|
-
catch {
|
|
506
|
-
// No exit proof: keep the promise and caller's lease outstanding, even if signalling fails.
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
const abort = () => terminate(new Error("Git push aborted"));
|
|
510
|
-
const timeout = setTimeout(() => terminate(new Error(`Git push timed out after ${GIT_TIMEOUT_MS}ms`)), GIT_TIMEOUT_MS);
|
|
511
|
-
function finish(error) {
|
|
512
|
-
if (settled)
|
|
513
|
-
return;
|
|
514
|
-
settled = true;
|
|
515
|
-
clearTimeout(timeout);
|
|
516
|
-
signal?.removeEventListener("abort", abort);
|
|
517
|
-
if (error)
|
|
518
|
-
reject(error);
|
|
519
|
-
else
|
|
520
|
-
resolve();
|
|
521
|
-
}
|
|
522
|
-
child.stderr?.on("data", (chunk) => { stderr = (stderr + chunk.toString("utf8")).slice(-1000); });
|
|
523
|
-
child.on("error", (error) => {
|
|
524
|
-
failure ??= error;
|
|
525
|
-
if (!child.pid)
|
|
526
|
-
finish(error); // Spawn failure owns no live process; kill errors do not prove exit.
|
|
527
|
-
});
|
|
528
|
-
child.once("exit", () => { if (failure)
|
|
529
|
-
child.stderr?.destroy(); });
|
|
530
|
-
child.once("close", (code, endedBy) => finish(failure ?? (code === 0 ? undefined
|
|
531
|
-
: new Error(`Git push failed (${endedBy ?? code}): ${stderr.trim() || "no diagnostic output"}`))));
|
|
532
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
533
|
-
if (signal?.aborted)
|
|
534
|
-
abort();
|
|
535
|
-
});
|
|
536
|
-
}
|
|
537
|
-
export function pushGitCommit(repositoryRoot, commit) {
|
|
538
|
-
try {
|
|
539
|
-
const root = assertRepositoryRoot(repositoryRoot);
|
|
540
|
-
assertReadableRevision(root, commit);
|
|
541
|
-
const destination = pushDestination(root);
|
|
542
|
-
if (destination === undefined)
|
|
543
|
-
return { status: "local", commit };
|
|
544
|
-
const remote = git(root, ["ls-remote", destination.remote, destination.ref], { allowFailure: true });
|
|
545
|
-
const remoteHead = remote.stdout.trim().split(/\s+/)[0];
|
|
546
|
-
if (remote.status === 0 && remoteHead && /^[0-9a-f]{40,64}$/.test(remoteHead)
|
|
547
|
-
&& git(root, ["merge-base", "--is-ancestor", commit, remoteHead], { allowFailure: true }).status === 0) {
|
|
548
|
-
return { status: "pushed", commit };
|
|
549
|
-
}
|
|
550
|
-
const result = git(root, ["push", destination.remote, `${commit}:${destination.ref}`], { allowFailure: true });
|
|
551
|
-
if (result.status === 0)
|
|
552
|
-
return { status: "pushed", commit };
|
|
553
|
-
return {
|
|
554
|
-
status: "pending",
|
|
555
|
-
commit,
|
|
556
|
-
error: (result.stderr.trim() || result.stdout.trim() || `exit status ${result.status}`),
|
|
557
|
-
};
|
|
558
|
-
}
|
|
559
|
-
catch (error) {
|
|
560
|
-
return { status: "pending", commit, error: error instanceof Error ? error.message : String(error) };
|
|
561
|
-
}
|
|
180
|
+
/** Commit only current State Flow-owned files; never changes canonical acceptance. */
|
|
181
|
+
export function backupCurrentStateFlowFiles(repositoryRoot) {
|
|
182
|
+
return withBackupLock(repositoryRoot, (root) => commitCurrentOwnedFiles(root, currentHead(root)));
|
|
562
183
|
}
|