@llblab/pi-kit 0.10.7 → 0.11.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 (33) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +2 -2
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +13 -10
  4. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +15 -1
  5. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +13 -0
  6. package/node_modules/@llblab/pi-state-flow/README.md +52 -261
  7. package/node_modules/@llblab/pi-state-flow/docs/README.md +5 -1
  8. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +47 -26
  9. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +97 -0
  10. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +47 -0
  11. package/node_modules/@llblab/pi-state-flow/docs/performance.md +459 -0
  12. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +35 -3
  13. package/node_modules/@llblab/pi-state-flow/docs/usage.md +134 -0
  14. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +19 -3
  15. package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +74 -0
  16. package/node_modules/@llblab/pi-state-flow/lib/context.ts +12 -12
  17. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +4 -2
  18. package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +21 -5
  19. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +146 -37
  20. package/node_modules/@llblab/pi-state-flow/lib/git.ts +142 -42
  21. package/node_modules/@llblab/pi-state-flow/lib/publication.ts +80 -27
  22. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +109 -7
  23. package/node_modules/@llblab/pi-state-flow/lib/status.ts +7 -12
  24. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +2 -1
  25. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +2 -3
  26. package/node_modules/@llblab/pi-state-flow/package.json +5 -4
  27. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +4 -0
  28. package/node_modules/@llblab/pi-telegram/docs/architecture.md +1 -1
  29. package/node_modules/@llblab/pi-telegram/lib/extension.ts +1 -1
  30. package/node_modules/@llblab/pi-telegram/lib/ownership.ts +28 -7
  31. package/node_modules/@llblab/pi-telegram/lib/updates.ts +3 -10
  32. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  33. package/package.json +3 -3
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { closeSync, lstatSync, mkdirSync, mkdtempSync, openSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, relative, resolve, sep } from "node:path";
6
- import { spawnSync } from "node:child_process";
6
+ import { spawn, spawnSync } from "node:child_process";
7
7
  import {
8
8
  assertOwnedFileUpdates,
9
9
  captureOwnedFileBases,
@@ -52,12 +52,13 @@ export interface GitPushResult {
52
52
  function git(
53
53
  repositoryRoot: string,
54
54
  args: readonly string[],
55
- options: { allowFailure?: boolean; env?: NodeJS.ProcessEnv; input?: string } = {},
55
+ options: { allowFailure?: boolean; env?: NodeJS.ProcessEnv; input?: string; maxBuffer?: number } = {},
56
56
  ): GitResult {
57
57
  const result = spawnSync("git", ["-C", repositoryRoot, ...args], {
58
58
  encoding: "utf8",
59
59
  timeout: GIT_TIMEOUT_MS,
60
60
  input: options.input,
61
+ ...(options.maxBuffer === undefined ? {} : { maxBuffer: options.maxBuffer }),
61
62
  env: {
62
63
  ...process.env,
63
64
  GIT_TERMINAL_PROMPT: "0",
@@ -142,26 +143,51 @@ function currentBranchRef(repositoryRoot: string): string {
142
143
  return result.stdout.trim();
143
144
  }
144
145
 
145
- function revisionFile(
146
- repositoryRoot: string,
147
- revision: string,
148
- path: string,
149
- ): DurableFileBase {
150
- const relativePath = relativeOwnedPath(path, repositoryRoot);
151
- const object = `${revision}:${relativePath}`;
152
- const entry = git(repositoryRoot, ["ls-tree", "-z", revision, "--", relativePath]).stdout;
153
- if (entry.length === 0) return { path, identity: "missing" };
154
- if (!/^100(?:644|755) blob [0-9a-f]+\t/.test(entry)) {
155
- throw new Error(`Historical State Flow file is not a regular blob: ${path}`);
146
+ /** One exact-path tree query per immutable revision; inspect only blobs the caller actually selects. */
147
+ function revisionFileReader(repositoryRoot: string, revision: string, paths: readonly string[]): (path: string) => DurableFileBase {
148
+ const relativePaths = new Map(paths.map((path) => [path, relativeOwnedPath(path, repositoryRoot)]));
149
+ if (relativePaths.size === 0) throw new Error("Git revision reader requires owned paths");
150
+ const selected = new Set(relativePaths.values());
151
+ const listing = git(repositoryRoot, ["ls-tree", "-z", revision, "--", ...selected], { env: {
152
+ GIT_LITERAL_PATHSPECS: "1", GIT_GLOB_PATHSPECS: "0", GIT_NOGLOB_PATHSPECS: "0", GIT_ICASE_PATHSPECS: "0",
153
+ } }).stdout;
154
+ if (listing.length > 0 && !listing.endsWith("\0")) throw new Error("Historical Git tree listing is incomplete");
155
+ const entries = new Map<string, string | null>();
156
+ for (const entry of listing.split("\0").filter(Boolean)) {
157
+ const separator = entry.indexOf("\t");
158
+ if (separator < 0) throw new Error("Historical Git tree listing is malformed");
159
+ const path = entry.slice(separator + 1);
160
+ if (!selected.has(path)) continue;
161
+ entries.set(path, entries.has(path) ? null : entry.slice(0, separator));
156
162
  }
157
- const content = git(repositoryRoot, ["show", object]).stdout;
158
- return {
159
- path,
160
- identity: `sha256:${createHash("sha256").update(content).digest("hex")}`,
161
- content,
163
+ const files = new Map<string, DurableFileBase>();
164
+ return (path) => {
165
+ const relativePath = relativePaths.get(path);
166
+ if (relativePath === undefined) throw new Error(`Git revision reader did not select path: ${path}`);
167
+ const cached = files.get(path);
168
+ if (cached) return cached;
169
+ const entry = entries.get(relativePath);
170
+ if (entry === null) throw new Error(`Historical Git tree has duplicate path: ${relativePath}`);
171
+ if (entry === undefined) {
172
+ const missing: DurableFileBase = { path, identity: "missing" };
173
+ files.set(path, missing);
174
+ return missing;
175
+ }
176
+ if (!/^100(?:644|755) blob [0-9a-f]+$/.test(entry)) {
177
+ throw new Error(`Historical State Flow file is not a regular blob: ${path}`);
178
+ }
179
+ // Selected state/runtime blobs have no semantic byte cap; Node's default pipe budget is only 1 MiB.
180
+ const content = git(repositoryRoot, ["show", `${revision}:${relativePath}`], { maxBuffer: Infinity }).stdout;
181
+ const file: DurableFileBase = { path, identity: `sha256:${createHash("sha256").update(content).digest("hex")}`, content };
182
+ files.set(path, file);
183
+ return file;
162
184
  };
163
185
  }
164
186
 
187
+ function revisionFile(repositoryRoot: string, revision: string, path: string): DurableFileBase {
188
+ return revisionFileReader(repositoryRoot, revision, [path])(path);
189
+ }
190
+
165
191
  function assertReadableRevision(root: string, revision: string): void {
166
192
  if (!/^[0-9a-f]{40,64}$/.test(revision)
167
193
  || git(root, ["cat-file", "-e", `${revision}^{commit}`], { allowFailure: true }).status !== 0) {
@@ -192,29 +218,41 @@ function captureTemporalBaseUnderLock(cwd: string, sessionId: string, root: stri
192
218
  return { head: currentHead(root), files: captureTemporalFileBases(cwd, sessionId, root, sessionKey) };
193
219
  }
194
220
 
195
- function revisionScopeFiles(root: string, revision: string, cwd: string, sessionId: string, sessionKey: string, scope: StateScope) {
196
- const read = (paths: ReturnType<typeof temporalScopePaths>) => ({
221
+ function revisionScopeFiles(read: (path: string) => DurableFileBase, paths: ReturnType<typeof temporalScopePaths>) {
222
+ return {
197
223
  paths,
198
- checkpoint: revisionFile(root, revision, paths.checkpoint),
199
- patches: revisionFile(root, revision, paths.patches),
200
- legacy: revisionFile(root, revision, resolve(paths.directory, "state.json")),
201
- meta: revisionFile(root, revision, paths.meta),
202
- });
203
- const canonical = read(temporalScopePaths(cwd, sessionId, scope, root, sessionKey));
204
- if (scope === "global" || [canonical.checkpoint, canonical.patches, canonical.legacy].some(({ identity }) => identity !== "missing")) return { ...canonical, legacyLayout: false };
205
- return { ...read(legacyTemporalScopePaths(cwd, sessionId, scope, root)), legacyLayout: true };
224
+ checkpoint: read(paths.checkpoint),
225
+ patches: read(paths.patches),
226
+ legacy: read(resolve(paths.directory, "state.json")),
227
+ meta: read(paths.meta),
228
+ };
206
229
  }
207
230
 
208
231
  /** Cold scope-stream reconstruction; pre-0.4 hashed paths remain read-only revision input. */
209
232
  export function loadTemporalRevision(cwd: string, sessionId: string, repositoryRoot: string, revision: string, sessionKey = sessionId): TemporalRevisionLoad {
210
233
  const root = assertRepositoryRoot(repositoryRoot);
211
234
  assertReadableRevision(root, revision);
235
+ const scopePaths = (["global", "cwd", "session"] as const).map((scope) => ({
236
+ scope,
237
+ canonical: temporalScopePaths(cwd, sessionId, scope, root, sessionKey),
238
+ legacy: scope === "global" ? undefined : legacyTemporalScopePaths(cwd, sessionId, scope, root),
239
+ }));
240
+ const canonicalRuntime = sessionRuntimePaths(cwd, sessionId, root, sessionKey);
241
+ const legacyRuntime = legacySessionRuntimePaths(cwd, sessionId, root);
242
+ const readFile = revisionFileReader(root, revision, [
243
+ ...scopePaths.flatMap(({ canonical, legacy }) => legacy ? [canonical, legacy] : [canonical])
244
+ .flatMap((paths) => [paths.checkpoint, paths.patches, resolve(paths.directory, "state.json"), paths.meta]),
245
+ canonicalRuntime.config, canonicalRuntime.meta, legacyRuntime.config, legacyRuntime.meta,
246
+ ]);
212
247
  const files: DurableFileBase[] = [];
213
248
  const scopes = {} as Record<StateScope, ScopeStream | undefined>;
214
249
  const provenance: Record<StateScope, ArtifactProvenanceRegistry> = { global: {}, cwd: {}, session: {} };
215
250
  let legacyLayout = false;
216
- for (const scope of ["global", "cwd", "session"] as const) {
217
- const selected = revisionScopeFiles(root, revision, cwd, sessionId, sessionKey, scope);
251
+ for (const { scope, canonical, legacy } of scopePaths) {
252
+ let selected = { ...revisionScopeFiles(readFile, canonical), legacyLayout: false };
253
+ if (legacy && [selected.checkpoint, selected.patches, selected.legacy].every(({ identity }) => identity === "missing")) {
254
+ selected = { ...revisionScopeFiles(readFile, legacy), legacyLayout: true };
255
+ }
218
256
  if (selected.legacy.identity !== "missing") throw new Error("Historical legacy storage requires explicit migration interpretation");
219
257
  legacyLayout ||= selected.legacyLayout;
220
258
  files.push(selected.checkpoint, selected.patches, selected.legacy, ...(scope === "session" ? [] : [selected.meta]));
@@ -223,11 +261,11 @@ export function loadTemporalRevision(cwd: string, sessionId: string, repositoryR
223
261
  scope === "cwd" && !selected.legacyLayout ? cwd : undefined);
224
262
  }
225
263
  const readRuntime = (paths: ReturnType<typeof sessionRuntimePaths>) => ({
226
- paths, config: revisionFile(root, revision, paths.config), meta: revisionFile(root, revision, paths.meta),
264
+ paths, config: readFile(paths.config), meta: readFile(paths.meta),
227
265
  });
228
- let selectedRuntime = { ...readRuntime(sessionRuntimePaths(cwd, sessionId, root, sessionKey)), legacyLayout: false };
266
+ let selectedRuntime = { ...readRuntime(canonicalRuntime), legacyLayout: false };
229
267
  if (selectedRuntime.config.identity === "missing" && selectedRuntime.meta.identity === "missing") {
230
- selectedRuntime = { ...readRuntime(legacySessionRuntimePaths(cwd, sessionId, root)), legacyLayout: true };
268
+ selectedRuntime = { ...readRuntime(legacyRuntime), legacyLayout: true };
231
269
  }
232
270
  legacyLayout ||= selectedRuntime.legacyLayout;
233
271
  const { paths: runtimePaths, config, meta } = selectedRuntime;
@@ -247,6 +285,7 @@ export function loadTemporalRevision(cwd: string, sessionId: string, repositoryR
247
285
  }
248
286
  const selected = loadTemporalRevision(cwd, sessionId, root, temporalRevision, sessionKey);
249
287
  Object.assign(scopes, selected.scopes);
288
+ Object.assign(provenance, selected.provenance);
250
289
  }
251
290
  if (scopes.global === undefined || scopes.cwd === undefined || scopes.session === undefined) {
252
291
  throw new Error("Session runtime has incomplete temporal scope storage");
@@ -430,6 +469,7 @@ function commitOwnedFiles(
430
469
  // and manual deletions, while `.gitignore` stays authoritative for untracked files. The
431
470
  // transient publication lock is ours, not repository content.
432
471
  git(repositoryRoot, ["add", "-A", "--", ".", ":(exclude).state-flow-publication.lock"], { env });
472
+ const entries: string[] = [];
433
473
  for (const update of updates) {
434
474
  const relativePath = relativeOwnedPath(update.path, repositoryRoot);
435
475
  if (update.content === undefined) {
@@ -437,8 +477,10 @@ function commitOwnedFiles(
437
477
  continue;
438
478
  }
439
479
  const blob = git(repositoryRoot, ["hash-object", "-w", "--stdin"], { input: update.content }).stdout.trim();
440
- git(repositoryRoot, ["update-index", "--add", "--cacheinfo", `100644,${blob},${relativePath}`], { env });
480
+ entries.push(`100644 ${blob}\t${relativePath}\0`);
441
481
  }
482
+ // NUL framing preserves literal path characters while the blobs retain exact prepared bytes.
483
+ if (entries.length > 0) git(repositoryRoot, ["update-index", "-z", "--index-info"], { env, input: entries.join("") });
442
484
  const tree = git(repositoryRoot, ["write-tree"], { env }).stdout.trim();
443
485
  if (expectedHead !== undefined) {
444
486
  const previousTree = git(repositoryRoot, ["rev-parse", `${expectedHead}^{tree}`]).stdout.trim();
@@ -559,20 +601,22 @@ function includeUncommittedCohort(cwd: string, sessionId: string, root: string,
559
601
  updates: OwnedFileUpdate[], changedScopes: StateScope[], scopes: readonly StateScope[], runtime: boolean, sessionKey = sessionId): void {
560
602
  const targets = new Map(updates.map((update) => [update.path, update]));
561
603
  const desired = (paths: string[]) => paths.map((path) => targets.get(path) ?? { path, content: current.files.find((file) => file.path === path)!.content });
562
- const absentFromHead = (files: OwnedFileUpdate[]) => files.some(({ path, content }) => current.head === undefined || revisionFile(root, current.head, path).content !== content);
563
- for (const scope of ["global", "cwd", "session"] as const) {
604
+ const pairs = (["global", "cwd", "session"] as const).map((scope) => {
564
605
  const paths = temporalScopePaths(cwd, sessionId, scope, root, sessionKey);
565
- const pair = desired([paths.checkpoint, paths.patches]);
606
+ return { scope, pair: desired([paths.checkpoint, paths.patches]) };
607
+ });
608
+ const runtimePaths = runtime ? sessionRuntimePaths(cwd, sessionId, root, sessionKey) : undefined;
609
+ const runtimePair = runtimePaths ? desired([runtimePaths.config, runtimePaths.meta]) : [];
610
+ const readFile = current.head === undefined ? undefined : revisionFileReader(root, current.head,
611
+ [...pairs.flatMap(({ pair }) => pair), ...runtimePair].map(({ path }) => path));
612
+ const absentFromHead = (files: OwnedFileUpdate[]) => files.some(({ path, content }) => readFile === undefined || readFile(path).content !== content);
613
+ for (const { scope, pair } of pairs) {
566
614
  if (!absentFromHead(pair)) continue;
567
615
  if (!scopes.includes(scope)) throw new Error(`Temporal scope update omitted an uncommitted stream: ${scope}`);
568
616
  for (const update of pair) targets.set(update.path, update);
569
617
  if (!changedScopes.includes(scope)) changedScopes.push(scope);
570
618
  }
571
- if (runtime) {
572
- const paths = sessionRuntimePaths(cwd, sessionId, root, sessionKey);
573
- const pair = desired([paths.config, paths.meta]);
574
- if (absentFromHead(pair)) for (const update of pair) targets.set(update.path, update);
575
- }
619
+ if (absentFromHead(runtimePair)) for (const update of runtimePair) targets.set(update.path, update);
576
620
  updates.splice(0, updates.length, ...targets.values());
577
621
  }
578
622
 
@@ -657,6 +701,62 @@ export function isGitCommitAncestor(repositoryRoot: string, ancestor: string, de
657
701
  throw new Error(`Cannot inspect Git commit ancestry: ${result.stderr || `exit ${result.status}`}`);
658
702
  }
659
703
 
704
+ /** Own one non-interactive exact-target push; cancellation is not confirmation of child exit. */
705
+ export function pushGitTarget(
706
+ repositoryRoot: string,
707
+ destination: { remote: string; ref: string },
708
+ commit: string,
709
+ signal?: AbortSignal,
710
+ ): Promise<void> {
711
+ if (typeof commit !== "string" || !/^[0-9a-f]{40,64}$/.test(commit)) return Promise.reject(new Error("Git push target must be an exact commit"));
712
+ if (signal?.aborted) return Promise.reject(new Error("Git push aborted"));
713
+ return new Promise<void>((resolve, reject) => {
714
+ const child = spawn("git", ["-C", repositoryRoot, "push", "--", destination.remote, `${commit}:${destination.ref}`], {
715
+ detached: process.platform !== "win32", // Own the POSIX group, not a detached daemon; retain the child handle.
716
+ stdio: ["ignore", "ignore", "pipe"],
717
+ windowsHide: true,
718
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" },
719
+ });
720
+ let stderr = "";
721
+ let failure: Error | undefined;
722
+ let settled = false;
723
+ function terminate(error: Error): void {
724
+ failure ??= error;
725
+ if (child.exitCode !== null || child.signalCode !== null) {
726
+ child.stderr?.destroy(); // Do not wait indefinitely for an outliving helper's inherited pipe.
727
+ return;
728
+ }
729
+ if (!child.pid) return;
730
+ try {
731
+ if (process.platform === "win32") child.kill("SIGKILL");
732
+ else process.kill(-child.pid, "SIGKILL");
733
+ } catch {
734
+ // No exit proof: keep the promise and caller's lease outstanding, even if signalling fails.
735
+ }
736
+ }
737
+ const abort = () => terminate(new Error("Git push aborted"));
738
+ const timeout = setTimeout(() => terminate(new Error(`Git push timed out after ${GIT_TIMEOUT_MS}ms`)), GIT_TIMEOUT_MS);
739
+ function finish(error?: Error): void {
740
+ if (settled) return;
741
+ settled = true;
742
+ clearTimeout(timeout);
743
+ signal?.removeEventListener("abort", abort);
744
+ if (error) reject(error);
745
+ else resolve();
746
+ }
747
+ child.stderr?.on("data", (chunk: Buffer) => { stderr = (stderr + chunk.toString("utf8")).slice(-1000); });
748
+ child.on("error", (error) => {
749
+ failure ??= error;
750
+ if (!child.pid) finish(error); // Spawn failure owns no live process; kill errors do not prove exit.
751
+ });
752
+ child.once("exit", () => { if (failure) child.stderr?.destroy(); });
753
+ child.once("close", (code, endedBy) => finish(failure ?? (code === 0 ? undefined
754
+ : new Error(`Git push failed (${endedBy ?? code}): ${stderr.trim() || "no diagnostic output"}`))));
755
+ signal?.addEventListener("abort", abort, { once: true });
756
+ if (signal?.aborted) abort();
757
+ });
758
+ }
759
+
660
760
  export function pushGitCommit(repositoryRoot: string, commit: string): GitPushResult {
661
761
  try {
662
762
  const root = assertRepositoryRoot(repositoryRoot);
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname, parse, resolve, sep } from "node:path";
4
4
 
5
5
 
@@ -62,7 +62,9 @@ export function remotePublicationDestinationKey(destination: RemotePublicationDe
62
62
  return JSON.stringify([resolve(destination.gitCommonDir), destination.remote, destination.ref]);
63
63
  }
64
64
 
65
- const COMMIT = /^[0-9a-f]{40,64}$/;
65
+ function isCommit(value: unknown): value is string {
66
+ return typeof value === "string" && /^[0-9a-f]{40,64}$/.test(value);
67
+ }
66
68
  export type PublicationQueueStatus = "pending" | "pushing" | "failed";
67
69
  export interface PublicationQueueState {
68
70
  version: 1;
@@ -76,7 +78,7 @@ export interface PublicationQueueState {
76
78
  export type CommitAncestor = (ancestor: string, descendant: string) => boolean;
77
79
 
78
80
  export function createPublicationQueue(destination: RemotePublicationDestination, target: string): PublicationQueueState {
79
- if (!COMMIT.test(target)) throw new Error("Publication queue target must be an exact commit");
81
+ if (!isCommit(target)) throw new Error("Publication queue target must be an exact commit");
80
82
  remotePublicationDestinationKey(destination);
81
83
  return { version: 1, destination: structuredClone(destination), target, status: "pending", attempt: 0 };
82
84
  }
@@ -88,7 +90,7 @@ export interface PublicationCoalesceObserver {
88
90
 
89
91
  export function coalescePublicationTarget(state: PublicationQueueState, destination: RemotePublicationDestination, target: string, isAncestor: CommitAncestor, observer?: PublicationCoalesceObserver): PublicationQueueState {
90
92
  validatePublicationQueue(state);
91
- if (!COMMIT.test(target)) throw new Error("Publication queue target must be an exact commit");
93
+ if (!isCommit(target)) throw new Error("Publication queue target must be an exact commit");
92
94
  if (remotePublicationDestinationKey(state.destination) !== remotePublicationDestinationKey(destination)) throw new Error("Publication queue destination changed");
93
95
  if (target === state.target || isAncestor(target, state.target)) return structuredClone(state);
94
96
  const previous = structuredClone(state);
@@ -129,7 +131,7 @@ export function failPublicationAttempt(state: PublicationQueueState, error: stri
129
131
 
130
132
  export function confirmPublicationTarget(state: PublicationQueueState, pushed: string, isAncestor: CommitAncestor): PublicationQueueState | undefined {
131
133
  validatePublicationQueue(state);
132
- if (!COMMIT.test(pushed)) throw new Error("Confirmed publication target must be an exact commit");
134
+ if (!isCommit(pushed)) throw new Error("Confirmed publication target must be an exact commit");
133
135
  if (pushed === state.target) return undefined;
134
136
  if (!isAncestor(pushed, state.target)) throw new Error("Publication confirmation does not cover the queued lineage");
135
137
  return { ...structuredClone(state), confirmed: pushed, status: "pending", error: undefined };
@@ -146,11 +148,11 @@ export function recoverPublicationQueue(state: PublicationQueueState): Publicati
146
148
  export function validatePublicationQueue(value: unknown): asserts value is PublicationQueueState {
147
149
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Invalid publication queue document");
148
150
  const v = value as Record<string, unknown>;
149
- if (v.version !== 1 || !COMMIT.test(String(v.target)) || !Number.isSafeInteger(v.attempt) || (v.attempt as number) < 0
151
+ if (v.version !== 1 || !isCommit(v.target) || !Number.isSafeInteger(v.attempt) || (v.attempt as number) < 0
150
152
  || (v.status !== "pending" && v.status !== "pushing" && v.status !== "failed")
151
153
  || typeof v.destination !== "object" || v.destination === null) throw new Error("Invalid publication queue document");
152
154
  remotePublicationDestinationKey(v.destination as unknown as RemotePublicationDestination);
153
- if (v.confirmed !== undefined && !COMMIT.test(String(v.confirmed))) throw new Error("Invalid publication queue document");
155
+ if (v.confirmed !== undefined && !isCommit(v.confirmed)) throw new Error("Invalid publication queue document");
154
156
  if (v.error !== undefined && (typeof v.error !== "string" || !v.error.trim())) throw new Error("Invalid publication queue document");
155
157
  const allowed = new Set(["version", "destination", "target", "confirmed", "status", "attempt", "error"]);
156
158
  if (Object.keys(v).some((key) => !allowed.has(key))) throw new Error("Invalid publication queue document");
@@ -181,6 +183,43 @@ export interface PublicationWorkerLease {
181
183
  release(): void;
182
184
  }
183
185
 
186
+ interface WorkerLeaseDocument {
187
+ version: 1;
188
+ pid: number;
189
+ token: string;
190
+ startedAt: string;
191
+ }
192
+
193
+ function readWorkerLease(path: string): WorkerLeaseDocument | undefined {
194
+ assertNoSymlinkAncestors(dirname(path));
195
+ const stat = lstatSync(path, { throwIfNoEntry: false });
196
+ if (!stat) return undefined;
197
+ if (!stat.isFile()) throw new Error("Publication worker lease path must be a regular file");
198
+ let descriptor: number;
199
+ try { descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
200
+ catch (error) {
201
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; // A live owner may have released it.
202
+ throw error;
203
+ }
204
+ try {
205
+ if (!fstatSync(descriptor).isFile()) throw new Error("Publication worker lease path must be a regular file");
206
+ const source = readFileSync(descriptor, "utf8");
207
+ let value: unknown;
208
+ try { value = JSON.parse(source); } catch { throw new Error("Publication worker lease is malformed"); }
209
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Publication worker lease is malformed");
210
+ const record = value as Record<string, unknown>;
211
+ if (Object.keys(record).sort().join(",") !== "pid,startedAt,token,version" || record.version !== 1
212
+ || !Number.isSafeInteger(record.pid) || (record.pid as number) <= 0 || (record.pid as number) > 2147483647
213
+ || typeof record.token !== "string" || !record.token.trim() || record.token !== record.token.trim()
214
+ || typeof record.startedAt !== "string" || !Number.isFinite(Date.parse(record.startedAt))) {
215
+ throw new Error("Publication worker lease is malformed");
216
+ }
217
+ return record as unknown as WorkerLeaseDocument;
218
+ } finally {
219
+ closeSync(descriptor);
220
+ }
221
+ }
222
+
184
223
  function processAlive(pid: number): boolean {
185
224
  try { process.kill(pid, 0); return true; }
186
225
  catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; }
@@ -191,28 +230,42 @@ export function acquirePublicationWorkerLease(queuePath: string): PublicationWor
191
230
  assertNoSymlinkAncestors(dirname(path));
192
231
  const token = randomUUID();
193
232
  const document = `${JSON.stringify({ version: 1, pid: process.pid, token, startedAt: new Date().toISOString() })}\n`;
194
- for (let attempt = 0; attempt < 2; attempt++) {
195
- try {
196
- writeFileSync(path, document, { flag: "wx", mode: 0o600 });
197
- return {
198
- path, token,
199
- release() {
200
- let current: unknown;
201
- try { current = JSON.parse(readFileSync(path, "utf8")); } catch { return; }
202
- if (typeof current === "object" && current !== null && (current as { token?: unknown }).token === token) rmSync(path, { force: true });
203
- },
204
- };
205
- } catch (error) {
206
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
207
- let current: unknown;
208
- try { current = JSON.parse(readFileSync(path, "utf8")); } catch { throw new Error("Publication worker lease is malformed"); }
209
- const pid = typeof current === "object" && current !== null ? (current as { pid?: unknown }).pid : undefined;
210
- if (!Number.isSafeInteger(pid) || (pid as number) <= 0) throw new Error("Publication worker lease is malformed");
211
- if (processAlive(pid as number)) return undefined;
212
- rmSync(path, { force: true });
233
+ const create = (): PublicationWorkerLease | undefined => {
234
+ try { writeFileSync(path, document, { flag: "wx", mode: 0o600 }); }
235
+ catch (error) {
236
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return undefined;
237
+ throw error;
213
238
  }
239
+ return { path, token, release() {
240
+ let current: WorkerLeaseDocument | undefined;
241
+ try { current = readWorkerLease(path); } catch { return; }
242
+ // A live owner cannot be reclaimed, so release need not wait for queue writers.
243
+ if (current?.pid === process.pid && current.token === token) rmSync(path, { force: true });
244
+ } };
245
+ };
246
+ const lease = create();
247
+ if (lease) return lease;
248
+ const current = readWorkerLease(path);
249
+ if (!current) return create();
250
+ if (processAlive(current.pid)) return undefined;
251
+ // Only reclamation needs the existing queue writer gate; exclusive creation protects fresh claims.
252
+ const gate = `${resolve(queuePath)}.lock`;
253
+ assertNoSymlinkAncestors(dirname(gate));
254
+ let descriptor: number;
255
+ try { descriptor = openSync(gate, "wx", 0o600); }
256
+ catch (error) {
257
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return undefined;
258
+ throw error;
259
+ }
260
+ try {
261
+ const owner = readWorkerLease(path);
262
+ if (owner && processAlive(owner.pid)) return undefined;
263
+ if (owner) rmSync(path, { force: true });
264
+ return create(); // A fresh claimant may win the gap; never remove its replacement.
265
+ } finally {
266
+ closeSync(descriptor);
267
+ rmSync(gate, { force: true });
214
268
  }
215
- return undefined;
216
269
  }
217
270
 
218
271
  export function loadPublicationQueue(path: string): PublicationQueueState | undefined {
@@ -16,6 +16,12 @@ const SCOPES = ["global", "cwd", "session"] as const;
16
16
  const SHARED_SCOPES = ["global", "cwd"] as const;
17
17
  export type RuntimePublication = ReturnType<typeof publishTemporalStateToGit> & { revision?: string };
18
18
 
19
+ interface SessionCopy {
20
+ stream: ScopeStream;
21
+ provenance: ArtifactProvenanceRegistry;
22
+ backend: "git" | "files";
23
+ }
24
+
19
25
  function emptyProvenance(): Record<StateScope, ArtifactProvenanceRegistry> {
20
26
  return { global: {}, cwd: {}, session: {} };
21
27
  }
@@ -33,10 +39,14 @@ function targetScopeConflict(scopes: readonly StateScope[]): Error {
33
39
  return new Error(`State Flow cannot publish the ${labels.join(" and ")} patches because the live ${labels.join(" and ")} states advanced after this transition's selected basis. Refresh or reconcile the target scopes before retrying.`);
34
40
  }
35
41
 
42
+ export class MissingSessionRuntimeError extends Error {
43
+ constructor() { super("Linked State Flow revision has no session runtime"); }
44
+ }
45
+
36
46
  /** Immutable target validation is independent of acquiring the live publication basis. */
37
47
  export function inspectRuntimeRevision(cwd: string, sessionId: string, root: string, revision: string, sessionKey = sessionId) {
38
48
  const loaded = loadTemporalRevision(cwd, sessionId, root, revision, sessionKey);
39
- if (!loaded.runtime) throw new Error("Linked State Flow revision has no session runtime");
49
+ if (!loaded.runtime) throw new MissingSessionRuntimeError();
40
50
  const resolved = resolveSessionRuntime(loaded.runtime.document, loaded.runtime.revision);
41
51
  if (!loaded.scopes.global || !loaded.scopes.cwd || !loaded.scopes.session) throw new Error("Incomplete temporal scope cohort");
42
52
  const view = { lineage: resolved.lineage, scopes: { global: loaded.scopes.global, cwd: loaded.scopes.cwd, session: loaded.scopes.session } };
@@ -138,8 +148,66 @@ export class TemporalRuntime {
138
148
  return result;
139
149
  }
140
150
 
141
- restore(revision: string, legacySnapshot?: Snapshot): Snapshot {
151
+ /** Validate selection without installing state; only a matching immutable Git inspection is reusable. */
152
+ prepareRestore(revision: string, legacySnapshot?: Snapshot): { snapshot: Snapshot; restore: () => Snapshot } {
142
153
  const inspected = inspectSnapshotRevision(this.cwd, this.sessionId, this.root, revision, legacySnapshot, this.sessionKey);
154
+ const selected = inspected.snapshot.meta.durableBase ?? revision;
155
+ let consumed = false;
156
+ return {
157
+ snapshot: structuredClone(inspected.snapshot),
158
+ restore: () => {
159
+ if (consumed) throw new Error("Prepared State Flow restore was already consumed");
160
+ consumed = true;
161
+ // File cohorts can expire; legacy migration and owner redirection keep their fresh-read path.
162
+ return inspected.temporal && selected === revision
163
+ ? this.restoreInspected(revision, inspected)
164
+ : this.restore(selected, inspected.snapshot);
165
+ },
166
+ };
167
+ }
168
+
169
+ /** Copy only a proven source session stream; shared streams come from the child's fresh live basis. */
170
+ prepareFork(source: SessionAddress, revision: string): { snapshot: Snapshot; fork: () => { snapshot: Snapshot; publication: RuntimePublication } } {
171
+ const parent = Object.freeze({ ...source });
172
+ if (parent.id === this.sessionId || parent.key === this.sessionKey) throw new Error("State Flow fork requires a distinct session identity and key");
173
+ const inspect = () => inspectSnapshotRevision(this.cwd, parent.id, this.root, revision, undefined, parent.key);
174
+ const inspected = inspect();
175
+ const snapshot: Snapshot = {
176
+ config: structuredClone(inspected.snapshot.config),
177
+ meta: {
178
+ step: 0,
179
+ ...(inspected.snapshot.meta.bootstrap === undefined ? {} : { bootstrap: inspected.snapshot.meta.bootstrap }),
180
+ ...(inspected.snapshot.meta.remotePublication === undefined ? {} : { remotePublication: structuredClone(inspected.snapshot.meta.remotePublication) }),
181
+ },
182
+ };
183
+ let consumed = false;
184
+ return {
185
+ snapshot: { ...structuredClone(snapshot), meta: { ...structuredClone(snapshot.meta), durableBase: revision } },
186
+ fork: () => {
187
+ if (consumed) throw new Error("Prepared State Flow fork was already consumed");
188
+ consumed = true;
189
+ if (this.view) throw new Error("State Flow fork target already has session storage");
190
+ // Unlike immutable Git input, a file-only source must still match its complete cohort.
191
+ const current = inspected.file ? inspect() : inspected;
192
+ const selected = current.temporal ?? current.file;
193
+ if (!selected) throw new Error("State Flow fork requires a temporal session stream");
194
+ const publication = this.initializeOrigin(snapshot, { allowCreateCwd: false, copy: {
195
+ stream: selected.view.scopes.session,
196
+ provenance: selected.provenance.session,
197
+ backend: current.file ? "files" : "git",
198
+ } });
199
+ const ownedRevision = publication?.revision ?? publication?.commit;
200
+ if (!publication || !ownedRevision) throw new Error("State Flow fork requires existing shared scope storage");
201
+ return { snapshot: { ...structuredClone(snapshot), meta: { ...structuredClone(snapshot.meta), durableBase: ownedRevision } }, publication };
202
+ },
203
+ };
204
+ }
205
+
206
+ restore(revision: string, legacySnapshot?: Snapshot): Snapshot {
207
+ return this.restoreInspected(revision, inspectSnapshotRevision(this.cwd, this.sessionId, this.root, revision, legacySnapshot, this.sessionKey));
208
+ }
209
+
210
+ private restoreInspected(revision: string, inspected: ReturnType<typeof inspectSnapshotRevision>): Snapshot {
143
211
  if (inspected.file) {
144
212
  const savedRuntime = hashJson(createSessionRuntime(inspected.snapshot, this.cwd, this.sessionId, inspected.file.view.lineage, "files", inspected.file.provenance.session));
145
213
  this.view = inspected.file.view;
@@ -205,16 +273,26 @@ export class TemporalRuntime {
205
273
  }
206
274
 
207
275
  initialize(snapshot: Snapshot, allowCreateCwd: boolean, expectedShared?: Pick<ScopedStates, "global" | "cwd">, newSessionOrigin = false): RuntimePublication | undefined {
276
+ return this.initializeOrigin(snapshot, { allowCreateCwd, expectedShared, newSessionOrigin });
277
+ }
278
+
279
+ private initializeOrigin(snapshot: Snapshot, options: {
280
+ allowCreateCwd: boolean;
281
+ expectedShared?: Pick<ScopedStates, "global" | "cwd">;
282
+ newSessionOrigin?: boolean;
283
+ copy?: SessionCopy;
284
+ }): RuntimePublication | undefined {
285
+ const { allowCreateCwd, expectedShared, newSessionOrigin = false, copy } = options;
208
286
  const hasCwd = hasCwdMaterialization(this.cwd, this.root);
209
287
  if (!allowCreateCwd && !hasCwd) return undefined;
210
- const backend = this.backend ?? (detectGitCapability() === "git" && lstatSync(join(this.root, ".git"), { throwIfNoEntry: false }) ? "git" : "files");
211
- if (backend === "git") {
288
+ const backend = copy?.backend ?? this.backend ?? (detectGitCapability() === "git" && lstatSync(join(this.root, ".git"), { throwIfNoEntry: false }) ? "git" : "files");
289
+ if (!copy && backend === "git") {
212
290
  if (!hasCwd) migrateHashedCwdAtHead(this.cwd, this.root);
213
291
  if (hasLegacyStateSources(this.cwd, this.sessionId, this.root, this.sessionKey)) {
214
292
  migrateLegacyStorageToGit(this.cwd, this.sessionId, this.root, this.sessionKey);
215
293
  }
216
294
  }
217
- else {
295
+ else if (!copy) {
218
296
  if (allowCreateCwd) initializeFileStore(this.root);
219
297
  if (hasLegacyStateSources(this.cwd, this.sessionId, this.root, this.sessionKey)) {
220
298
  migrateLegacyStorageToFiles(this.cwd, this.sessionId, this.root, this.sessionKey);
@@ -222,6 +300,15 @@ export class TemporalRuntime {
222
300
  }
223
301
  const base: TemporalGitBase = backend === "git" ? captureTemporalGitBase(this.cwd, this.sessionId, this.root, this.sessionKey) : captureTemporalFileBase(this.cwd, this.sessionId, this.root, this.sessionKey);
224
302
  const files = new Map(base.files.map((file) => [file.path, file.content]));
303
+ if (copy) {
304
+ const session = temporalScopePaths(this.cwd, this.sessionId, "session", this.root, this.sessionKey);
305
+ const owned = [session.checkpoint, session.patches, session.meta, join(session.directory, "config.json"), join(session.directory, "state.json")];
306
+ const occupied = owned.some((path) => files.get(path) !== undefined);
307
+ const historical = !occupied && backend === "git" && base.head ? loadTemporalRevision(this.cwd, this.sessionId, this.root, base.head, this.sessionKey) : undefined;
308
+ if (occupied || historical?.runtime || historical?.scopes.session) {
309
+ throw new Error("State Flow fork target already has session storage");
310
+ }
311
+ }
225
312
  if (SCOPES.some((scope) => {
226
313
  const paths = temporalScopePaths(this.cwd, this.sessionId, scope, this.root, this.sessionKey);
227
314
  return files.get(join(paths.directory, "state.json")) !== undefined;
@@ -231,11 +318,13 @@ export class TemporalRuntime {
231
318
  return [scope, parseScopeStream(files.get(paths.checkpoint), files.get(paths.patches), scope, scope === "cwd" ? this.cwd : undefined)];
232
319
  })) as Record<StateScope, TemporalState["scopes"][StateScope] | undefined>;
233
320
  if (!streams.cwd && !allowCreateCwd) return undefined;
321
+ if (copy && !streams.global) throw new Error("State Flow fork requires existing shared scope storage");
234
322
  const paths = sessionRuntimePaths(this.cwd, this.sessionId, this.root, this.sessionKey);
235
323
  const existingRuntime = parseSessionRuntime(files.get(paths.config), files.get(paths.meta), this.cwd, this.sessionId);
236
324
  if (existingRuntime && !snapshot.legacySession && !newSessionOrigin) throw new Error("Existing session runtime requires a branch revision pointer");
237
325
  // Explicit start before any branch runtime is a new origin, never inheritance of a later session layer.
238
326
  if (snapshot.legacySession || newSessionOrigin) streams.session = undefined;
327
+ if (copy) streams.session = copy.stream;
239
328
  const fresh = createTemporalState({ global: emptyState(), cwd: emptyState(), session: snapshot.legacySession?.state ?? emptyState() }, randomUUID());
240
329
  const candidate = new TemporalRuntime(this.cwd, this.session, this.root);
241
330
  candidate.backend = backend;
@@ -246,12 +335,12 @@ export class TemporalRuntime {
246
335
  candidate.provenanceByScope = {
247
336
  global: parseScopeProvenance(files.get(globalMeta), globalMeta),
248
337
  cwd: parseScopeProvenance(files.get(cwdMeta), cwdMeta),
249
- session: streams.session === undefined ? {} : parseArtifactProvenanceRegistry(existingRuntime?.meta.artifacts, "State Flow session artifact provenance"),
338
+ session: copy ? structuredClone(copy.provenance) : streams.session === undefined ? {} : parseArtifactProvenanceRegistry(existingRuntime?.meta.artifacts, "State Flow session artifact provenance"),
250
339
  };
251
340
  if (expectedShared && (["global", "cwd"] as const).some((scope) => !sameJson(candidate.read(0, scope), expectedShared[scope]))) {
252
341
  throw new Error("Legacy branch shared scopes diverged from the selected revision; migration cannot overwrite them");
253
342
  }
254
- const publication = candidate.publish(snapshot, true);
343
+ const publication = copy ? candidate.publishForkOrigin(snapshot) : candidate.publish(snapshot, true);
255
344
  this.view = candidate.view;
256
345
  this.base = candidate.base;
257
346
  this.backend = backend;
@@ -261,6 +350,19 @@ export class TemporalRuntime {
261
350
  return publication;
262
351
  }
263
352
 
353
+ /** Initial copy owns only the new session files, without pruning or rewriting shared provenance. */
354
+ private publishForkOrigin(snapshot: Snapshot): RuntimePublication {
355
+ const runtime = createSessionRuntime(snapshot, this.cwd, this.sessionId, this.view!.lineage, this.backend === "files" ? "files" : "unconfirmed", this.provenanceByScope.session);
356
+ const result = this.backend === "files"
357
+ ? publishTemporalStateToFiles(this.cwd, this.sessionId, this.view!, ["session"], this.base!, this.root, runtime, this.sessionKey, this.provenanceByScope)
358
+ : publishTemporalStateToGit(this.cwd, this.sessionId, this.view!, ["session"], this.base!, this.root, runtime, this.sessionKey, (snapshot.meta.remotePublication?.mode ?? "transition") === "transition", this.provenanceByScope);
359
+ const publication: RuntimePublication = result;
360
+ this.base = publication.base;
361
+ this.semanticRevision = publication.revision ?? publication.commit;
362
+ this.savedRuntime = hashJson(runtime);
363
+ return publication;
364
+ }
365
+
264
366
  /**
265
367
  * Reconcile untouched shared-scope drift against the current proven live basis.
266
368
  *