@llblab/pi-kit 0.7.0 → 0.8.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 +21 -21
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +3 -122
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +11 -0
- package/node_modules/@llblab/pi-state-flow/README.md +41 -35
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +36 -16
- package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +4 -4
- package/node_modules/@llblab/pi-state-flow/index.ts +23 -1
- package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +3 -0
- package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +191 -29
- package/node_modules/@llblab/pi-state-flow/lib/config.ts +6 -1
- package/node_modules/@llblab/pi-state-flow/lib/context.ts +42 -7
- package/node_modules/@llblab/pi-state-flow/lib/durable.ts +38 -8
- package/node_modules/@llblab/pi-state-flow/lib/episode.ts +1 -13
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +290 -134
- package/node_modules/@llblab/pi-state-flow/lib/git.ts +32 -16
- package/node_modules/@llblab/pi-state-flow/lib/logging.ts +41 -0
- package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +12 -6
- package/node_modules/@llblab/pi-state-flow/lib/migration.ts +16 -0
- package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +3 -0
- package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +167 -31
- package/node_modules/@llblab/pi-state-flow/lib/skills.ts +15 -6
- package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +40 -34
- package/node_modules/@llblab/pi-state-flow/lib/state.ts +6 -0
- package/node_modules/@llblab/pi-state-flow/lib/status.ts +4 -9
- package/node_modules/@llblab/pi-state-flow/lib/storage.ts +25 -5
- package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +17 -147
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +49 -27
- package/node_modules/@llblab/pi-state-flow/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -2
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +3 -2
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +6 -1
- package/node_modules/@llblab/pi-telegram/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +8 -6
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +4 -4
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +18 -0
- package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +32 -19
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +5 -0
- package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +189 -15
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +3 -0
- package/node_modules/@llblab/pi-telegram/lib/config.ts +67 -4
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +65 -6
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +6 -1
- package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +49 -3
- package/node_modules/@llblab/pi-telegram/lib/preview.ts +17 -0
- package/node_modules/@llblab/pi-telegram/lib/prompts.ts +1 -0
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +66 -8
- package/node_modules/@llblab/pi-telegram/lib/rendering.ts +4 -1
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +19 -0
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +39 -0
- package/node_modules/@llblab/pi-telegram/lib/setup.ts +44 -4
- package/node_modules/@llblab/pi-telegram/lib/status.ts +41 -4
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +74 -18
- package/node_modules/@llblab/pi-telegram/lib/turns.ts +7 -0
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/package.json +3 -3
- package/node_modules/@llblab/pi-state-flow/lib/validation.ts +0 -27
- /package/node_modules/@llblab/pi-telegram/lib/{logs.ts → logging.ts} +0 -0
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
legacyTemporalScopePaths,
|
|
15
15
|
durablePaths,
|
|
16
16
|
isStateFlowOwnedPath,
|
|
17
|
+
parseScopeProvenance,
|
|
17
18
|
parseScopeStream,
|
|
18
19
|
parseStateSource,
|
|
19
20
|
serializeScopeStream,
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
type DurableFileBase,
|
|
26
27
|
type OwnedFileUpdate,
|
|
27
28
|
} from "./durable.ts";
|
|
29
|
+
import { parseArtifactProvenanceRegistry, type ArtifactProvenanceRegistry } from "./artifact.ts";
|
|
28
30
|
import { planLegacyStorageMigration } from "./migration.ts";
|
|
29
31
|
import { sameJson } from "./json.ts";
|
|
30
32
|
import { validateTemporalState, type ScopeStream, type TemporalState } from "./temporal.ts";
|
|
@@ -176,6 +178,8 @@ export interface TemporalRevisionLoad {
|
|
|
176
178
|
base: TemporalGitBase;
|
|
177
179
|
scopes: Record<StateScope, ScopeStream | undefined>;
|
|
178
180
|
runtime?: { document: SessionRuntime; revision: string };
|
|
181
|
+
/** Runtime-owned artifact provenance retained at this revision. */
|
|
182
|
+
provenance: Record<StateScope, ArtifactProvenanceRegistry>;
|
|
179
183
|
/** True only when this revision directly selected pre-0.4 hashed paths. */
|
|
180
184
|
legacyLayout?: true;
|
|
181
185
|
}
|
|
@@ -194,6 +198,7 @@ function revisionScopeFiles(root: string, revision: string, cwd: string, session
|
|
|
194
198
|
checkpoint: revisionFile(root, revision, paths.checkpoint),
|
|
195
199
|
patches: revisionFile(root, revision, paths.patches),
|
|
196
200
|
legacy: revisionFile(root, revision, resolve(paths.directory, "state.json")),
|
|
201
|
+
meta: revisionFile(root, revision, paths.meta),
|
|
197
202
|
});
|
|
198
203
|
const canonical = read(temporalScopePaths(cwd, sessionId, scope, root, sessionKey));
|
|
199
204
|
if (scope === "global" || [canonical.checkpoint, canonical.patches, canonical.legacy].some(({ identity }) => identity !== "missing")) return { ...canonical, legacyLayout: false };
|
|
@@ -206,12 +211,14 @@ export function loadTemporalRevision(cwd: string, sessionId: string, repositoryR
|
|
|
206
211
|
assertReadableRevision(root, revision);
|
|
207
212
|
const files: DurableFileBase[] = [];
|
|
208
213
|
const scopes = {} as Record<StateScope, ScopeStream | undefined>;
|
|
214
|
+
const provenance: Record<StateScope, ArtifactProvenanceRegistry> = { global: {}, cwd: {}, session: {} };
|
|
209
215
|
let legacyLayout = false;
|
|
210
216
|
for (const scope of ["global", "cwd", "session"] as const) {
|
|
211
217
|
const selected = revisionScopeFiles(root, revision, cwd, sessionId, sessionKey, scope);
|
|
212
218
|
if (selected.legacy.identity !== "missing") throw new Error("Historical legacy storage requires explicit migration interpretation");
|
|
213
219
|
legacyLayout ||= selected.legacyLayout;
|
|
214
|
-
files.push(selected.checkpoint, selected.patches, selected.legacy);
|
|
220
|
+
files.push(selected.checkpoint, selected.patches, selected.legacy, ...(scope === "session" ? [] : [selected.meta]));
|
|
221
|
+
if (scope !== "session") provenance[scope] = parseScopeProvenance(selected.meta.content, selected.meta.path);
|
|
215
222
|
scopes[scope] = parseScopeStream(selected.checkpoint.content, selected.patches.content, scope,
|
|
216
223
|
scope === "cwd" && !selected.legacyLayout ? cwd : undefined);
|
|
217
224
|
}
|
|
@@ -226,7 +233,8 @@ export function loadTemporalRevision(cwd: string, sessionId: string, repositoryR
|
|
|
226
233
|
const { paths: runtimePaths, config, meta } = selectedRuntime;
|
|
227
234
|
files.push(config, meta);
|
|
228
235
|
const document = parseSessionRuntime(config.content, meta.content, cwd, sessionId);
|
|
229
|
-
if (document === undefined) return { base: { head: revision, files }, scopes, ...(legacyLayout ? { legacyLayout: true as const } : {}) };
|
|
236
|
+
if (document === undefined) return { base: { head: revision, files }, scopes, provenance, ...(legacyLayout ? { legacyLayout: true as const } : {}) };
|
|
237
|
+
provenance.session = parseArtifactProvenanceRegistry(document.meta.artifacts, "State Flow session artifact provenance");
|
|
230
238
|
const owner = git(root, ["log", "-1", "--format=%H", revision, "--",
|
|
231
239
|
relativeOwnedPath(runtimePaths.config, root), relativeOwnedPath(runtimePaths.meta, root),
|
|
232
240
|
]).stdout.trim();
|
|
@@ -244,7 +252,7 @@ export function loadTemporalRevision(cwd: string, sessionId: string, repositoryR
|
|
|
244
252
|
throw new Error("Session runtime has incomplete temporal scope storage");
|
|
245
253
|
}
|
|
246
254
|
validateTemporalState({ lineage: document.meta.lineage, scopes: { global: scopes.global, cwd: scopes.cwd, session: scopes.session } });
|
|
247
|
-
return { base: { head: revision, files }, scopes, runtime: { document, revision: owner }, ...(legacyLayout ? { legacyLayout: true as const } : {}) };
|
|
255
|
+
return { base: { head: revision, files }, scopes, runtime: { document, revision: owner }, provenance, ...(legacyLayout ? { legacyLayout: true as const } : {}) };
|
|
248
256
|
}
|
|
249
257
|
|
|
250
258
|
/** Legacy state.json is already current; explanatory journals are irrelevant to semantic recovery. */
|
|
@@ -418,16 +426,17 @@ function commitOwnedFiles(
|
|
|
418
426
|
try {
|
|
419
427
|
if (expectedHead === undefined) git(repositoryRoot, ["read-tree", "--empty"], { env });
|
|
420
428
|
else git(repositoryRoot, ["read-tree", expectedHead], { env });
|
|
421
|
-
|
|
429
|
+
// A State Flow commit carries the complete non-ignored worktree delta, including user edits
|
|
430
|
+
// and manual deletions, while `.gitignore` stays authoritative for untracked files. The
|
|
431
|
+
// transient publication lock is ours, not repository content.
|
|
432
|
+
git(repositoryRoot, ["add", "-A", "--", ".", ":(exclude).state-flow-publication.lock"], { env });
|
|
422
433
|
for (const update of updates) {
|
|
423
434
|
const relativePath = relativeOwnedPath(update.path, repositoryRoot);
|
|
424
435
|
if (update.content === undefined) {
|
|
425
|
-
entries.push({ relativePath });
|
|
426
436
|
git(repositoryRoot, ["update-index", "--force-remove", "--", relativePath], { env });
|
|
427
437
|
continue;
|
|
428
438
|
}
|
|
429
439
|
const blob = git(repositoryRoot, ["hash-object", "-w", "--stdin"], { input: update.content }).stdout.trim();
|
|
430
|
-
entries.push({ relativePath, blob });
|
|
431
440
|
git(repositoryRoot, ["update-index", "--add", "--cacheinfo", `100644,${blob},${relativePath}`], { env });
|
|
432
441
|
}
|
|
433
442
|
const tree = git(repositoryRoot, ["write-tree"], { env }).stdout.trim();
|
|
@@ -443,10 +452,9 @@ function commitOwnedFiles(
|
|
|
443
452
|
assertOwnedFileUpdates(updates, repositoryRoot);
|
|
444
453
|
git(repositoryRoot, ["update-ref", branchRef, commit, expectedHead ?? zero]);
|
|
445
454
|
try {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
git(repositoryRoot, ["update-index", "--index-info"], { input: indexInfo });
|
|
455
|
+
// Align the caller-visible index with the committed tree so advancing HEAD through the
|
|
456
|
+
// isolated index leaves no artificial staged/unstaged status entries behind.
|
|
457
|
+
git(repositoryRoot, ["read-tree", commit]);
|
|
450
458
|
} catch (error) {
|
|
451
459
|
git(repositoryRoot, ["update-ref", branchRef, expectedHead ?? zero, commit]);
|
|
452
460
|
throw error;
|
|
@@ -512,10 +520,10 @@ function publishOwnedCohort(
|
|
|
512
520
|
}
|
|
513
521
|
|
|
514
522
|
/** Explicit current-file adoption, not reconstruction or invention of pre-Git history. */
|
|
515
|
-
export function adoptFileStateToGit(cwd: string, sessionId: string, repositoryRoot: string, revision: string, snapshot: Snapshot, sessionKey = sessionId) {
|
|
523
|
+
export function adoptFileStateToGit(cwd: string, sessionId: string, repositoryRoot: string, revision: string, snapshot: Snapshot, sessionKey = sessionId, push = true) {
|
|
516
524
|
const selected = loadTemporalFileRevision(cwd, sessionId, repositoryRoot, revision, sessionKey);
|
|
517
525
|
if (snapshot.meta.step !== selected.runtime.meta.step) throw new Error("Git adoption must preserve the semantic step");
|
|
518
|
-
const runtime = createSessionRuntime(snapshot, cwd, sessionId, selected.view.lineage);
|
|
526
|
+
const runtime = createSessionRuntime(snapshot, cwd, sessionId, selected.view.lineage, "unconfirmed", selected.provenance.session);
|
|
519
527
|
runtime.meta.temporalRevision = "self";
|
|
520
528
|
const sources = serializeSessionRuntime(runtime, cwd, sessionId);
|
|
521
529
|
initializeGitRepository(repositoryRoot);
|
|
@@ -523,8 +531,14 @@ export function adoptFileStateToGit(cwd: string, sessionId: string, repositoryRo
|
|
|
523
531
|
const current = captureTemporalBaseUnderLock(cwd, sessionId, root, sessionKey);
|
|
524
532
|
assertTemporalFileBase(selected.base, current);
|
|
525
533
|
const paths = sessionRuntimePaths(cwd, sessionId, root, sessionKey);
|
|
534
|
+
const provenancePaths = new Set([
|
|
535
|
+
temporalScopePaths(cwd, sessionId, "global", root, sessionKey).meta,
|
|
536
|
+
temporalScopePaths(cwd, sessionId, "cwd", root, sessionKey).meta,
|
|
537
|
+
]);
|
|
526
538
|
// Preserve exact valid scope bytes; only runtime provenance changes representation.
|
|
527
|
-
const updates = current.files.filter(({ path }) => path.endsWith("checkpoint.json")
|
|
539
|
+
const updates = current.files.filter(({ path, identity }) => path.endsWith("checkpoint.json")
|
|
540
|
+
|| path.endsWith("patches.jsonl")
|
|
541
|
+
|| (provenancePaths.has(path) && identity !== "missing"))
|
|
528
542
|
.map(({ path, content }) => ({ path, content: content! }));
|
|
529
543
|
updates.push({ path: paths.config, content: sources.config }, { path: paths.meta, content: sources.meta });
|
|
530
544
|
const existing = current.head && updates.every(({ path, content }) => revisionFile(root, current.head!, path).content === content)
|
|
@@ -532,9 +546,10 @@ export function adoptFileStateToGit(cwd: string, sessionId: string, repositoryRo
|
|
|
532
546
|
if (existing && (!existing.runtime || !sameJson({ lineage: existing.runtime.document.meta.lineage, scopes: existing.scopes }, selected.view))) {
|
|
533
547
|
throw new Error("Existing Git runtime does not anchor the selected file cohort");
|
|
534
548
|
}
|
|
535
|
-
const publication = publishOwnedCohort(root, updates, current.files, current.head, [], "persist");
|
|
549
|
+
const publication = publishOwnedCohort(root, updates, current.files, current.head, [], "persist", push);
|
|
536
550
|
const target = publication.commit ?? existing!.runtime!.revision;
|
|
537
|
-
|
|
551
|
+
const remote = push ? publication.push ?? pushGitCommit(root, target) : undefined;
|
|
552
|
+
return { ...publication, revision: target, ...(remote === undefined ? {} : { push: remote }), view: selected.view, provenance: selected.provenance,
|
|
538
553
|
base: { head: publication.commit ?? current.head, files: temporalFileReceipts(current, updates) } };
|
|
539
554
|
});
|
|
540
555
|
}
|
|
@@ -572,6 +587,7 @@ export function publishTemporalStateToGit(
|
|
|
572
587
|
runtime?: SessionRuntime,
|
|
573
588
|
sessionKey = sessionId,
|
|
574
589
|
push = true,
|
|
590
|
+
provenance?: Readonly<Record<StateScope, ArtifactProvenanceRegistry>>,
|
|
575
591
|
): { base: TemporalGitBase; commit?: string; push?: GitPushResult } {
|
|
576
592
|
return withPublicationLock(repositoryRoot, (root) => {
|
|
577
593
|
const current = captureTemporalBaseUnderLock(cwd, sessionId, root, sessionKey);
|
|
@@ -587,7 +603,7 @@ export function publishTemporalStateToGit(
|
|
|
587
603
|
const selected = loadTemporalRevision(cwd, sessionId, root, runtime!.meta.temporalRevision!, sessionKey);
|
|
588
604
|
if (!sameJson(selected.scopes, view.scopes)) throw new Error("Runtime temporal reference does not match selected streams");
|
|
589
605
|
}
|
|
590
|
-
const { updates, changedScopes } = planTemporalPublication(cwd, sessionId, view, scopes, current, root, runtime, runtimeOnly, sessionKey);
|
|
606
|
+
const { updates, changedScopes } = planTemporalPublication(cwd, sessionId, view, scopes, current, root, runtime, runtimeOnly, sessionKey, provenance);
|
|
591
607
|
if (!runtimeOnly) includeUncommittedCohort(cwd, sessionId, root, current, updates, changedScopes, scopes, runtime !== undefined, sessionKey);
|
|
592
608
|
if (updates.length === 0) return { base: current };
|
|
593
609
|
const publication = publishOwnedCohort(root, updates, current.files, current.head, changedScopes, "persist", push);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Domain: opt-in diagnostic capture for rejected State Flow resolutions.
|
|
2
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { isObject } from "./json.ts";
|
|
5
|
+
|
|
6
|
+
export type StateFlowDiagnosticCategory = "invalid-patch" | "publication-conflict" | "terminal-pending" | "finalization";
|
|
7
|
+
|
|
8
|
+
/** Minimal structural block; only ordinary text keeps its exact content. */
|
|
9
|
+
export interface StateFlowDiagnosticBlock {
|
|
10
|
+
type: string;
|
|
11
|
+
text?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StateFlowDiagnosticRecord {
|
|
15
|
+
at: string;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
cwd: string;
|
|
18
|
+
category: StateFlowDiagnosticCategory;
|
|
19
|
+
error: string;
|
|
20
|
+
content?: StateFlowDiagnosticBlock[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Preserve exact text blocks and block boundaries; reasoning bodies are never duplicated. */
|
|
24
|
+
export function projectDiagnosticContent(content: unknown): StateFlowDiagnosticBlock[] {
|
|
25
|
+
if (!Array.isArray(content)) return [];
|
|
26
|
+
return content.map((block) => {
|
|
27
|
+
if (!isObject(block) || typeof block.type !== "string") return { type: "unknown" };
|
|
28
|
+
if (block.type === "text" && typeof block.text === "string") return { type: "text", text: block.text };
|
|
29
|
+
return { type: block.type };
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Diagnostic JSONL lives beneath the active Pi agent directory, never inside the state repository. */
|
|
34
|
+
export function stateFlowLogPath(agentDir: string): string {
|
|
35
|
+
return join(agentDir, "tmp", "state-flow", "logs.jsonl");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function appendStateFlowDiagnostic(path: string, record: StateFlowDiagnosticRecord): void {
|
|
39
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
40
|
+
appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
41
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
classifyArtifactFreshness,
|
|
3
3
|
type ArtifactMetadata,
|
|
4
|
+
type ArtifactProvenance,
|
|
4
5
|
type ArtifactRegistry,
|
|
5
6
|
type ArtifactSourceIdentity,
|
|
6
7
|
} from "./artifact.ts";
|
|
@@ -55,10 +56,13 @@ function cycleTime(value: Date | number | string | undefined): number {
|
|
|
55
56
|
return timestamp;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
function compiledTime(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
function compiledTime(entry: ArtifactProvenance | undefined, legacy: unknown): number {
|
|
60
|
+
const timestamp = entry?.malformed === true ? undefined
|
|
61
|
+
: entry !== undefined && Object.hasOwn(entry, "compiledAt") ? entry.compiledAt
|
|
62
|
+
: legacy;
|
|
63
|
+
if (typeof timestamp !== "string") return Number.NEGATIVE_INFINITY;
|
|
64
|
+
const parsed = Date.parse(timestamp);
|
|
65
|
+
return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY;
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
function request(source: ArtifactSourceCandidate): ArtifactMaintenanceRequest {
|
|
@@ -81,6 +85,7 @@ export function planArtifactMaintenance(
|
|
|
81
85
|
registry: Readonly<Record<string, unknown>>,
|
|
82
86
|
compiler: string,
|
|
83
87
|
options: ArtifactMaintenanceOptions = {},
|
|
88
|
+
provenance: Readonly<Record<string, ArtifactProvenance>> = {},
|
|
84
89
|
): ArtifactMaintenancePlan {
|
|
85
90
|
if (!isObject(registry)) throw new Error("Artifacts must be a path-keyed JSON object");
|
|
86
91
|
const now = cycleTime(options.now);
|
|
@@ -104,9 +109,10 @@ export function planArtifactMaintenance(
|
|
|
104
109
|
seen.add(source.path);
|
|
105
110
|
nonNegativeSafeInteger(source.bytes, `Artifact source bytes at ${source.path}`);
|
|
106
111
|
const metadata = Object.hasOwn(registry, source.path) ? registry[source.path] : undefined;
|
|
107
|
-
const
|
|
112
|
+
const entry = Object.hasOwn(provenance, source.path) ? provenance[source.path] : undefined;
|
|
113
|
+
const freshness = classifyArtifactFreshness(source, metadata, compiler, false, entry);
|
|
108
114
|
if (freshness.kind !== "fresh") continue;
|
|
109
|
-
const compiledAt = compiledTime(metadata as ArtifactMetadata);
|
|
115
|
+
const compiledAt = compiledTime(entry, isObject(metadata) ? (metadata as ArtifactMetadata).compiled_at : undefined);
|
|
110
116
|
if (compiledAt !== Number.NEGATIVE_INFINITY && now - compiledAt < minimumAgeMs) continue;
|
|
111
117
|
eligible.push({ source, compiledAt });
|
|
112
118
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Domain: conservative current-snapshot migration planning; Git owns publication and rollback.
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { lstatSync } from "node:fs";
|
|
3
4
|
import { join, resolve } from "node:path";
|
|
4
5
|
import {
|
|
5
6
|
captureOwnedFileBases,
|
|
@@ -36,6 +37,21 @@ export function hasCwdMaterialization(cwd: string, repositoryRoot: string): bool
|
|
|
36
37
|
return false;
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/** Cheap canonical-layout fast path: inspect only the three predecessor snapshot names. */
|
|
41
|
+
export function hasLegacyStateSources(
|
|
42
|
+
cwd: string,
|
|
43
|
+
sessionId: string,
|
|
44
|
+
repositoryRoot: string,
|
|
45
|
+
sessionKey = sessionId,
|
|
46
|
+
): boolean {
|
|
47
|
+
const root = resolve(repositoryRoot);
|
|
48
|
+
return [
|
|
49
|
+
join(root, "state.json"),
|
|
50
|
+
join(cwdScopePaths(cwd, root).directory, "state.json"),
|
|
51
|
+
join(sessionScopePaths(cwd, sessionId, root, sessionKey).directory, "state.json"),
|
|
52
|
+
].some((path) => lstatSync(path, { throwIfNoEntry: false }) !== undefined);
|
|
53
|
+
}
|
|
54
|
+
|
|
39
55
|
/** Plan from current scope snapshots only; old explanatory journals are never replay input. */
|
|
40
56
|
export function planLegacyStorageMigration(
|
|
41
57
|
cwd: string,
|
|
@@ -12,6 +12,8 @@ export interface RehydrationRoute {
|
|
|
12
12
|
scope: StateScope;
|
|
13
13
|
source: ArtifactSourceIdentity;
|
|
14
14
|
metadata: unknown;
|
|
15
|
+
/** Runtime-owned freshness evidence retained beside the semantic artifact. */
|
|
16
|
+
provenance?: unknown;
|
|
15
17
|
compiler: string;
|
|
16
18
|
intent: ArtifactAcquisitionIntent;
|
|
17
19
|
materializedSufficient?: boolean;
|
|
@@ -58,6 +60,7 @@ export function planKnowledgeRehydration(
|
|
|
58
60
|
intent: route.intent,
|
|
59
61
|
...(route.materializedSufficient === undefined ? {} : { materializedSufficient: route.materializedSufficient }),
|
|
60
62
|
...(route.explicitRefresh === undefined ? {} : { explicitRefresh: route.explicitRefresh }),
|
|
63
|
+
...(route.provenance === undefined ? {} : { provenance: route.provenance }),
|
|
61
64
|
});
|
|
62
65
|
if (decision.kind === "use-materialized") {
|
|
63
66
|
materialized.push(route.source.path);
|
|
@@ -1,19 +1,38 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { lstatSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { parseScopeStream, sessionRuntimePaths, temporalScopePaths, type SessionAddress } from "./durable.ts";
|
|
4
|
+
import { parseScopeProvenance, parseScopeStream, sessionRuntimePaths, temporalScopePaths, type SessionAddress } from "./durable.ts";
|
|
5
|
+
import { parseArtifactProvenanceRegistry, pruneArtifactProvenance, type ArtifactProvenance, type ArtifactProvenanceRegistry } from "./artifact.ts";
|
|
5
6
|
import { adoptFileStateToGit, initializeGitRepository, isLocalGitRepository, captureTemporalGitBase, loadLegacyStatesAtRevision, loadTemporalRevision, migrateHashedCwdAtHead, migrateHashedLayoutAtHead, migrateLegacyStorageToGit, publishTemporalStateToGit, type TemporalGitBase } from "./git.ts";
|
|
6
|
-
import { captureTemporalFileBase, detectGitCapability, initializeFileStore, loadTemporalFileRevision, migrateLegacyStorageToFiles, publishTemporalStateToFiles } from "./storage.ts";
|
|
7
|
+
import { captureTemporalFileBase, detectGitCapability, initializeFileStore, loadTemporalFileRevision, migrateLegacyStorageToFiles, publishTemporalStateToFiles, type TemporalFileBase } from "./storage.ts";
|
|
7
8
|
import { type AcceptedTransition, type RecentTransitionWindow } from "./history.ts";
|
|
8
9
|
import { hashJson, sameJson } from "./json.ts";
|
|
9
|
-
import { hasCwdMaterialization } from "./migration.ts";
|
|
10
|
+
import { hasCwdMaterialization, hasLegacyStateSources } from "./migration.ts";
|
|
10
11
|
import { RevisionUnavailableError, createSessionRuntime, isFileRevision, parseSessionRuntime, resolveFileSessionRuntime, resolveSessionRuntime, type Snapshot } from "./snapshot.ts";
|
|
11
12
|
import { emptyState, type MaterializedState, type ScopedStates, type StateScope } from "./state.ts";
|
|
12
|
-
import { adoptTemporalStreams, advanceTemporalState, createTemporalState, readTemporalState, validateTemporalState, type TemporalState } from "./temporal.ts";
|
|
13
|
+
import { adoptTemporalStreams, advanceTemporalState, createTemporalState, readTemporalState, validateTemporalState, type ScopeStream, type TemporalState } from "./temporal.ts";
|
|
13
14
|
|
|
14
15
|
const SCOPES = ["global", "cwd", "session"] as const;
|
|
16
|
+
const SHARED_SCOPES = ["global", "cwd"] as const;
|
|
15
17
|
export type RuntimePublication = ReturnType<typeof publishTemporalStateToGit> & { revision?: string };
|
|
16
18
|
|
|
19
|
+
function emptyProvenance(): Record<StateScope, ArtifactProvenanceRegistry> {
|
|
20
|
+
return { global: {}, cwd: {}, session: {} };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function scopeLabel(scope: StateScope): string {
|
|
24
|
+
return scope === "cwd" ? "CWD" : scope;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Precise fail-closed conflict for a shared scope this transition actually overwrites. */
|
|
28
|
+
function targetScopeConflict(scopes: readonly StateScope[]): Error {
|
|
29
|
+
const labels = scopes.map(scopeLabel);
|
|
30
|
+
if (labels.length === 1) {
|
|
31
|
+
return new Error(`State Flow cannot publish the ${labels[0]} patch because the live ${labels[0]} state advanced after this transition's selected basis. Refresh or reconcile the target scope before retrying.`);
|
|
32
|
+
}
|
|
33
|
+
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
|
+
}
|
|
35
|
+
|
|
17
36
|
/** Immutable target validation is independent of acquiring the live publication basis. */
|
|
18
37
|
export function inspectRuntimeRevision(cwd: string, sessionId: string, root: string, revision: string, sessionKey = sessionId) {
|
|
19
38
|
const loaded = loadTemporalRevision(cwd, sessionId, root, revision, sessionKey);
|
|
@@ -22,7 +41,7 @@ export function inspectRuntimeRevision(cwd: string, sessionId: string, root: str
|
|
|
22
41
|
if (!loaded.scopes.global || !loaded.scopes.cwd || !loaded.scopes.session) throw new Error("Incomplete temporal scope cohort");
|
|
23
42
|
const view = { lineage: resolved.lineage, scopes: { global: loaded.scopes.global, cwd: loaded.scopes.cwd, session: loaded.scopes.session } };
|
|
24
43
|
validateTemporalState(view);
|
|
25
|
-
return { runtime: loaded.runtime, resolved, view, ...(loaded.legacyLayout ? { legacyLayout: true as const } : {}) };
|
|
44
|
+
return { runtime: loaded.runtime, resolved, view, provenance: loaded.provenance, ...(loaded.legacyLayout ? { legacyLayout: true as const } : {}) };
|
|
26
45
|
}
|
|
27
46
|
|
|
28
47
|
/** Both checkpoint generations validate their immutable target before any live migration/publication. */
|
|
@@ -51,6 +70,7 @@ export class TemporalRuntime {
|
|
|
51
70
|
private semanticRevision: string | undefined;
|
|
52
71
|
private savedRuntime: string | undefined;
|
|
53
72
|
private backend: "git" | "files" | undefined;
|
|
73
|
+
private provenanceByScope: Record<StateScope, ArtifactProvenanceRegistry> = emptyProvenance();
|
|
54
74
|
readonly cwd: string;
|
|
55
75
|
private readonly session: SessionAddress;
|
|
56
76
|
readonly root: string;
|
|
@@ -63,6 +83,11 @@ export class TemporalRuntime {
|
|
|
63
83
|
get sessionId(): string { return this.session.id; }
|
|
64
84
|
get sessionKey(): string { return this.session.key; }
|
|
65
85
|
|
|
86
|
+
/** Runtime-owned artifact freshness evidence for one scope; never model-visible state. */
|
|
87
|
+
artifactProvenance(scope: StateScope): ArtifactProvenanceRegistry {
|
|
88
|
+
return structuredClone(this.provenanceByScope[scope]);
|
|
89
|
+
}
|
|
90
|
+
|
|
66
91
|
/** Explicit start owns directory/repository creation; reads never call this. */
|
|
67
92
|
prepare(): void {
|
|
68
93
|
const backend = detectGitCapability();
|
|
@@ -75,8 +100,10 @@ export class TemporalRuntime {
|
|
|
75
100
|
promote(snapshot: Snapshot): RuntimePublication | undefined {
|
|
76
101
|
if (this.backend !== "files" || !this.view || detectGitCapability() === "files") return undefined;
|
|
77
102
|
if (!snapshot.meta.durableBase || snapshot.meta.durableBase !== this.semanticRevision) throw new Error("Git adoption requires the selected file revision");
|
|
78
|
-
const
|
|
79
|
-
const
|
|
103
|
+
const pushRemote = (snapshot.meta.remotePublication?.mode ?? "transition") === "transition";
|
|
104
|
+
const result = adoptFileStateToGit(this.cwd, this.sessionId, this.root, snapshot.meta.durableBase, snapshot, this.sessionKey, pushRemote);
|
|
105
|
+
this.provenanceByScope = structuredClone(result.provenance);
|
|
106
|
+
const savedRuntime = hashJson(createSessionRuntime(snapshot, this.cwd, this.sessionId, result.view.lineage, "unconfirmed", this.provenanceByScope.session));
|
|
80
107
|
this.view = result.view;
|
|
81
108
|
this.base = result.base;
|
|
82
109
|
this.backend = "git";
|
|
@@ -114,10 +141,11 @@ export class TemporalRuntime {
|
|
|
114
141
|
restore(revision: string, legacySnapshot?: Snapshot): Snapshot {
|
|
115
142
|
const inspected = inspectSnapshotRevision(this.cwd, this.sessionId, this.root, revision, legacySnapshot, this.sessionKey);
|
|
116
143
|
if (inspected.file) {
|
|
117
|
-
const savedRuntime = hashJson(createSessionRuntime(inspected.snapshot, this.cwd, this.sessionId, inspected.file.view.lineage, "files"));
|
|
144
|
+
const savedRuntime = hashJson(createSessionRuntime(inspected.snapshot, this.cwd, this.sessionId, inspected.file.view.lineage, "files", inspected.file.provenance.session));
|
|
118
145
|
this.view = inspected.file.view;
|
|
119
146
|
this.base = inspected.file.base;
|
|
120
147
|
this.backend = "files";
|
|
148
|
+
this.provenanceByScope = structuredClone(inspected.file.provenance);
|
|
121
149
|
this.semanticRevision = revision;
|
|
122
150
|
this.savedRuntime = savedRuntime;
|
|
123
151
|
return inspected.snapshot;
|
|
@@ -170,6 +198,7 @@ export class TemporalRuntime {
|
|
|
170
198
|
this.view = view;
|
|
171
199
|
this.base = base;
|
|
172
200
|
this.backend = "git";
|
|
201
|
+
this.provenanceByScope = structuredClone(loaded.provenance);
|
|
173
202
|
this.semanticRevision = semanticRevision;
|
|
174
203
|
this.savedRuntime = savedRuntime;
|
|
175
204
|
return resolved.snapshot;
|
|
@@ -181,14 +210,22 @@ export class TemporalRuntime {
|
|
|
181
210
|
const backend = this.backend ?? (detectGitCapability() === "git" && lstatSync(join(this.root, ".git"), { throwIfNoEntry: false }) ? "git" : "files");
|
|
182
211
|
if (backend === "git") {
|
|
183
212
|
if (!hasCwd) migrateHashedCwdAtHead(this.cwd, this.root);
|
|
184
|
-
|
|
213
|
+
if (hasLegacyStateSources(this.cwd, this.sessionId, this.root, this.sessionKey)) {
|
|
214
|
+
migrateLegacyStorageToGit(this.cwd, this.sessionId, this.root, this.sessionKey);
|
|
215
|
+
}
|
|
185
216
|
}
|
|
186
217
|
else {
|
|
187
218
|
if (allowCreateCwd) initializeFileStore(this.root);
|
|
188
|
-
|
|
219
|
+
if (hasLegacyStateSources(this.cwd, this.sessionId, this.root, this.sessionKey)) {
|
|
220
|
+
migrateLegacyStorageToFiles(this.cwd, this.sessionId, this.root, this.sessionKey);
|
|
221
|
+
}
|
|
189
222
|
}
|
|
190
223
|
const base: TemporalGitBase = backend === "git" ? captureTemporalGitBase(this.cwd, this.sessionId, this.root, this.sessionKey) : captureTemporalFileBase(this.cwd, this.sessionId, this.root, this.sessionKey);
|
|
191
224
|
const files = new Map(base.files.map((file) => [file.path, file.content]));
|
|
225
|
+
if (SCOPES.some((scope) => {
|
|
226
|
+
const paths = temporalScopePaths(this.cwd, this.sessionId, scope, this.root, this.sessionKey);
|
|
227
|
+
return files.get(join(paths.directory, "state.json")) !== undefined;
|
|
228
|
+
})) throw new Error("Legacy State Flow storage changed during initialization; retry migration from a fresh basis");
|
|
192
229
|
const streams = Object.fromEntries(SCOPES.map((scope) => {
|
|
193
230
|
const paths = temporalScopePaths(this.cwd, this.sessionId, scope, this.root, this.sessionKey);
|
|
194
231
|
return [scope, parseScopeStream(files.get(paths.checkpoint), files.get(paths.patches), scope, scope === "cwd" ? this.cwd : undefined)];
|
|
@@ -204,6 +241,13 @@ export class TemporalRuntime {
|
|
|
204
241
|
candidate.backend = backend;
|
|
205
242
|
candidate.base = base;
|
|
206
243
|
candidate.view = adoptTemporalStreams({ global: streams.global ?? fresh.scopes.global, cwd: streams.cwd ?? fresh.scopes.cwd, session: streams.session ?? fresh.scopes.session }, `${base.head ?? "unborn"}:${randomUUID()}`);
|
|
244
|
+
const globalMeta = temporalScopePaths(this.cwd, this.sessionId, "global", this.root, this.sessionKey).meta;
|
|
245
|
+
const cwdMeta = temporalScopePaths(this.cwd, this.sessionId, "cwd", this.root, this.sessionKey).meta;
|
|
246
|
+
candidate.provenanceByScope = {
|
|
247
|
+
global: parseScopeProvenance(files.get(globalMeta), globalMeta),
|
|
248
|
+
cwd: parseScopeProvenance(files.get(cwdMeta), cwdMeta),
|
|
249
|
+
session: streams.session === undefined ? {} : parseArtifactProvenanceRegistry(existingRuntime?.meta.artifacts, "State Flow session artifact provenance"),
|
|
250
|
+
};
|
|
207
251
|
if (expectedShared && (["global", "cwd"] as const).some((scope) => !sameJson(candidate.read(0, scope), expectedShared[scope]))) {
|
|
208
252
|
throw new Error("Legacy branch shared scopes diverged from the selected revision; migration cannot overwrite them");
|
|
209
253
|
}
|
|
@@ -211,54 +255,146 @@ export class TemporalRuntime {
|
|
|
211
255
|
this.view = candidate.view;
|
|
212
256
|
this.base = candidate.base;
|
|
213
257
|
this.backend = backend;
|
|
258
|
+
this.provenanceByScope = structuredClone(candidate.provenanceByScope);
|
|
214
259
|
this.semanticRevision = candidate.semanticRevision;
|
|
215
260
|
this.savedRuntime = candidate.savedRuntime;
|
|
216
261
|
return publication;
|
|
217
262
|
}
|
|
218
263
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
264
|
+
/**
|
|
265
|
+
* Reconcile untouched shared-scope drift against the current proven live basis.
|
|
266
|
+
*
|
|
267
|
+
* A restored branch can lag behind live global/CWD state. Untouched shared scopes adopt
|
|
268
|
+
* the current live streams at a fresh origin; a shared scope the accepted transition
|
|
269
|
+
* actually changes remains a fail-closed write conflict. Divergence in non-adoptable
|
|
270
|
+
* session or runtime files also fails closed under the existing race rule.
|
|
271
|
+
*/
|
|
272
|
+
private reconcileSharedDrift(changedScopes: ReadonlySet<StateScope>): {
|
|
273
|
+
view: TemporalState;
|
|
274
|
+
base: TemporalGitBase | TemporalFileBase;
|
|
275
|
+
provenance: Record<StateScope, ArtifactProvenanceRegistry>;
|
|
276
|
+
} {
|
|
277
|
+
const captured = this.backend === "files"
|
|
278
|
+
? captureTemporalFileBase(this.cwd, this.sessionId, this.root, this.sessionKey)
|
|
279
|
+
: captureTemporalGitBase(this.cwd, this.sessionId, this.root, this.sessionKey);
|
|
280
|
+
const liveFiles = new Map(captured.files.map((file) => [file.path, file]));
|
|
281
|
+
const adoptable = new Set<string>();
|
|
282
|
+
for (const scope of SHARED_SCOPES) {
|
|
283
|
+
const paths = temporalScopePaths(this.cwd, this.sessionId, scope, this.root, this.sessionKey);
|
|
284
|
+
adoptable.add(paths.checkpoint);
|
|
285
|
+
adoptable.add(paths.patches);
|
|
286
|
+
adoptable.add(paths.meta);
|
|
287
|
+
}
|
|
288
|
+
for (const file of this.base!.files) {
|
|
289
|
+
if (adoptable.has(file.path)) continue;
|
|
290
|
+
const current = liveFiles.get(file.path);
|
|
291
|
+
if (current === undefined || current.identity !== file.identity) {
|
|
292
|
+
throw new Error("Temporal State Flow base or scope identity changed concurrently");
|
|
232
293
|
}
|
|
233
294
|
}
|
|
295
|
+
const provenance = structuredClone(this.provenanceByScope);
|
|
296
|
+
const adopted = new Map<StateScope, ScopeStream>();
|
|
297
|
+
const targets: StateScope[] = [];
|
|
298
|
+
for (const scope of SHARED_SCOPES) {
|
|
299
|
+
const paths = temporalScopePaths(this.cwd, this.sessionId, scope, this.root, this.sessionKey);
|
|
300
|
+
const stream = parseScopeStream(liveFiles.get(paths.checkpoint)?.content, liveFiles.get(paths.patches)?.content, scope, scope === "cwd" ? this.cwd : undefined);
|
|
301
|
+
if (stream === undefined) throw new Error(`Live State Flow ${scope} scope storage is incomplete`);
|
|
302
|
+
const liveProvenance = parseScopeProvenance(liveFiles.get(paths.meta)?.content, paths.meta);
|
|
303
|
+
const streamDrifted = !sameJson(stream, this.view!.scopes[scope]);
|
|
304
|
+
const provenanceDrifted = !sameJson(liveProvenance, this.provenanceByScope[scope]);
|
|
305
|
+
if (!streamDrifted && !provenanceDrifted) continue;
|
|
306
|
+
if (changedScopes.has(scope)) {
|
|
307
|
+
targets.push(scope);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
provenance[scope] = liveProvenance;
|
|
311
|
+
if (streamDrifted) adopted.set(scope, stream);
|
|
312
|
+
}
|
|
313
|
+
if (targets.length > 0) throw targetScopeConflict(targets);
|
|
314
|
+
if (adopted.size === 0) return { view: this.view!, base: captured, provenance };
|
|
315
|
+
const streams = {
|
|
316
|
+
global: adopted.get("global") ?? structuredClone(this.view!.scopes.global),
|
|
317
|
+
cwd: adopted.get("cwd") ?? structuredClone(this.view!.scopes.cwd),
|
|
318
|
+
session: structuredClone(this.view!.scopes.session),
|
|
319
|
+
};
|
|
320
|
+
const head = "head" in captured ? captured.head : undefined;
|
|
321
|
+
return {
|
|
322
|
+
view: adoptTemporalStreams(streams, `${head ?? "unborn"}:reconcile:${randomUUID()}`),
|
|
323
|
+
base: captured,
|
|
324
|
+
provenance,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
publish(
|
|
329
|
+
snapshot: Snapshot,
|
|
330
|
+
semantic = false,
|
|
331
|
+
accepted?: AcceptedTransition,
|
|
332
|
+
options: { pushRemote?: boolean; provenance?: Partial<Record<StateScope, Record<string, ArtifactProvenance>>> } = {},
|
|
333
|
+
): RuntimePublication | undefined {
|
|
334
|
+
if (!this.view || !this.base) throw new Error("State Flow temporal publication is unavailable; restore or initialize before accepting a transition");
|
|
335
|
+
// Activation and terminal policy: only the legacy transition mode publishes synchronously.
|
|
336
|
+
const pushRemote = options.pushRemote ?? (snapshot.meta.remotePublication?.mode ?? "transition") === "transition";
|
|
337
|
+
const provenanceScopes = SCOPES.filter((scope) => Object.entries(options.provenance?.[scope] ?? {})
|
|
338
|
+
.some(([path, entry]) => this.provenanceByScope[scope][path] === undefined
|
|
339
|
+
|| !sameJson(this.provenanceByScope[scope][path], entry)));
|
|
340
|
+
let basis = this.view;
|
|
341
|
+
let base: TemporalGitBase | TemporalFileBase = this.base;
|
|
342
|
+
let basisProvenance = this.provenanceByScope;
|
|
343
|
+
if ((semantic || provenanceScopes.length > 0) && this.semanticRevision) {
|
|
344
|
+
const changedScopes = new Set<StateScope>([
|
|
345
|
+
...(accepted?.transitions ?? []).map(({ scope }) => scope),
|
|
346
|
+
...provenanceScopes,
|
|
347
|
+
]);
|
|
348
|
+
const reconciled = this.reconcileSharedDrift(changedScopes);
|
|
349
|
+
basis = reconciled.view;
|
|
350
|
+
base = reconciled.base;
|
|
351
|
+
basisProvenance = reconciled.provenance;
|
|
352
|
+
}
|
|
353
|
+
const next = accepted ? advanceTemporalState(basis, accepted.transitions, accepted.id) : basis;
|
|
354
|
+
const nextProvenance = structuredClone(basisProvenance);
|
|
355
|
+
for (const scope of SCOPES) {
|
|
356
|
+
const updates = options.provenance?.[scope];
|
|
357
|
+
if (updates) for (const [path, entry] of Object.entries(updates)) nextProvenance[scope][path] = structuredClone(entry);
|
|
358
|
+
}
|
|
359
|
+
for (const scope of SCOPES) {
|
|
360
|
+
nextProvenance[scope] = pruneArtifactProvenance(nextProvenance[scope], readTemporalState(next, 0, scope).artifacts);
|
|
361
|
+
}
|
|
362
|
+
const provenanceChanged = !sameJson(nextProvenance, this.provenanceByScope);
|
|
363
|
+
const scopedWrite = semantic || provenanceChanged;
|
|
364
|
+
const runtime = createSessionRuntime(snapshot, this.cwd, this.sessionId, next.lineage, this.backend === "files" ? "files" : "unconfirmed", nextProvenance.session);
|
|
365
|
+
const fingerprint = hashJson(runtime);
|
|
366
|
+
if (!semantic && fingerprint === this.savedRuntime && !provenanceChanged) return undefined;
|
|
234
367
|
if (this.backend === "files") {
|
|
235
368
|
runtime.meta.temporalRevision = "self";
|
|
236
|
-
const result = publishTemporalStateToFiles(this.cwd, this.sessionId, next, semantic ? SCOPES : [],
|
|
369
|
+
const result = publishTemporalStateToFiles(this.cwd, this.sessionId, next, semantic ? SCOPES : [], base, this.root, runtime, this.sessionKey, nextProvenance);
|
|
237
370
|
this.base = result.base;
|
|
238
371
|
this.view = next;
|
|
372
|
+
this.provenanceByScope = nextProvenance;
|
|
239
373
|
this.savedRuntime = fingerprint;
|
|
240
374
|
this.semanticRevision = result.revision;
|
|
241
375
|
return { base: result.base, revision: result.revision };
|
|
242
376
|
}
|
|
243
|
-
if (!
|
|
377
|
+
if (!scopedWrite) {
|
|
244
378
|
const current = captureTemporalGitBase(this.cwd, this.sessionId, this.root, this.sessionKey);
|
|
245
379
|
const paths = sessionRuntimePaths(this.cwd, this.sessionId, this.root, this.sessionKey);
|
|
246
380
|
for (const path of [paths.config, paths.meta]) {
|
|
247
|
-
if (current.files.find((file) => file.path === path)?.identity !==
|
|
381
|
+
if (current.files.find((file) => file.path === path)?.identity !== base.files.find((file) => file.path === path)?.identity) {
|
|
248
382
|
throw new Error("Temporal State Flow runtime changed concurrently");
|
|
249
383
|
}
|
|
250
384
|
}
|
|
251
|
-
|
|
385
|
+
base = current;
|
|
252
386
|
}
|
|
253
|
-
runtime.meta.temporalRevision =
|
|
387
|
+
runtime.meta.temporalRevision = scopedWrite ? "self" : this.semanticRevision!;
|
|
254
388
|
const result = publishTemporalStateToGit(
|
|
255
|
-
this.cwd, this.sessionId, next, semantic ? SCOPES : [],
|
|
256
|
-
|
|
389
|
+
this.cwd, this.sessionId, next, semantic ? SCOPES : provenanceChanged ? provenanceScopes : [], base, this.root, runtime, this.sessionKey,
|
|
390
|
+
pushRemote,
|
|
391
|
+
nextProvenance,
|
|
257
392
|
);
|
|
258
393
|
this.base = result.base;
|
|
259
394
|
this.view = next;
|
|
395
|
+
this.provenanceByScope = nextProvenance;
|
|
260
396
|
this.savedRuntime = fingerprint;
|
|
261
|
-
if (
|
|
397
|
+
if (scopedWrite && result.commit) this.semanticRevision = result.commit;
|
|
262
398
|
return result;
|
|
263
399
|
}
|
|
264
400
|
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
hashArtifactSource,
|
|
4
4
|
isArtifactHash,
|
|
5
5
|
type ArtifactMetadata,
|
|
6
|
+
type ArtifactProvenance,
|
|
6
7
|
type ArtifactRegistry,
|
|
7
8
|
} from "./artifact.ts";
|
|
8
9
|
import { canonicalJson, isObject, type JsonObject, type JsonValue } from "./json.ts";
|
|
@@ -19,16 +20,24 @@ function hasContent(value: unknown): boolean {
|
|
|
19
20
|
|
|
20
21
|
export function hasCompiledSkillArtifact(
|
|
21
22
|
artifacts: ArtifactRegistry,
|
|
23
|
+
provenance: ArtifactProvenance | undefined,
|
|
22
24
|
source: string,
|
|
23
25
|
expectedHash?: string,
|
|
24
26
|
): boolean {
|
|
25
27
|
const metadata = artifacts[source];
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
if (!isObject(metadata)
|
|
29
|
+
|| metadata.kind !== "skill"
|
|
30
|
+
|| !isObject(metadata.compilation)
|
|
31
|
+
|| !hasContent(metadata.compilation)
|
|
32
|
+
|| provenance?.malformed === true) return false;
|
|
33
|
+
const compilerRevision = provenance !== undefined && Object.hasOwn(provenance, "compilerRevision")
|
|
34
|
+
? provenance.compilerRevision
|
|
35
|
+
: metadata.compiler;
|
|
36
|
+
const sourceHash = provenance !== undefined && Object.hasOwn(provenance, "sourceHash")
|
|
37
|
+
? provenance.sourceHash
|
|
38
|
+
: metadata.hash;
|
|
39
|
+
return compilerRevision === SKILL_ARTIFACT_COMPILER
|
|
40
|
+
&& (expectedHash === undefined || sourceHash === expectedHash);
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
export type SkillSourceHasher = (source: string) => string;
|