@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.
- package/CHANGELOG.md +8 -0
- package/README.md +2 -2
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +13 -10
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +15 -1
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +13 -0
- package/node_modules/@llblab/pi-state-flow/README.md +52 -261
- package/node_modules/@llblab/pi-state-flow/docs/README.md +5 -1
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +47 -26
- package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +97 -0
- package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +47 -0
- package/node_modules/@llblab/pi-state-flow/docs/performance.md +459 -0
- package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +35 -3
- package/node_modules/@llblab/pi-state-flow/docs/usage.md +134 -0
- package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +19 -3
- package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +74 -0
- package/node_modules/@llblab/pi-state-flow/lib/context.ts +12 -12
- package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +4 -2
- package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +21 -5
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +146 -37
- package/node_modules/@llblab/pi-state-flow/lib/git.ts +142 -42
- package/node_modules/@llblab/pi-state-flow/lib/publication.ts +80 -27
- package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +109 -7
- package/node_modules/@llblab/pi-state-flow/lib/status.ts +7 -12
- package/node_modules/@llblab/pi-state-flow/lib/storage.ts +2 -1
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +2 -3
- package/node_modules/@llblab/pi-state-flow/package.json +5 -4
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +4 -0
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +1 -1
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/ownership.ts +28 -7
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +3 -10
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- 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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
158
|
-
return {
|
|
159
|
-
path
|
|
160
|
-
|
|
161
|
-
|
|
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(
|
|
196
|
-
|
|
221
|
+
function revisionScopeFiles(read: (path: string) => DurableFileBase, paths: ReturnType<typeof temporalScopePaths>) {
|
|
222
|
+
return {
|
|
197
223
|
paths,
|
|
198
|
-
checkpoint:
|
|
199
|
-
patches:
|
|
200
|
-
legacy:
|
|
201
|
-
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
|
|
217
|
-
|
|
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:
|
|
264
|
+
paths, config: readFile(paths.config), meta: readFile(paths.meta),
|
|
227
265
|
});
|
|
228
|
-
let selectedRuntime = { ...readRuntime(
|
|
266
|
+
let selectedRuntime = { ...readRuntime(canonicalRuntime), legacyLayout: false };
|
|
229
267
|
if (selectedRuntime.config.identity === "missing" && selectedRuntime.meta.identity === "missing") {
|
|
230
|
-
selectedRuntime = { ...readRuntime(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 || !
|
|
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 && !
|
|
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
|
-
|
|
195
|
-
try {
|
|
196
|
-
|
|
197
|
-
return
|
|
198
|
-
|
|
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
|
|
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
|
-
|
|
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
|
*
|