@llblab/pi-kit 0.18.1 → 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.
Files changed (139) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +4 -2
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +35 -37
  4. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +5 -3
  5. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +19 -1
  6. package/node_modules/@llblab/pi-state-flow/README.md +102 -48
  7. package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +6 -9
  8. package/node_modules/@llblab/pi-state-flow/dist/index.js +6 -9
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.d.ts +2 -2
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.js +21 -8
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.d.ts +40 -24
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.js +93 -63
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.d.ts +4 -3
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.js +9 -9
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/config.d.ts +1 -1
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/config.js +6 -5
  17. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +8 -7
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +56 -58
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +12 -24
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +7 -18
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +47 -78
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.d.ts +4 -6
  23. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +241 -437
  24. package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +2 -72
  25. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +120 -499
  26. package/node_modules/@llblab/pi-state-flow/dist/lib/history.d.ts +2 -1
  27. package/node_modules/@llblab/pi-state-flow/dist/lib/history.js +4 -3
  28. package/node_modules/@llblab/pi-state-flow/dist/lib/json.d.ts +3 -0
  29. package/node_modules/@llblab/pi-state-flow/dist/lib/json.js +43 -22
  30. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.d.ts +1 -5
  31. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.js +0 -2
  32. package/node_modules/@llblab/pi-state-flow/dist/lib/memory.d.ts +1 -14
  33. package/node_modules/@llblab/pi-state-flow/dist/lib/memory.js +5 -37
  34. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +1 -2
  35. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +13 -32
  36. package/node_modules/@llblab/pi-state-flow/dist/lib/query.d.ts +6 -6
  37. package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +25 -21
  38. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.d.ts +3 -3
  39. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.js +15 -12
  40. package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.d.ts +2 -4
  41. package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.js +5 -1
  42. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +26 -88
  43. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +159 -255
  44. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.d.ts +0 -3
  45. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.js +1 -47
  46. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +16 -33
  47. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +48 -137
  48. package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +16 -11
  49. package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +13 -14
  50. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +1 -9
  51. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +15 -43
  52. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.d.ts +5 -9
  53. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.js +13 -45
  54. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +1 -1
  55. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +15 -7
  56. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +104 -24
  57. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.d.ts +0 -2
  58. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +11 -14
  59. package/node_modules/@llblab/pi-state-flow/dist/package.json +9 -6
  60. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +11 -17
  61. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +8 -8
  62. package/node_modules/@llblab/pi-state-flow/docs/README.md +2 -2
  63. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +64 -67
  64. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +24 -6
  65. package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +12 -15
  66. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +22 -27
  67. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +25 -41
  68. package/node_modules/@llblab/pi-state-flow/docs/performance.md +38 -421
  69. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +33 -46
  70. package/node_modules/@llblab/pi-state-flow/docs/usage.md +37 -61
  71. package/node_modules/@llblab/pi-state-flow/index.ts +7 -71
  72. package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +26 -11
  73. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +116 -88
  74. package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +12 -10
  75. package/node_modules/@llblab/pi-state-flow/lib/config.ts +6 -6
  76. package/node_modules/@llblab/pi-state-flow/lib/context.ts +52 -59
  77. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +11 -20
  78. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +44 -86
  79. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +237 -460
  80. package/node_modules/@llblab/pi-state-flow/lib/git.ts +110 -552
  81. package/node_modules/@llblab/pi-state-flow/lib/history.ts +4 -3
  82. package/node_modules/@llblab/pi-state-flow/lib/json.ts +39 -23
  83. package/node_modules/@llblab/pi-state-flow/lib/logging.ts +1 -7
  84. package/node_modules/@llblab/pi-state-flow/lib/memory.ts +5 -44
  85. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +14 -25
  86. package/node_modules/@llblab/pi-state-flow/lib/query.ts +26 -22
  87. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +17 -11
  88. package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +7 -5
  89. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +154 -254
  90. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +1 -49
  91. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +58 -142
  92. package/node_modules/@llblab/pi-state-flow/lib/state.ts +27 -19
  93. package/node_modules/@llblab/pi-state-flow/lib/status.ts +16 -54
  94. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +12 -40
  95. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +1 -1
  96. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +104 -22
  97. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +16 -26
  98. package/node_modules/@llblab/pi-state-flow/package.json +9 -6
  99. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +11 -17
  100. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +8 -8
  101. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +7 -0
  102. package/node_modules/@llblab/pi-telegram/README.md +1 -2
  103. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +1 -3
  104. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +1 -6
  105. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +1 -4
  106. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +13 -33
  107. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +1 -4
  108. package/node_modules/@llblab/pi-telegram/dist/lib/menu-queue.d.ts +1 -0
  109. package/node_modules/@llblab/pi-telegram/dist/lib/menu-queue.js +72 -1
  110. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +7 -0
  111. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +27 -0
  112. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +3 -0
  113. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +17 -0
  114. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +8 -1
  115. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  116. package/node_modules/@llblab/pi-telegram/docs/architecture.md +2 -0
  117. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +1 -1
  118. package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -1
  119. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +0 -10
  120. package/node_modules/@llblab/pi-telegram/lib/commands.ts +18 -41
  121. package/node_modules/@llblab/pi-telegram/lib/extension.ts +1 -8
  122. package/node_modules/@llblab/pi-telegram/lib/menu-queue.ts +126 -1
  123. package/node_modules/@llblab/pi-telegram/lib/queue.ts +46 -0
  124. package/node_modules/@llblab/pi-telegram/lib/replies.ts +19 -0
  125. package/node_modules/@llblab/pi-telegram/lib/sync.ts +8 -1
  126. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  127. package/package.json +3 -3
  128. package/node_modules/@llblab/pi-state-flow/dist/lib/discovery.d.ts +0 -21
  129. package/node_modules/@llblab/pi-state-flow/dist/lib/discovery.js +0 -125
  130. package/node_modules/@llblab/pi-state-flow/dist/lib/maintenance.d.ts +0 -36
  131. package/node_modules/@llblab/pi-state-flow/dist/lib/maintenance.js +0 -98
  132. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.d.ts +0 -13
  133. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.js +0 -167
  134. package/node_modules/@llblab/pi-state-flow/dist/lib/publication.d.ts +0 -86
  135. package/node_modules/@llblab/pi-state-flow/dist/lib/publication.js +0 -437
  136. package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +0 -133
  137. package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +0 -147
  138. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +0 -171
  139. package/node_modules/@llblab/pi-state-flow/lib/publication.ts +0 -458
@@ -1,16 +1,10 @@
1
- // Domain: durable Git revision reads, compare-and-swap publication, and exact-commit push retry.
2
- import { createHash } from "node:crypto";
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 { spawn, spawnSync } from "node:child_process";
7
- import { assertOwnedFileUpdates, captureOwnedFileBases, captureTemporalFileBases, isStateFlowOwnedPath, parseScopeProvenance, parseScopeStream, restoreDurableFileBases, temporalScopePaths, sessionRuntimePaths, writeOwnedFileUpdates, } from "./durable.js";
8
- import { parseArtifactProvenanceRegistry } from "./artifact.js";
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
- ...(options.maxBuffer === undefined ? {} : { maxBuffer: options.maxBuffer }),
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 durable repository root mismatch: expected ${expected}, found ${actual}`);
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
- /** Serialize cooperating State Flow publishers through capture, commit, and rollback. */
70
- function withPublicationLock(repositoryRoot, action) {
71
- return withStoragePublicationLock(repositoryRoot, (lockedRoot) => {
72
- const root = assertRepositoryRoot(lockedRoot);
73
- const common = resolve(root, git(root, ["rev-parse", "--git-common-dir"]).stdout.trim());
74
- const path = resolve(common, "state-flow-publication.lock");
75
- const descriptor = acquirePublicationLock(path, (cause) => new Error(`State Flow publication lock is unavailable at ${path}; reconcile the active or interrupted publisher before retrying`, { cause }));
76
- try {
77
- writeFileSync(descriptor, `${process.pid}\n`);
78
- return action(root);
79
- }
80
- finally {
81
- closeSync(descriptor);
82
- rmSync(path);
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 durable publication requires an attached Git branch");
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
- /** One exact-path tree query per immutable revision; inspect only blobs the caller actually selects. */
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
- const observedHead = currentHead(repositoryRoot);
242
- if (observedHead !== expectedHead) {
243
- throw new Error("Durable State Flow Git base changed concurrently; reload before publishing");
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
- // A State Flow commit carries the complete non-ignored worktree delta, including user edits
254
- // and manual deletions, while `.gitignore` stays authoritative for untracked files. The
255
- // transient publication lock is ours, not repository content.
256
- git(repositoryRoot, ["add", "-A", "--", ".", ":(exclude).state-flow-publication.lock"], { env });
257
- const entries = [];
258
- for (const update of updates) {
259
- const relativePath = relativeOwnedPath(update.path, repositoryRoot);
260
- if (update.content === undefined) {
261
- git(repositoryRoot, ["update-index", "--force-remove", "--", relativePath], { env });
262
- continue;
263
- }
264
- const blob = git(repositoryRoot, ["hash-object", "-w", "--stdin"], { input: update.content }).stdout.trim();
265
- entries.push(`100644 ${blob}\t${relativePath}\0`);
266
- }
267
- // NUL framing preserves literal path characters while the blobs retain exact prepared bytes.
268
- if (entries.length > 0)
269
- git(repositoryRoot, ["update-index", "-z", "--index-info"], { env, input: entries.join("") });
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 && !rootCommit) {
272
- const previousTree = git(repositoryRoot, ["rev-parse", `${expectedHead}^{tree}`]).stdout.trim();
273
- if (tree === previousTree)
274
- return undefined;
275
- }
276
- const message = `state-flow: ${operation} ${scopes.join("+") || "runtime"} transition\n\n${STATE_FLOW_COMMIT_TRAILER}\n`;
277
- const commitArgs = ["commit-tree", tree];
278
- if (expectedHead !== undefined && !rootCommit)
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 the caller-visible index with the committed tree so advancing HEAD through the
286
- // isolated index leaves no artificial staged/unstaged status entries behind.
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, expectedHead ?? zero, commit]);
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
- /** Explicit migration entrypoint; initialization adopts it only with the temporal writer cutover. */
300
- export function migrateLegacyStorageToGit(cwd, sessionId, repositoryRoot, sessionKey = sessionId) {
301
- return withPublicationLock(repositoryRoot, (root) => migrateLegacyStorageUnderLock(cwd, sessionId, root, sessionKey));
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
  }