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