@smartmemory/compose 0.5.0 → 0.5.1
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/README.md +14 -0
- package/bin/compose.js +24 -3
- package/lib/agent-string.js +9 -4
- package/lib/build-stream-writer.js +6 -0
- package/lib/build.js +643 -84
- package/lib/consumer-fanout.js +403 -16
- package/lib/experiment-pricing.js +5 -1
- package/lib/flow-state.js +38 -0
- package/lib/gsd.js +95 -48
- package/lib/model-pricing.js +4 -1
- package/lib/output-gate.js +81 -0
- package/lib/pipeline-profiles.js +200 -0
- package/lib/result-normalizer.js +13 -0
- package/lib/stratum-mcp-client.js +4 -4
- package/lib/team-flag.js +1 -1
- package/lib/wave-checkpoint.js +100 -0
- package/package.json +2 -2
- package/presets/team-fable-astra.profiles.json +18 -0
- package/presets/team-fable-astra.stratum.yaml +236 -0
- package/server/model-tiers.js +14 -6
package/lib/consumer-fanout.js
CHANGED
|
@@ -21,6 +21,8 @@ import { execFileSync } from 'node:child_process';
|
|
|
21
21
|
import { createHash, randomUUID } from 'node:crypto';
|
|
22
22
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
23
23
|
import { tmpdir } from 'node:os';
|
|
24
|
+
import { normalizeOwnedPath, profilesDigest as digestJson } from './pipeline-profiles.js';
|
|
25
|
+
import { worktreeBaseFor, readCheckpointRef, prepareCheckpoint, publishCheckpoint, reconcileCheckpoint, WaveCheckpointError } from './wave-checkpoint.js';
|
|
24
26
|
|
|
25
27
|
const JOURNAL_VERSION = 1;
|
|
26
28
|
let tempIndexSequence = 0;
|
|
@@ -96,7 +98,7 @@ function canonicalPath(path) {
|
|
|
96
98
|
return resolve(existing, ...missing);
|
|
97
99
|
}
|
|
98
100
|
|
|
99
|
-
function withTemporaryIndex(cwd, fn) {
|
|
101
|
+
export function withTemporaryIndex(cwd, fn) {
|
|
100
102
|
const indexPath = join(
|
|
101
103
|
tmpdir(),
|
|
102
104
|
`compose-consumer-index-${process.pid}-${tempIndexSequence++}-${randomUUID()}`,
|
|
@@ -143,6 +145,56 @@ function cumulativeDiff(cwd, baseCommit) {
|
|
|
143
145
|
});
|
|
144
146
|
}
|
|
145
147
|
|
|
148
|
+
/** Enumerate the exact retained patch, never the worker's reported files_changed. */
|
|
149
|
+
function retainedPatchEvidence(cwd, baseCommit, diff) {
|
|
150
|
+
return withTemporaryIndex(cwd, env => {
|
|
151
|
+
git(cwd, ['read-tree', baseCommit], { env });
|
|
152
|
+
if (diff) git(cwd, ['apply', '--cached', '--binary', '-'], { env, input: diff });
|
|
153
|
+
const capturedTree = git(cwd, ['write-tree'], { env });
|
|
154
|
+
// --no-renames gives delete/add endpoints; -z preserves whitespace and newlines.
|
|
155
|
+
const changedPaths = git(cwd, ['diff', '--cached', '--name-only', '-z', '--no-renames', baseCommit, '--'],
|
|
156
|
+
{ env, trim: false }).split('\0').filter(Boolean);
|
|
157
|
+
return { baseCommit, capturedTree, changedPaths };
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
function checkOwnership(cwd, entry, { capture = false } = {}) {
|
|
161
|
+
if (!entry.ownership && !Object.hasOwn(entry.itemBinding?.item ?? {}, 'files_owned')) return null;
|
|
162
|
+
let allowedFiles = [];
|
|
163
|
+
let evidence;
|
|
164
|
+
let code = 'FILES_OWNED_VIOLATION';
|
|
165
|
+
let files = [];
|
|
166
|
+
try {
|
|
167
|
+
if (!entry.itemBinding || entry.itemBinding.itemDigest !== digestJson(entry.itemBinding.item)) {
|
|
168
|
+
code = 'OWNERSHIP_EVIDENCE_MISMATCH'; throw new Error('Item binding missing or digest differs');
|
|
169
|
+
}
|
|
170
|
+
const owned = entry.itemBinding?.item?.files_owned;
|
|
171
|
+
if (!Array.isArray(owned)) throw new Error('files_owned must be an array');
|
|
172
|
+
allowedFiles = owned.map(normalizeOwnedPath);
|
|
173
|
+
if (typeof entry.diff !== 'string' || entry.diffDigest !== sha256(entry.diff) || !entry.ownership?.baseCommit) {
|
|
174
|
+
code = 'OWNERSHIP_EVIDENCE_MISMATCH';
|
|
175
|
+
throw new Error('Retained patch/base/digest missing or changed');
|
|
176
|
+
}
|
|
177
|
+
evidence = retainedPatchEvidence(cwd, entry.ownership.baseCommit, entry.diff);
|
|
178
|
+
if (!capture && (evidence.capturedTree !== entry.ownership.capturedTree
|
|
179
|
+
|| digestJson(evidence.changedPaths) !== digestJson(entry.ownership.changedPaths))) {
|
|
180
|
+
code = 'OWNERSHIP_EVIDENCE_MISMATCH';
|
|
181
|
+
throw new Error('Retained patch tree/paths differ from capture');
|
|
182
|
+
}
|
|
183
|
+
files = evidence.changedPaths.filter(file => !allowedFiles.includes(file));
|
|
184
|
+
if (!files.length) { if (capture) entry.ownership = evidence; return null; }
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (code !== 'FILES_OWNED_VIOLATION' || evidence === undefined && allowedFiles.length) code = 'OWNERSHIP_EVIDENCE_MISMATCH';
|
|
187
|
+
files = evidence?.changedPaths ?? [];
|
|
188
|
+
return { code, taskId: entry.itemBinding?.item?.id, stepId: entry.scopedId, itemIndex: entry.itemIndex,
|
|
189
|
+
generation: entry.generation, dispatchToken: entry.dispatchToken, severity: 'error', files, allowedFiles,
|
|
190
|
+
diffDigest: entry.diffDigest, message: error.message };
|
|
191
|
+
}
|
|
192
|
+
if (capture) entry.ownership = evidence;
|
|
193
|
+
return { code, taskId: entry.itemBinding?.item?.id, stepId: entry.scopedId, itemIndex: entry.itemIndex,
|
|
194
|
+
generation: entry.generation, dispatchToken: entry.dispatchToken, severity: 'error', files, allowedFiles,
|
|
195
|
+
diffDigest: entry.diffDigest, message: `Task ${entry.itemBinding?.item?.id ?? entry.scopedId} changed unowned paths: ${files.join(', ')}` };
|
|
196
|
+
}
|
|
197
|
+
|
|
146
198
|
/**
|
|
147
199
|
* Apply one lane's cumulative diff to a temporary index.
|
|
148
200
|
*
|
|
@@ -295,7 +347,7 @@ export class MergeAfterCancelError extends ConsumerArtifactError {
|
|
|
295
347
|
}
|
|
296
348
|
|
|
297
349
|
export class ConsumerFanoutArtifacts {
|
|
298
|
-
constructor({ runId, targetCwd, artifactRoot, hooks = {}, revisionDigest, specDigest }) {
|
|
350
|
+
constructor({ runId, targetCwd, artifactRoot, hooks = {}, revisionDigest, specDigest, profilesDigest }) {
|
|
299
351
|
this.runId = runId;
|
|
300
352
|
this.targetCwd = canonicalPath(targetCwd);
|
|
301
353
|
this.artifactRoot = canonicalPath(artifactRoot ?? defaultArtifactRoot(this.targetCwd));
|
|
@@ -311,6 +363,7 @@ export class ConsumerFanoutArtifacts {
|
|
|
311
363
|
// a crash between journal creation and the first bind cannot leave an
|
|
312
364
|
// unpinned journal that a drifted spec could later re-pin as the truth.
|
|
313
365
|
this._initialRevisionDigest = typeof revisionDigest === 'string' && revisionDigest.length > 0 ? revisionDigest : null;
|
|
366
|
+
this._initialProfilesDigest = profilesDigest;
|
|
314
367
|
this._initialSpecDigest = typeof specDigest === 'string' && specDigest.length > 0 ? specDigest : null;
|
|
315
368
|
this.runRoot = join(this.artifactRoot, runDirectoryName(runId));
|
|
316
369
|
this.journalPath = join(this.runRoot, 'journal.json');
|
|
@@ -419,6 +472,15 @@ export class ConsumerFanoutArtifacts {
|
|
|
419
472
|
);
|
|
420
473
|
}
|
|
421
474
|
}
|
|
475
|
+
if (this._initialProfilesDigest !== undefined) {
|
|
476
|
+
if (journal.profilesDigest !== undefined && journal.profilesDigest !== this._initialProfilesDigest) {
|
|
477
|
+
throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Consumer profiles changed');
|
|
478
|
+
}
|
|
479
|
+
if (journal.profilesDigest === undefined && (journal.issuances.length || journal.wave)) {
|
|
480
|
+
throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Cannot add profiles to an active legacy run');
|
|
481
|
+
}
|
|
482
|
+
journal.profilesDigest = this._initialProfilesDigest;
|
|
483
|
+
}
|
|
422
484
|
return journal;
|
|
423
485
|
}
|
|
424
486
|
|
|
@@ -426,6 +488,7 @@ export class ConsumerFanoutArtifacts {
|
|
|
426
488
|
if (!existsSync(this.journalPath)) {
|
|
427
489
|
const journal = {
|
|
428
490
|
version: JOURNAL_VERSION,
|
|
491
|
+
...(this._initialProfilesDigest !== undefined ? { profilesDigest: this._initialProfilesDigest } : {}),
|
|
429
492
|
runId: this.runId,
|
|
430
493
|
targetCwd: this.targetCwd,
|
|
431
494
|
createdAt: now(),
|
|
@@ -452,7 +515,8 @@ export class ConsumerFanoutArtifacts {
|
|
|
452
515
|
const before = readFileSync(this.journalPath, 'utf8');
|
|
453
516
|
const journal = this.#reload();
|
|
454
517
|
if ((this._initialRevisionDigest && journal.revisionDigest !== JSON.parse(before).revisionDigest)
|
|
455
|
-
|| (this._initialSpecDigest && journal.specDigest !== JSON.parse(before).specDigest)
|
|
518
|
+
|| (this._initialSpecDigest && journal.specDigest !== JSON.parse(before).specDigest)
|
|
519
|
+
|| (this._initialProfilesDigest !== undefined && journal.profilesDigest !== JSON.parse(before).profilesDigest)) {
|
|
456
520
|
this.#writeGuarded(journal);
|
|
457
521
|
}
|
|
458
522
|
return journal;
|
|
@@ -539,7 +603,10 @@ export class ConsumerFanoutArtifacts {
|
|
|
539
603
|
itemIndex: descriptor.itemIndex,
|
|
540
604
|
generation: descriptor.generation,
|
|
541
605
|
path,
|
|
542
|
-
baseCommit:
|
|
606
|
+
baseCommit: this.journal.wave
|
|
607
|
+
? (this.journal.waveAdmissions?.find(a => a.fanoutStepId === descriptor.step && a.epoch === (this.journal.dispatchBindings?.[descriptor.dispatchToken]?.itemBinding.epoch ?? descriptor.epoch))?.baseCommit
|
|
608
|
+
?? worktreeBaseFor({ journal: this.journal, ref: readCheckpointRef({ cwd: this.targetCwd, ref: this.journal.wave.ref }) }))
|
|
609
|
+
: git(this.targetCwd, ['rev-parse', 'HEAD']),
|
|
543
610
|
status: 'creating',
|
|
544
611
|
superseded: false,
|
|
545
612
|
createdAt: now(),
|
|
@@ -570,8 +637,15 @@ export class ConsumerFanoutArtifacts {
|
|
|
570
637
|
* First-write-wins; a later issuance carrying a DIFFERENT engine revision digest
|
|
571
638
|
* is a mid-run revision the journal must reject rather than silently absorb.
|
|
572
639
|
*/
|
|
573
|
-
bindRunRevision({ revisionDigest, specDigest }) {
|
|
640
|
+
bindRunRevision({ revisionDigest, specDigest, profilesDigest }) {
|
|
574
641
|
return this.#mutate(() => {
|
|
642
|
+
if (profilesDigest !== undefined) {
|
|
643
|
+
if (this.journal.profilesDigest !== undefined && this.journal.profilesDigest !== profilesDigest
|
|
644
|
+
|| this.journal.profilesDigest === undefined && (this.journal.issuances.length || this.journal.wave)) {
|
|
645
|
+
throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Consumer profiles changed');
|
|
646
|
+
}
|
|
647
|
+
this.journal.profilesDigest = profilesDigest;
|
|
648
|
+
}
|
|
575
649
|
if (typeof revisionDigest === 'string' && revisionDigest.length > 0) {
|
|
576
650
|
if (this.journal.revisionDigest === null) {
|
|
577
651
|
this.journal.revisionDigest = revisionDigest;
|
|
@@ -590,6 +664,236 @@ export class ConsumerFanoutArtifacts {
|
|
|
590
664
|
});
|
|
591
665
|
}
|
|
592
666
|
|
|
667
|
+
/** Additive v1 seam records: absent until explicitly enabled. */
|
|
668
|
+
recordWaveAdmission(admission) {
|
|
669
|
+
return this.#mutate(() => {
|
|
670
|
+
if (!Array.isArray(admission.items) || !admission.inputDigest || !admission.baseCommit) {
|
|
671
|
+
throw new ConsumerArtifactError('WAVE_INPUT_INVALID', 'Admission needs full items, digest and pinned base');
|
|
672
|
+
}
|
|
673
|
+
if (this.journal.wave && !this.journal.waveAdmissions?.some(a => a.fanoutStepId === admission.fanoutStepId && a.epoch === admission.epoch)) {
|
|
674
|
+
const base = worktreeBaseFor({ journal: this.journal, ref: readCheckpointRef({ cwd: this.targetCwd, ref: this.journal.wave.ref }) });
|
|
675
|
+
if (base !== admission.baseCommit) throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Admission base differs from published tip');
|
|
676
|
+
}
|
|
677
|
+
this.journal.waveAdmissions ??= [];
|
|
678
|
+
const previous = this.journal.waveAdmissions.find(a => a.fanoutStepId === admission.fanoutStepId && a.epoch === admission.epoch);
|
|
679
|
+
if (previous) {
|
|
680
|
+
if (digestJson({ ...previous, validatedAt: null }) !== digestJson({ ...admission, validatedAt: null })) {
|
|
681
|
+
throw new ConsumerArtifactError('WAVE_INPUT_INVALID', 'Recorded wave admission changed');
|
|
682
|
+
}
|
|
683
|
+
return previous;
|
|
684
|
+
}
|
|
685
|
+
const record = { ...structuredClone(admission), validatedAt: admission.validatedAt ?? now() };
|
|
686
|
+
this.journal.waveAdmissions.push(record);
|
|
687
|
+
return record;
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
recordDispatchBinding({ dispatchToken, itemBinding, resolvedProfile }) {
|
|
692
|
+
return this.#mutate(() => {
|
|
693
|
+
if (this.journal.profilesDigest !== undefined && resolvedProfile?.profilesDigest !== this.journal.profilesDigest) {
|
|
694
|
+
throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Dispatch profile is not pinned to this run');
|
|
695
|
+
}
|
|
696
|
+
if (itemBinding.itemDigest !== digestJson(itemBinding.item)) {
|
|
697
|
+
throw new ConsumerArtifactError('WAVE_INPUT_INVALID', 'Dispatch item digest differs');
|
|
698
|
+
}
|
|
699
|
+
this.journal.dispatchBindings ??= {};
|
|
700
|
+
const binding = { itemBinding: structuredClone(itemBinding), resolvedProfile: structuredClone(resolvedProfile) };
|
|
701
|
+
const previous = this.journal.dispatchBindings[dispatchToken];
|
|
702
|
+
if (previous && digestJson(previous) !== digestJson(binding)) {
|
|
703
|
+
throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Dispatch binding changed');
|
|
704
|
+
}
|
|
705
|
+
this.journal.dispatchBindings[dispatchToken] = binding;
|
|
706
|
+
return binding;
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
recordPendingUsageReceipt({ dispatchId, receipt }) {
|
|
711
|
+
return this.#mutate(() => {
|
|
712
|
+
this.journal.pendingUsageReceipts ??= [];
|
|
713
|
+
const existing = this.journal.pendingUsageReceipts.find(r => r.dispatchId === dispatchId);
|
|
714
|
+
if (existing) {
|
|
715
|
+
if (digestJson(existing.receipt) !== digestJson(receipt)) {
|
|
716
|
+
throw new ConsumerArtifactError('CONSUMER_EVIDENCE_MISMATCH', 'Receipt payload changed on replay');
|
|
717
|
+
}
|
|
718
|
+
return existing;
|
|
719
|
+
}
|
|
720
|
+
const record = { dispatchId, receipt: structuredClone(receipt), state: 'pending' };
|
|
721
|
+
this.journal.pendingUsageReceipts.push(record);
|
|
722
|
+
return record;
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
acknowledgeUsageReceipt({ dispatchId, seq }) {
|
|
727
|
+
return this.#mutate(() => {
|
|
728
|
+
const receipt = this.journal.pendingUsageReceipts?.find(r => r.dispatchId === dispatchId);
|
|
729
|
+
if (!receipt) throw new ConsumerArtifactError('CONSUMER_EVIDENCE_MISMATCH', 'Receipt intent is missing');
|
|
730
|
+
receipt.state = 'acknowledged';
|
|
731
|
+
if (seq !== undefined) receipt.seq = seq;
|
|
732
|
+
return receipt;
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
initializeWave({ ref, profilesDigest }) {
|
|
737
|
+
return this.#mutate(() => {
|
|
738
|
+
if (this.journal.wave) {
|
|
739
|
+
if (this.journal.wave.ref !== ref || this.journal.wave.profilesDigest !== profilesDigest) {
|
|
740
|
+
throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Wave configuration changed');
|
|
741
|
+
}
|
|
742
|
+
return this.journal.wave;
|
|
743
|
+
}
|
|
744
|
+
if (readCheckpointRef({ cwd: this.targetCwd, ref }) !== null) {
|
|
745
|
+
throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Pre-existing unowned wave ref');
|
|
746
|
+
}
|
|
747
|
+
if (existsSync(resolve(this.targetCwd, git(this.targetCwd, ['rev-parse', '--git-path', 'MERGE_HEAD'])))
|
|
748
|
+
|| git(this.targetCwd, ['ls-files', '-u'])) throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Unresolved index merge');
|
|
749
|
+
const baseCommit = git(this.targetCwd, ['rev-parse', 'HEAD']);
|
|
750
|
+
this.journal.wave = { ref, baseCommit, baseTree: git(this.targetCwd, ['rev-parse', 'HEAD^{tree}']),
|
|
751
|
+
initialWorkingTree: snapshotWorkingTree(this.targetCwd), profilesDigest, checkpoints: [] };
|
|
752
|
+
return this.journal.wave;
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
recordPreparedCheckpoint(checkpoint) {
|
|
757
|
+
return this.#mutate(() => {
|
|
758
|
+
const wave = this.journal.wave;
|
|
759
|
+
if (!wave) throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Wave not initialized');
|
|
760
|
+
const existing = wave.checkpoints.find(c => c.gateToken === checkpoint.gateToken);
|
|
761
|
+
if (existing) {
|
|
762
|
+
if (existing.commit !== checkpoint.commit || existing.tree !== checkpoint.tree) {
|
|
763
|
+
throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Checkpoint token changed');
|
|
764
|
+
}
|
|
765
|
+
return existing;
|
|
766
|
+
}
|
|
767
|
+
const parent = wave.checkpoints.at(-1)?.commit ?? wave.baseCommit;
|
|
768
|
+
if (checkpoint.parentCommit !== parent || checkpoint.waveNumber !== wave.checkpoints.length + 1
|
|
769
|
+
|| !checkpoint.gateToken || !Number.isInteger(checkpoint.gateOrdinal)) {
|
|
770
|
+
throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Checkpoint order or identity differs');
|
|
771
|
+
}
|
|
772
|
+
const record = { ...structuredClone(checkpoint), state: 'prepared', preparedAt: now() };
|
|
773
|
+
wave.checkpoints.push(record);
|
|
774
|
+
return record;
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
markCheckpointPublished({ gateToken, commit, evidenceReceiptId }) {
|
|
779
|
+
return this.#mutate(() => {
|
|
780
|
+
const checkpoint = this.journal.wave?.checkpoints.find(c => c.gateToken === gateToken);
|
|
781
|
+
if (!checkpoint || checkpoint.commit !== commit
|
|
782
|
+
|| readCheckpointRef({ cwd: this.targetCwd, ref: this.journal.wave.ref }) !== commit) {
|
|
783
|
+
throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Publication does not match prepared checkpoint');
|
|
784
|
+
}
|
|
785
|
+
this.#verifyCheckpointObject(checkpoint);
|
|
786
|
+
checkpoint.state = 'published';
|
|
787
|
+
checkpoint.publishedAt ??= now();
|
|
788
|
+
checkpoint.materializedTree ??= checkpoint.tree;
|
|
789
|
+
if (evidenceReceiptId) checkpoint.evidenceReceiptId = evidenceReceiptId;
|
|
790
|
+
return checkpoint;
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Recover approved obligations only; never replay their patches into the live parent. */
|
|
795
|
+
recoverCheckpoint(transaction) {
|
|
796
|
+
const wave = this.journal.wave;
|
|
797
|
+
if (!wave) return;
|
|
798
|
+
let checkpoint = wave.checkpoints.find(c => c.gateToken === transaction.gateToken);
|
|
799
|
+
const tip = readCheckpointRef({ cwd: this.targetCwd, ref: wave.ref });
|
|
800
|
+
// A verified newer recorded tip must never be rewound to a historical checkpoint.
|
|
801
|
+
const index = checkpoint ? wave.checkpoints.indexOf(checkpoint) : -1;
|
|
802
|
+
if (checkpoint && (checkpoint.gateOrdinal !== transaction.gateOrdinal
|
|
803
|
+
|| checkpoint.gateStepId !== transaction.gateStepId || checkpoint.tree !== transaction.witnessChain.at(-1)
|
|
804
|
+
|| checkpoint.parentCommit !== transaction.checkpointParent)) {
|
|
805
|
+
throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Checkpoint differs from its approved merge obligation');
|
|
806
|
+
}
|
|
807
|
+
for (const [i, recorded] of wave.checkpoints.entries()) {
|
|
808
|
+
if (recorded.parentCommit !== (wave.checkpoints[i - 1]?.commit ?? wave.baseCommit)) {
|
|
809
|
+
throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Recorded checkpoint chain is broken');
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (index >= 0 && wave.checkpoints.slice(index + 1).some(c => c.state === 'published' && c.commit === tip)) {
|
|
813
|
+
for (const recorded of wave.checkpoints.slice(index)) this.#verifyCheckpointObject(recorded);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
if (!checkpoint) {
|
|
817
|
+
const previous = wave.checkpoints.at(-1)?.commit ?? null;
|
|
818
|
+
if (reconcileCheckpoint({ journalEntry: { gateOutcome: 'approve', previousCommit: previous }, refValue: tip }) !== 'PREPARE_AND_PUBLISH') {
|
|
819
|
+
throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Unexpected ref before preparation');
|
|
820
|
+
}
|
|
821
|
+
if (!Number.isInteger(transaction.gateOrdinal)) throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Missing gate ordinal');
|
|
822
|
+
const tree = transaction.witnessChain.at(-1);
|
|
823
|
+
this.#verifyCheckpointReplay(transaction, tree);
|
|
824
|
+
this.#materializeCheckpoint(transaction, tree);
|
|
825
|
+
const prepared = prepareCheckpoint({ cwd: this.targetCwd, ref: wave.ref,
|
|
826
|
+
parentCommit: transaction.checkpointParent ?? previous ?? wave.baseCommit, tree,
|
|
827
|
+
message: `Compose wave ${wave.checkpoints.length + 1} run ${this.runId} gate ${transaction.gateToken}` });
|
|
828
|
+
checkpoint = this.recordPreparedCheckpoint({ ...prepared, waveNumber: wave.checkpoints.length + 1,
|
|
829
|
+
fanoutStepId: transaction.fanoutStepId, epoch: transaction.epoch, gateStepId: transaction.gateStepId,
|
|
830
|
+
gateToken: transaction.gateToken, gateOrdinal: transaction.gateOrdinal, baselineTree: transaction.baselineTree,
|
|
831
|
+
orderedDispatchTokens: transaction.acceptedDispatchTokens ?? transaction.orderedDiffs.map(d => d.dispatchToken) });
|
|
832
|
+
}
|
|
833
|
+
this.#verifyCheckpointObject(checkpoint);
|
|
834
|
+
const action = reconcileCheckpoint({ journalEntry: checkpoint, refValue: tip });
|
|
835
|
+
if (action === 'REPLAY_AND_PUBLISH') {
|
|
836
|
+
if (checkpoint.payloadsDroppedAt) throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Checkpoint payloads were already cleaned');
|
|
837
|
+
this.#materializeCheckpoint(transaction, checkpoint.tree);
|
|
838
|
+
this.#verifyCheckpointReplay(transaction, checkpoint.tree);
|
|
839
|
+
publishCheckpoint({ cwd: this.targetCwd, ref: wave.ref, expected: tip, commit: checkpoint.commit });
|
|
840
|
+
} else if (!['MARK_PUBLISHED', 'ALREADY_PUBLISHED'].includes(action)) {
|
|
841
|
+
throw new WaveCheckpointError(action, 'Cannot reconcile checkpoint ref');
|
|
842
|
+
} else if (transaction.witnessChain.slice(0, -1).includes(snapshotWorkingTree(this.targetCwd))) {
|
|
843
|
+
this.#materializeCheckpoint(transaction, checkpoint.tree);
|
|
844
|
+
}
|
|
845
|
+
this.markCheckpointPublished({ gateToken: checkpoint.gateToken, commit: checkpoint.commit });
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
#materializeCheckpoint(transaction, tree) {
|
|
849
|
+
const live = snapshotWorkingTree(this.targetCwd);
|
|
850
|
+
if (live === tree) return;
|
|
851
|
+
if (transaction.witnessChain.includes(live)) {
|
|
852
|
+
checkoutTreeDelta(this.targetCwd, live, tree);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const changed = git(this.targetCwd, ['diff', '--name-only', '-z', '--no-renames', tree, live, '--'], { trim: false }).split('\0').filter(Boolean);
|
|
856
|
+
const wavePaths = new Set(git(this.targetCwd, ['diff', '--name-only', '-z', '--no-renames', transaction.baselineTree, tree, '--'],
|
|
857
|
+
{ trim: false }).split('\0').filter(Boolean));
|
|
858
|
+
if (changed.some(path => wavePaths.has(path))) {
|
|
859
|
+
throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Parent differs ambiguously from the integrated checkpoint');
|
|
860
|
+
}
|
|
861
|
+
// Independent post-wave edits are preserved; publication captures only the witness.
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
#verifyCheckpointReplay(transaction, expectedTree) {
|
|
865
|
+
try {
|
|
866
|
+
const tree = withTemporaryIndex(this.targetCwd, env => {
|
|
867
|
+
git(this.targetCwd, ['read-tree', transaction.baselineTree], { env });
|
|
868
|
+
for (const ordered of transaction.orderedDiffs) {
|
|
869
|
+
if (typeof ordered.diff !== 'string' || sha256(ordered.diff) !== ordered.digest) throw new Error('Missing or changed retained patch');
|
|
870
|
+
if (ordered.diff) applyDiffToIndex(this.targetCwd, ordered.diff, env);
|
|
871
|
+
}
|
|
872
|
+
return git(this.targetCwd, ['write-tree'], { env });
|
|
873
|
+
});
|
|
874
|
+
if (tree !== expectedTree) throw new Error('Replayed tree differs');
|
|
875
|
+
} catch (error) { throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', error.message); }
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
#verifyCheckpointObject(checkpoint) {
|
|
879
|
+
try {
|
|
880
|
+
const object = git(this.targetCwd, ['cat-file', 'commit', checkpoint.commit], { trim: false });
|
|
881
|
+
const boundary = object.indexOf('\n\n');
|
|
882
|
+
const headers = object.slice(0, boundary).split('\n');
|
|
883
|
+
const metadata = checkpoint.commitMetadata;
|
|
884
|
+
const epoch = Math.floor(Date.parse(metadata.date) / 1000);
|
|
885
|
+
const identity = `${metadata.authorName} <${metadata.authorEmail}> ${epoch} +0000`;
|
|
886
|
+
const expectedHeaders = [`tree ${checkpoint.tree}`, `parent ${checkpoint.parentCommit}`,
|
|
887
|
+
`author ${identity}`, `committer ${identity}`];
|
|
888
|
+
if (digestJson(headers) !== digestJson(expectedHeaders)
|
|
889
|
+
|| object.slice(boundary + 2).replace(/\n$/, '') !== checkpoint.message.replace(/\n$/, '')) {
|
|
890
|
+
throw new Error('Checkpoint identity/tree/parent/message differs');
|
|
891
|
+
}
|
|
892
|
+
git(this.targetCwd, ['cat-file', '-e', `${checkpoint.parentCommit}^{commit}`]);
|
|
893
|
+
git(this.targetCwd, ['cat-file', '-e', `${checkpoint.tree}^{tree}`]);
|
|
894
|
+
} catch (error) { throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', error.message); }
|
|
895
|
+
}
|
|
896
|
+
|
|
593
897
|
/** Record which fanout step a merge gate settles, so resume never re-derives it
|
|
594
898
|
* from a mutable local spec that may have drifted. First-write-wins per gate. */
|
|
595
899
|
recordGateBinding({ gateStepId, fanoutStepId }) {
|
|
@@ -626,7 +930,7 @@ export class ConsumerFanoutArtifacts {
|
|
|
626
930
|
|| (scope.fanoutStepId === fanoutStepId && scope.itemIndex === itemIndex);
|
|
627
931
|
let changed = false;
|
|
628
932
|
for (const issuance of this.journal.issuances) {
|
|
629
|
-
if (issuance.state === 'merged' || issuance.state === 'superseded') continue;
|
|
933
|
+
if (issuance.state === 'merged' || issuance.state === 'superseded' || issuance.state === 'failed') continue;
|
|
630
934
|
if (!inScope(issuance.fanoutStepId, issuance.itemIndex)) continue;
|
|
631
935
|
const fanoutItems = audit?.steps?.[issuance.fanoutStepId]?.fanout?.items;
|
|
632
936
|
const item = Array.isArray(fanoutItems) ? fanoutItems[issuance.itemIndex] : undefined;
|
|
@@ -678,13 +982,17 @@ export class ConsumerFanoutArtifacts {
|
|
|
678
982
|
*/
|
|
679
983
|
reconcileDescriptor(descriptor, audit) {
|
|
680
984
|
return this.#mutate(() => {
|
|
985
|
+
const binding = this.journal.dispatchBindings?.[descriptor.dispatchToken]?.itemBinding;
|
|
986
|
+
if (isNoneIsolation(descriptor) && Object.hasOwn(binding?.item ?? {}, 'files_owned')) {
|
|
987
|
+
throw new ConsumerArtifactError('WAVE_OWNERSHIP_INVALID', 'Ownership requires worktree isolation');
|
|
988
|
+
}
|
|
681
989
|
// Per-item settlement: the descriptor's audit is a concurrent snapshot that
|
|
682
990
|
// may be stale for OTHER items, so reconcile only this item's own records.
|
|
683
991
|
this.#reconcileAuditInto(audit, { fanoutStepId: descriptor.step, itemIndex: descriptor.itemIndex });
|
|
684
992
|
const prepared = this.journal.issuances.find(
|
|
685
993
|
(entry) => entry.dispatchToken === descriptor.dispatchToken,
|
|
686
994
|
);
|
|
687
|
-
if (prepared?.state === 'prepared') {
|
|
995
|
+
if (prepared?.state === 'prepared' || prepared?.state === 'failed') {
|
|
688
996
|
return { action: 'report', envelope: structuredClone(prepared.envelope), issuance: prepared };
|
|
689
997
|
}
|
|
690
998
|
if (prepared?.state === 'accepted') {
|
|
@@ -747,14 +1055,26 @@ export class ConsumerFanoutArtifacts {
|
|
|
747
1055
|
});
|
|
748
1056
|
}
|
|
749
1057
|
|
|
750
|
-
prepareIssuance(descriptor, envelope, { finalStage }) {
|
|
1058
|
+
prepareIssuance(descriptor, envelope, { finalStage, itemBinding, resolvedProfile, ownership } = {}) {
|
|
751
1059
|
return this.#mutate(() => {
|
|
752
1060
|
const existing = this.journal.issuances.find(
|
|
753
1061
|
(entry) => entry.dispatchToken === descriptor.dispatchToken,
|
|
754
1062
|
);
|
|
755
1063
|
if (existing) return existing;
|
|
756
1064
|
|
|
1065
|
+
const binding = itemBinding ?? this.journal.dispatchBindings?.[descriptor.dispatchToken]?.itemBinding
|
|
1066
|
+
?? (ownership ? {
|
|
1067
|
+
item: structuredClone(descriptor.item ?? {}), itemDigest: digestJson(descriptor.item ?? {}),
|
|
1068
|
+
epoch: descriptor.epoch, sourceProvenance: 'descriptor.item',
|
|
1069
|
+
} : undefined);
|
|
1070
|
+
const ownershipEnabled = ownership || Object.hasOwn(binding?.item ?? {}, 'files_owned');
|
|
1071
|
+
const profile = resolvedProfile ?? this.journal.dispatchBindings?.[descriptor.dispatchToken]?.resolvedProfile;
|
|
1072
|
+
const fields = { ...(binding ? { itemBinding: structuredClone(binding) } : {}),
|
|
1073
|
+
...(profile ? { resolvedProfile: structuredClone(profile) } : {}) };
|
|
757
1074
|
if (isNoneIsolation(descriptor)) {
|
|
1075
|
+
if (ownership || Object.hasOwn(binding?.item ?? {}, 'files_owned')) {
|
|
1076
|
+
throw new ConsumerArtifactError('WAVE_OWNERSHIP_INVALID', 'Ownership requires worktree isolation');
|
|
1077
|
+
}
|
|
758
1078
|
// Envelope-only journal entry: no worktree, no witness, no diff. It never
|
|
759
1079
|
// participates in the merge (prepareMerge filters isolation:none out), so
|
|
760
1080
|
// its files persist directly in the target cwd.
|
|
@@ -768,6 +1088,7 @@ export class ConsumerFanoutArtifacts {
|
|
|
768
1088
|
attempt: descriptor.attempt,
|
|
769
1089
|
revisionDigest: descriptor.revisionDigest,
|
|
770
1090
|
contractDigest: descriptor.contractDigest,
|
|
1091
|
+
...fields,
|
|
771
1092
|
isolation: 'none',
|
|
772
1093
|
worktreeKey: null,
|
|
773
1094
|
witnessTree: null,
|
|
@@ -804,6 +1125,8 @@ export class ConsumerFanoutArtifacts {
|
|
|
804
1125
|
attempt: descriptor.attempt,
|
|
805
1126
|
revisionDigest: descriptor.revisionDigest,
|
|
806
1127
|
contractDigest: descriptor.contractDigest,
|
|
1128
|
+
...fields,
|
|
1129
|
+
...(ownershipEnabled && finalStage ? { ownership: { baseCommit: worktree.baseCommit } } : {}),
|
|
807
1130
|
isolation: 'worktree',
|
|
808
1131
|
worktreeKey: worktree.key,
|
|
809
1132
|
witnessTree: witness.witnessTree,
|
|
@@ -814,11 +1137,39 @@ export class ConsumerFanoutArtifacts {
|
|
|
814
1137
|
diffDigest: diff === null ? null : sha256(diff),
|
|
815
1138
|
preparedAt: now(),
|
|
816
1139
|
};
|
|
1140
|
+
if (finalStage && ownershipEnabled) {
|
|
1141
|
+
const finding = checkOwnership(this.targetCwd, entry, { capture: true });
|
|
1142
|
+
if (finding) this.#failOwnership(entry, finding);
|
|
1143
|
+
}
|
|
817
1144
|
this.journal.issuances.push(entry);
|
|
818
1145
|
return entry;
|
|
819
1146
|
});
|
|
820
1147
|
}
|
|
821
1148
|
|
|
1149
|
+
#failOwnership(entry, finding) {
|
|
1150
|
+
entry.state = 'failed';
|
|
1151
|
+
entry.findings ??= [];
|
|
1152
|
+
if (!entry.findings.some(f => f.code === finding.code && f.message === finding.message)) entry.findings.push(finding);
|
|
1153
|
+
entry.ownership ??= {};
|
|
1154
|
+
entry.ownership.finding = finding;
|
|
1155
|
+
entry.envelope = { ...entry.envelope, failure: `${finding.code}: ${finding.message}` };
|
|
1156
|
+
delete entry.envelope.output;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
#verifyOwnership(entry, ordered) {
|
|
1160
|
+
let finding = checkOwnership(this.targetCwd, entry);
|
|
1161
|
+
if (entry.itemBinding && ordered && (ordered.diff !== entry.diff || ordered.digest !== entry.diffDigest
|
|
1162
|
+
|| ordered.bindingDigest !== digestJson(entry.itemBinding))) {
|
|
1163
|
+
finding = { code: 'OWNERSHIP_EVIDENCE_MISMATCH', message: 'Ordered patch/binding differs from captured issuance',
|
|
1164
|
+
dispatchToken: entry.dispatchToken, severity: 'error', files: [], allowedFiles: entry.itemBinding.item?.files_owned ?? [] };
|
|
1165
|
+
}
|
|
1166
|
+
if (finding) {
|
|
1167
|
+
this.#failOwnership(entry, finding);
|
|
1168
|
+
this.#save();
|
|
1169
|
+
throw new ConsumerMergeDecisionError(finding.code, finding.message, { finding });
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
822
1173
|
prepareArtifactFailure(descriptor, envelope, error) {
|
|
823
1174
|
return this.#mutate(() => {
|
|
824
1175
|
const existing = this.journal.issuances.find(
|
|
@@ -869,13 +1220,20 @@ export class ConsumerFanoutArtifacts {
|
|
|
869
1220
|
{ gateToken },
|
|
870
1221
|
);
|
|
871
1222
|
}
|
|
872
|
-
if (existing)
|
|
1223
|
+
if (existing) {
|
|
1224
|
+
for (const ordered of existing.orderedDiffs ?? []) {
|
|
1225
|
+
const issuance = this.journal.issuances.find(e => e.dispatchToken === ordered.dispatchToken);
|
|
1226
|
+
if (issuance) this.#verifyOwnership(issuance, ordered);
|
|
1227
|
+
}
|
|
1228
|
+
return existing;
|
|
1229
|
+
}
|
|
873
1230
|
|
|
874
1231
|
const accepted = this.acceptedEntriesFor(fanoutStepId);
|
|
875
1232
|
// Only isolation:worktree items own a diff and participate in the merge.
|
|
876
1233
|
// isolation:none items already wrote in the target cwd; they are accepted
|
|
877
1234
|
// evidence but owe no diff (a pure-none fanout yields zero ordered diffs).
|
|
878
1235
|
const worktreeAccepted = accepted.filter((entry) => entry.isolation !== 'none');
|
|
1236
|
+
for (const entry of worktreeAccepted) this.#verifyOwnership(entry);
|
|
879
1237
|
const baselineTree = snapshotWorkingTree(this.targetCwd);
|
|
880
1238
|
// A worker that changed NOTHING owes an empty diff, not a missing one, and it
|
|
881
1239
|
// must not join the merge: applying nothing leaves the tree identical, so its
|
|
@@ -896,6 +1254,7 @@ export class ConsumerFanoutArtifacts {
|
|
|
896
1254
|
itemIndex: entry.itemIndex,
|
|
897
1255
|
generation: entry.generation,
|
|
898
1256
|
digest: entry.diffDigest,
|
|
1257
|
+
...(entry.itemBinding ? { bindingDigest: digestJson(entry.itemBinding) } : {}),
|
|
899
1258
|
diff: entry.diff,
|
|
900
1259
|
}));
|
|
901
1260
|
const recordBlocked = (code, message, witnessChain = [baselineTree]) => {
|
|
@@ -973,6 +1332,12 @@ export class ConsumerFanoutArtifacts {
|
|
|
973
1332
|
gateToken,
|
|
974
1333
|
fanoutStepId,
|
|
975
1334
|
state: 'prepared',
|
|
1335
|
+
...(this.journal.wave ? {
|
|
1336
|
+
epoch: audit?.steps?.[fanoutStepId]?.epoch,
|
|
1337
|
+
checkpointParent: this.journal.wave.checkpoints.at(-1)?.commit ?? this.journal.wave.baseCommit,
|
|
1338
|
+
acceptedDispatchTokens: worktreeAccepted.map(e => e.dispatchToken),
|
|
1339
|
+
gateOrdinal: (audit?.events ?? []).filter(e => e.type === 'gate_resolved' && e.stepId === gateStepId).length,
|
|
1340
|
+
} : {}),
|
|
976
1341
|
baselineTree,
|
|
977
1342
|
witnessChain,
|
|
978
1343
|
orderedDiffs,
|
|
@@ -1087,6 +1452,10 @@ export class ConsumerFanoutArtifacts {
|
|
|
1087
1452
|
// not a lockfile in this slice.
|
|
1088
1453
|
const live = this.#currentTransaction(gateToken);
|
|
1089
1454
|
if (live) this.#assertMergeNotDecided(live, gateToken);
|
|
1455
|
+
this.#mutate(() => {
|
|
1456
|
+
const issuance = this.journal.issuances.find(e => e.dispatchToken === ordered.dispatchToken);
|
|
1457
|
+
if (issuance) this.#verifyOwnership(issuance, ordered);
|
|
1458
|
+
});
|
|
1090
1459
|
// Same merge algorithm as the witness precompute (temporary index,
|
|
1091
1460
|
// three-way), then the merged tree is checked out. Applying straight to
|
|
1092
1461
|
// the working tree cannot use --3way (it requires the real index to match).
|
|
@@ -1261,10 +1630,18 @@ export class ConsumerFanoutArtifacts {
|
|
|
1261
1630
|
});
|
|
1262
1631
|
}
|
|
1263
1632
|
|
|
1264
|
-
cleanupWorktrees(reason) {
|
|
1633
|
+
cleanupWorktrees(reason, { dispatchTokens } = {}) {
|
|
1265
1634
|
this.#mutate(() => {
|
|
1635
|
+
const selected = dispatchTokens ? new Set(dispatchTokens) : null;
|
|
1636
|
+
const eligible = token => {
|
|
1637
|
+
if (selected && !selected.has(token)) return false;
|
|
1638
|
+
if (!this.journal.wave) return true;
|
|
1639
|
+
return this.journal.wave.checkpoints.some(c => c.state === 'published' && c.evidenceReceiptId
|
|
1640
|
+
&& c.orderedDispatchTokens.includes(token));
|
|
1641
|
+
};
|
|
1642
|
+
const worktreeKeys = new Set(this.journal.issuances.filter(e => eligible(e.dispatchToken)).map(e => e.worktreeKey));
|
|
1266
1643
|
for (const record of this.journal.worktrees) {
|
|
1267
|
-
if (record.cleanedAt) continue;
|
|
1644
|
+
if (record.cleanedAt || (selected || this.journal.wave) && !worktreeKeys.has(record.key)) continue;
|
|
1268
1645
|
if (existsSync(record.path)) {
|
|
1269
1646
|
try {
|
|
1270
1647
|
git(this.targetCwd, ['worktree', 'remove', '--force', record.path], { timeout: 60_000 });
|
|
@@ -1277,16 +1654,20 @@ export class ConsumerFanoutArtifacts {
|
|
|
1277
1654
|
record.cleanupReason = reason;
|
|
1278
1655
|
}
|
|
1279
1656
|
for (const issuance of this.journal.issuances) {
|
|
1280
|
-
if (typeof issuance.diff === 'string') {
|
|
1657
|
+
if (eligible(issuance.dispatchToken) && typeof issuance.diff === 'string') {
|
|
1281
1658
|
issuance.diff = null;
|
|
1282
1659
|
issuance.diffDroppedAt = now();
|
|
1283
1660
|
}
|
|
1284
1661
|
}
|
|
1662
|
+
for (const checkpoint of this.journal.wave?.checkpoints ?? []) {
|
|
1663
|
+
if (checkpoint.state === 'published' && checkpoint.evidenceReceiptId
|
|
1664
|
+
&& checkpoint.orderedDispatchTokens.every(eligible)) checkpoint.payloadsDroppedAt ??= now();
|
|
1665
|
+
}
|
|
1285
1666
|
for (const transaction of this.journal.mergeTransactions) {
|
|
1286
1667
|
for (const ordered of transaction.orderedDiffs ?? []) {
|
|
1287
|
-
if (typeof ordered.diff === 'string') ordered.diff = null;
|
|
1668
|
+
if (eligible(ordered.dispatchToken) && typeof ordered.diff === 'string') ordered.diff = null;
|
|
1288
1669
|
}
|
|
1289
|
-
if (transaction.orderedDiffs?.length > 0 && !transaction.diffPayloadsDroppedAt) {
|
|
1670
|
+
if (transaction.orderedDiffs?.length > 0 && transaction.orderedDiffs.every(d => d.diff === null) && !transaction.diffPayloadsDroppedAt) {
|
|
1290
1671
|
transaction.diffPayloadsDroppedAt = now();
|
|
1291
1672
|
}
|
|
1292
1673
|
}
|
|
@@ -1320,10 +1701,15 @@ export function verifyConsumerRunRevision({
|
|
|
1320
1701
|
artifactRoot,
|
|
1321
1702
|
specDigest,
|
|
1322
1703
|
resumeRevisionDigest,
|
|
1704
|
+
profilesDigest,
|
|
1323
1705
|
}) {
|
|
1324
1706
|
if (!runId || !existsSync(journalLocation({ runId, targetCwd, artifactRoot }))) return null;
|
|
1325
1707
|
const { journal } = new ConsumerFanoutArtifacts({ runId, targetCwd, artifactRoot });
|
|
1326
1708
|
|
|
1709
|
+
if ((journal.profilesDigest !== undefined || profilesDigest !== undefined) && journal.profilesDigest !== profilesDigest) {
|
|
1710
|
+
throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Consumer profiles differ from the pinned run',
|
|
1711
|
+
{ recorded: journal.profilesDigest, current: profilesDigest });
|
|
1712
|
+
}
|
|
1327
1713
|
const fullyPinned = Boolean(journal.revisionDigest) && Boolean(journal.specDigest);
|
|
1328
1714
|
if (!fullyPinned) {
|
|
1329
1715
|
const hasWork = journal.issuances.length > 0
|
|
@@ -1405,13 +1791,14 @@ export function recoverAdvancedConsumerArtifacts({
|
|
|
1405
1791
|
const matchingGateEvents = gateEvents.filter(
|
|
1406
1792
|
(event) => event.stepId === transaction.gateStepId,
|
|
1407
1793
|
);
|
|
1408
|
-
const eventOffset = gateEventOffsets.get(transaction.gateStepId) ?? 0;
|
|
1794
|
+
const eventOffset = transaction.gateOrdinal ?? gateEventOffsets.get(transaction.gateStepId) ?? 0;
|
|
1409
1795
|
const resolvedEvent = matchingGateEvents[eventOffset];
|
|
1410
1796
|
gateEventOffsets.set(transaction.gateStepId, eventOffset + 1);
|
|
1411
1797
|
const resolvedOutcome = transaction.gateOutcome ?? resolvedEvent?.detail?.decision;
|
|
1412
1798
|
const approvedComplete = transaction.state === 'complete' && resolvedOutcome === 'approve';
|
|
1413
1799
|
|
|
1414
|
-
if (approvedComplete && gateState?.status !== 'waiting_gate') {
|
|
1800
|
+
if (approvedComplete && (artifacts.journal.wave || gateState?.status !== 'waiting_gate')) {
|
|
1801
|
+
if (artifacts.journal.wave) artifacts.recoverCheckpoint(transaction);
|
|
1415
1802
|
artifacts.markGateAdvanced({ gateToken: transaction.gateToken, outcome: resolvedOutcome, at: resolvedEvent?.at });
|
|
1416
1803
|
recoveredDecision = true;
|
|
1417
1804
|
approvedAdvanced = true;
|
|
@@ -6,13 +6,17 @@
|
|
|
6
6
|
* model IDs degrade to usd:null rather than crashing — a crashed / future
|
|
7
7
|
* model still yields a record with partial metrics.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Claude 5 rates: COMP-FABLE-ASTRA slice 1 (2026-09). Earlier rates retained
|
|
10
|
+
* for historical receipts (Anthropic / OpenAI, 2026-07).
|
|
10
11
|
* Keys are prefix-matched so dated variants (e.g. claude-sonnet-4-6-20250514)
|
|
11
12
|
* resolve against the base key.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
/** @type {Record<string, { inputPerMTok: number, outputPerMTok: number }>} */
|
|
15
16
|
const EXPERIMENT_PRICING = {
|
|
17
|
+
'claude-fable-5-1': { inputPerMTok: 10, outputPerMTok: 50 },
|
|
18
|
+
'claude-opus-5': { inputPerMTok: 5, outputPerMTok: 25 },
|
|
19
|
+
'claude-sonnet-5': { inputPerMTok: 2, outputPerMTok: 10 },
|
|
16
20
|
// Claude 4.x
|
|
17
21
|
'claude-opus-4-8': { inputPerMTok: 5, outputPerMTok: 25 },
|
|
18
22
|
'claude-opus-4-7': { inputPerMTok: 5, outputPerMTok: 25 },
|