@smartmemory/compose 0.4.1 → 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/.claude/agents/compose-architect.md +40 -0
- package/.claude/agents/compose-explorer.md +35 -0
- package/.claude/hooks/canon-guard.mjs +52 -0
- package/README.md +15 -1
- package/bin/compose.js +57 -17
- package/bin/git-hooks/pre-push.template +26 -1
- package/bin/receipts-gate.js +39 -0
- package/contracts/fluid-record.schema.json +5 -0
- package/dist/assets/{App-Z4MU-H_F.js → App-DC7paCZv.js} +190 -190
- package/dist/assets/{_baseUniq-ClWoCPFl.js → _baseUniq-Czad7yiy.js} +1 -1
- package/dist/assets/{arc-DY26UIVo.js → arc-EquvLk8y.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-6Ggq4DqJ.js → architectureDiagram-Q4EWVU46-Dr_qinWi.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CH3Ked0l.js → blockDiagram-DXYQGD6D-D2z46ED_.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-Bk8dYilu.js → c4Diagram-AHTNJAMY-BHob1Yt0.js} +1 -1
- package/dist/assets/channel-B-7ZRCKC.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-BMR0XaAQ.js → chunk-4BX2VUAB-DomWBRa_.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-JytR14a9.js → chunk-4TB4RGXK-WyC_x_DH.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-B4Q97BCP.js → chunk-55IACEB6-BajRv3zx.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-R_qarkSf.js → chunk-EDXVE4YY-rMnedK_r.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-C9s7KR9m.js → chunk-FMBD7UC4-BPi03Hcb.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-BySQzVxc.js → chunk-OYMX7WX6-B7J_mKX0.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DdpSYZsW.js → chunk-QZHKN3VN-BLXTVr8N.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-iE_tzriw.js → chunk-YZCP3GAM-BYWjo2OJ.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-Balz1OEB.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-Balz1OEB.js +1 -0
- package/dist/assets/clone-CfNV0lUO.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BdlU6ZX_.js → cose-bilkent-S5V4N54A-Coaq0xaU.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-Cp3F5KTn.js → dagre-KV5264BT-DvUvAxlj.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DiR6_2q_.js → diagram-5BDNPKRD-70bXRUXV.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-w0i-p5HX.js → diagram-G4DWMVQ6-hMA8wgzx.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-tIHhwUv3.js → diagram-MMDJMWI5-BNir7C6i.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-BAeY3B19.js → diagram-TYMM5635-BCYl1xrE.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-Ckx_Knko.js → erDiagram-SMLLAGMA-bjxP0_bt.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-DeoNka6J.js → flowDiagram-DWJPFMVM-CBn9fhEp.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-BmGnFbEg.js → ganttDiagram-T4ZO3ILL-y1O7mWzn.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Dk48IHsx.js → gitGraphDiagram-UUTBAWPF-DIxwDXHB.js} +1 -1
- package/dist/assets/{graph-BNzKGvoy.js → graph-9D1ZumWp.js} +1 -1
- package/dist/assets/{index-BEfrNBp8.js → index-Ds_IXQo3.js} +2 -2
- package/dist/assets/{infoDiagram-42DDH7IO-BRf827i0.js → infoDiagram-42DDH7IO-DsWLGhaY.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-0kCZaeCM.js → ishikawaDiagram-UXIWVN3A-CipZIE90.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-rvU7ayRt.js → journeyDiagram-VCZTEJTY-Vr5xqcQm.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-DpQwX1C5.js → kanban-definition-6JOO6SKY-EqUYneyh.js} +1 -1
- package/dist/assets/{layout-BI8cXFPI.js → layout-hfWIIs0-.js} +1 -1
- package/dist/assets/{linear-a0glcDiw.js → linear-BdDWoN0t.js} +1 -1
- package/dist/assets/{min-vPHfnXcC.js → min-Bn_xAS7n.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-D14eF-7C.js → mindmap-definition-QFDTVHPH-qsgubzCF.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-Cno-gETh.js → pieDiagram-DEJITSTG-Bv1xq_58.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-BUQM1Hfm.js → quadrantDiagram-34T5L4WZ-DwMbAegF.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-pOXlN2-q.js → requirementDiagram-MS252O5E-BJVmLNcp.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-Crynd3_b.js → sankeyDiagram-XADWPNL6-o5GZb8Y1.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-D9fZdCM8.js → sequenceDiagram-FGHM5R23-ocqJp2qk.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-CW9qVec8.js → stateDiagram-FHFEXIEX-DGaDUFxP.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dz-15i-r.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BcHzhm_8.js → timeline-definition-GMOUNBTQ-C4YwFvAn.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-BfytJcWk.js → vennDiagram-DHZGUBPP-uOKn9j-y.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-DLj-IjyB.js → wardley-RL74JXVD-DIQSmQde.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Ds0Ue68c.js → wardleyDiagram-NUSXRM2D-CdamsEDC.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-vjWDXFL6.js → xychartDiagram-5P7HB3ND-DhLs41yk.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/agent-string.js +9 -4
- package/lib/build-cancel.js +205 -0
- package/lib/build-stream-writer.js +6 -0
- package/lib/build.js +1189 -165
- package/lib/canon-guard.js +3 -24
- package/lib/canon-registry.js +2 -71
- package/lib/codex-preflight.js +8 -0
- package/lib/colleague/context.js +123 -0
- package/lib/consumer-fanout.js +427 -17
- package/lib/decision-blocks.js +38 -0
- package/lib/dispatch-ledger.js +7 -0
- package/lib/experiment-pricing.js +5 -1
- package/lib/flow-state.js +38 -0
- package/lib/fluid/factory.js +112 -1
- package/lib/fluid/ideabox-manifest.js +203 -0
- package/lib/fluid/ideabox-migrate.js +177 -29
- package/lib/fluid/ideabox-preamble.js +155 -0
- package/lib/fluid/ideabox-readable.js +83 -0
- package/lib/fluid/ideabox-recover.js +393 -0
- package/lib/fluid/import-ideabox.js +188 -45
- package/lib/fluid/local-provider.js +6 -0
- package/lib/fluid/portfolio.js +255 -0
- package/lib/fluid/record-shape.js +7 -0
- package/lib/fluid/render-ideabox.js +153 -7
- package/lib/fluid/smartmemory-provider.js +6 -0
- package/lib/gate-prompt.js +14 -7
- package/lib/gsd.js +95 -48
- package/lib/ideabox-cli.js +68 -0
- package/lib/ideabox.js +209 -9
- package/lib/maya-identity.js +16 -2
- package/lib/model-pricing.js +4 -1
- package/lib/output-gate.js +81 -0
- package/lib/pipeline-profiles.js +200 -0
- package/lib/process-termination.js +121 -3
- package/lib/receipts-gate.js +268 -0
- package/lib/result-normalizer.js +41 -1
- package/lib/smartmemory-client.js +68 -1
- package/lib/stratum-mcp-client.js +104 -5
- package/lib/team-flag.js +1 -1
- package/lib/tool-inventory.js +0 -1
- package/lib/version-check.js +9 -3
- package/lib/wave-checkpoint.js +100 -0
- package/package.json +7 -5
- package/presets/team-fable-astra.profiles.json +18 -0
- package/presets/team-fable-astra.stratum.yaml +236 -0
- package/server/build-stream-bridge.js +43 -1
- package/server/cc-session-watcher.js +54 -5
- package/server/compose-mcp-tools.js +48 -50
- package/server/compose-mcp.js +0 -2
- package/server/design-routes.js +1 -1
- package/server/file-watcher.js +14 -0
- package/server/ideabox-routes.js +10 -0
- package/server/index.js +5 -1
- package/server/lifecycle-guard.js +13 -0
- package/server/maya-routes.js +111 -7
- package/server/mcp-tool-defs.js +0 -25
- package/server/mcp-tool-policy.js +6 -13
- package/server/model-tiers.js +14 -6
- package/server/stratum-client.js +61 -15
- package/server/supervisor.js +18 -4
- package/server/vision-routes.js +9 -3
- package/dist/assets/channel-SnZzzh7k.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
- package/dist/assets/clone-DgklGjHm.js +0 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
- package/lib/append-integrity.js +0 -81
- package/lib/canon-override.js +0 -196
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
|
*
|
|
@@ -286,8 +338,16 @@ export class ConsumerMergeDecisionError extends ConsumerArtifactError {
|
|
|
286
338
|
}
|
|
287
339
|
}
|
|
288
340
|
|
|
341
|
+
/** Cancellation must never enter the merge-repair/revise decision channel. */
|
|
342
|
+
export class MergeAfterCancelError extends ConsumerArtifactError {
|
|
343
|
+
constructor(message) {
|
|
344
|
+
super('MERGE_AFTER_CANCEL', message);
|
|
345
|
+
this.name = 'MergeAfterCancelError';
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
289
349
|
export class ConsumerFanoutArtifacts {
|
|
290
|
-
constructor({ runId, targetCwd, artifactRoot, hooks = {}, revisionDigest, specDigest }) {
|
|
350
|
+
constructor({ runId, targetCwd, artifactRoot, hooks = {}, revisionDigest, specDigest, profilesDigest }) {
|
|
291
351
|
this.runId = runId;
|
|
292
352
|
this.targetCwd = canonicalPath(targetCwd);
|
|
293
353
|
this.artifactRoot = canonicalPath(artifactRoot ?? defaultArtifactRoot(this.targetCwd));
|
|
@@ -303,6 +363,7 @@ export class ConsumerFanoutArtifacts {
|
|
|
303
363
|
// a crash between journal creation and the first bind cannot leave an
|
|
304
364
|
// unpinned journal that a drifted spec could later re-pin as the truth.
|
|
305
365
|
this._initialRevisionDigest = typeof revisionDigest === 'string' && revisionDigest.length > 0 ? revisionDigest : null;
|
|
366
|
+
this._initialProfilesDigest = profilesDigest;
|
|
306
367
|
this._initialSpecDigest = typeof specDigest === 'string' && specDigest.length > 0 ? specDigest : null;
|
|
307
368
|
this.runRoot = join(this.artifactRoot, runDirectoryName(runId));
|
|
308
369
|
this.journalPath = join(this.runRoot, 'journal.json');
|
|
@@ -411,6 +472,15 @@ export class ConsumerFanoutArtifacts {
|
|
|
411
472
|
);
|
|
412
473
|
}
|
|
413
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
|
+
}
|
|
414
484
|
return journal;
|
|
415
485
|
}
|
|
416
486
|
|
|
@@ -418,6 +488,7 @@ export class ConsumerFanoutArtifacts {
|
|
|
418
488
|
if (!existsSync(this.journalPath)) {
|
|
419
489
|
const journal = {
|
|
420
490
|
version: JOURNAL_VERSION,
|
|
491
|
+
...(this._initialProfilesDigest !== undefined ? { profilesDigest: this._initialProfilesDigest } : {}),
|
|
421
492
|
runId: this.runId,
|
|
422
493
|
targetCwd: this.targetCwd,
|
|
423
494
|
createdAt: now(),
|
|
@@ -444,7 +515,8 @@ export class ConsumerFanoutArtifacts {
|
|
|
444
515
|
const before = readFileSync(this.journalPath, 'utf8');
|
|
445
516
|
const journal = this.#reload();
|
|
446
517
|
if ((this._initialRevisionDigest && journal.revisionDigest !== JSON.parse(before).revisionDigest)
|
|
447
|
-
|| (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)) {
|
|
448
520
|
this.#writeGuarded(journal);
|
|
449
521
|
}
|
|
450
522
|
return journal;
|
|
@@ -531,7 +603,10 @@ export class ConsumerFanoutArtifacts {
|
|
|
531
603
|
itemIndex: descriptor.itemIndex,
|
|
532
604
|
generation: descriptor.generation,
|
|
533
605
|
path,
|
|
534
|
-
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']),
|
|
535
610
|
status: 'creating',
|
|
536
611
|
superseded: false,
|
|
537
612
|
createdAt: now(),
|
|
@@ -562,8 +637,15 @@ export class ConsumerFanoutArtifacts {
|
|
|
562
637
|
* First-write-wins; a later issuance carrying a DIFFERENT engine revision digest
|
|
563
638
|
* is a mid-run revision the journal must reject rather than silently absorb.
|
|
564
639
|
*/
|
|
565
|
-
bindRunRevision({ revisionDigest, specDigest }) {
|
|
640
|
+
bindRunRevision({ revisionDigest, specDigest, profilesDigest }) {
|
|
566
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
|
+
}
|
|
567
649
|
if (typeof revisionDigest === 'string' && revisionDigest.length > 0) {
|
|
568
650
|
if (this.journal.revisionDigest === null) {
|
|
569
651
|
this.journal.revisionDigest = revisionDigest;
|
|
@@ -582,6 +664,236 @@ export class ConsumerFanoutArtifacts {
|
|
|
582
664
|
});
|
|
583
665
|
}
|
|
584
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
|
+
|
|
585
897
|
/** Record which fanout step a merge gate settles, so resume never re-derives it
|
|
586
898
|
* from a mutable local spec that may have drifted. First-write-wins per gate. */
|
|
587
899
|
recordGateBinding({ gateStepId, fanoutStepId }) {
|
|
@@ -618,7 +930,7 @@ export class ConsumerFanoutArtifacts {
|
|
|
618
930
|
|| (scope.fanoutStepId === fanoutStepId && scope.itemIndex === itemIndex);
|
|
619
931
|
let changed = false;
|
|
620
932
|
for (const issuance of this.journal.issuances) {
|
|
621
|
-
if (issuance.state === 'merged' || issuance.state === 'superseded') continue;
|
|
933
|
+
if (issuance.state === 'merged' || issuance.state === 'superseded' || issuance.state === 'failed') continue;
|
|
622
934
|
if (!inScope(issuance.fanoutStepId, issuance.itemIndex)) continue;
|
|
623
935
|
const fanoutItems = audit?.steps?.[issuance.fanoutStepId]?.fanout?.items;
|
|
624
936
|
const item = Array.isArray(fanoutItems) ? fanoutItems[issuance.itemIndex] : undefined;
|
|
@@ -670,13 +982,17 @@ export class ConsumerFanoutArtifacts {
|
|
|
670
982
|
*/
|
|
671
983
|
reconcileDescriptor(descriptor, audit) {
|
|
672
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
|
+
}
|
|
673
989
|
// Per-item settlement: the descriptor's audit is a concurrent snapshot that
|
|
674
990
|
// may be stale for OTHER items, so reconcile only this item's own records.
|
|
675
991
|
this.#reconcileAuditInto(audit, { fanoutStepId: descriptor.step, itemIndex: descriptor.itemIndex });
|
|
676
992
|
const prepared = this.journal.issuances.find(
|
|
677
993
|
(entry) => entry.dispatchToken === descriptor.dispatchToken,
|
|
678
994
|
);
|
|
679
|
-
if (prepared?.state === 'prepared') {
|
|
995
|
+
if (prepared?.state === 'prepared' || prepared?.state === 'failed') {
|
|
680
996
|
return { action: 'report', envelope: structuredClone(prepared.envelope), issuance: prepared };
|
|
681
997
|
}
|
|
682
998
|
if (prepared?.state === 'accepted') {
|
|
@@ -739,14 +1055,26 @@ export class ConsumerFanoutArtifacts {
|
|
|
739
1055
|
});
|
|
740
1056
|
}
|
|
741
1057
|
|
|
742
|
-
prepareIssuance(descriptor, envelope, { finalStage }) {
|
|
1058
|
+
prepareIssuance(descriptor, envelope, { finalStage, itemBinding, resolvedProfile, ownership } = {}) {
|
|
743
1059
|
return this.#mutate(() => {
|
|
744
1060
|
const existing = this.journal.issuances.find(
|
|
745
1061
|
(entry) => entry.dispatchToken === descriptor.dispatchToken,
|
|
746
1062
|
);
|
|
747
1063
|
if (existing) return existing;
|
|
748
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) } : {}) };
|
|
749
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
|
+
}
|
|
750
1078
|
// Envelope-only journal entry: no worktree, no witness, no diff. It never
|
|
751
1079
|
// participates in the merge (prepareMerge filters isolation:none out), so
|
|
752
1080
|
// its files persist directly in the target cwd.
|
|
@@ -760,6 +1088,7 @@ export class ConsumerFanoutArtifacts {
|
|
|
760
1088
|
attempt: descriptor.attempt,
|
|
761
1089
|
revisionDigest: descriptor.revisionDigest,
|
|
762
1090
|
contractDigest: descriptor.contractDigest,
|
|
1091
|
+
...fields,
|
|
763
1092
|
isolation: 'none',
|
|
764
1093
|
worktreeKey: null,
|
|
765
1094
|
witnessTree: null,
|
|
@@ -796,6 +1125,8 @@ export class ConsumerFanoutArtifacts {
|
|
|
796
1125
|
attempt: descriptor.attempt,
|
|
797
1126
|
revisionDigest: descriptor.revisionDigest,
|
|
798
1127
|
contractDigest: descriptor.contractDigest,
|
|
1128
|
+
...fields,
|
|
1129
|
+
...(ownershipEnabled && finalStage ? { ownership: { baseCommit: worktree.baseCommit } } : {}),
|
|
799
1130
|
isolation: 'worktree',
|
|
800
1131
|
worktreeKey: worktree.key,
|
|
801
1132
|
witnessTree: witness.witnessTree,
|
|
@@ -806,11 +1137,39 @@ export class ConsumerFanoutArtifacts {
|
|
|
806
1137
|
diffDigest: diff === null ? null : sha256(diff),
|
|
807
1138
|
preparedAt: now(),
|
|
808
1139
|
};
|
|
1140
|
+
if (finalStage && ownershipEnabled) {
|
|
1141
|
+
const finding = checkOwnership(this.targetCwd, entry, { capture: true });
|
|
1142
|
+
if (finding) this.#failOwnership(entry, finding);
|
|
1143
|
+
}
|
|
809
1144
|
this.journal.issuances.push(entry);
|
|
810
1145
|
return entry;
|
|
811
1146
|
});
|
|
812
1147
|
}
|
|
813
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
|
+
|
|
814
1173
|
prepareArtifactFailure(descriptor, envelope, error) {
|
|
815
1174
|
return this.#mutate(() => {
|
|
816
1175
|
const existing = this.journal.issuances.find(
|
|
@@ -861,13 +1220,20 @@ export class ConsumerFanoutArtifacts {
|
|
|
861
1220
|
{ gateToken },
|
|
862
1221
|
);
|
|
863
1222
|
}
|
|
864
|
-
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
|
+
}
|
|
865
1230
|
|
|
866
1231
|
const accepted = this.acceptedEntriesFor(fanoutStepId);
|
|
867
1232
|
// Only isolation:worktree items own a diff and participate in the merge.
|
|
868
1233
|
// isolation:none items already wrote in the target cwd; they are accepted
|
|
869
1234
|
// evidence but owe no diff (a pure-none fanout yields zero ordered diffs).
|
|
870
1235
|
const worktreeAccepted = accepted.filter((entry) => entry.isolation !== 'none');
|
|
1236
|
+
for (const entry of worktreeAccepted) this.#verifyOwnership(entry);
|
|
871
1237
|
const baselineTree = snapshotWorkingTree(this.targetCwd);
|
|
872
1238
|
// A worker that changed NOTHING owes an empty diff, not a missing one, and it
|
|
873
1239
|
// must not join the merge: applying nothing leaves the tree identical, so its
|
|
@@ -888,6 +1254,7 @@ export class ConsumerFanoutArtifacts {
|
|
|
888
1254
|
itemIndex: entry.itemIndex,
|
|
889
1255
|
generation: entry.generation,
|
|
890
1256
|
digest: entry.diffDigest,
|
|
1257
|
+
...(entry.itemBinding ? { bindingDigest: digestJson(entry.itemBinding) } : {}),
|
|
891
1258
|
diff: entry.diff,
|
|
892
1259
|
}));
|
|
893
1260
|
const recordBlocked = (code, message, witnessChain = [baselineTree]) => {
|
|
@@ -965,6 +1332,12 @@ export class ConsumerFanoutArtifacts {
|
|
|
965
1332
|
gateToken,
|
|
966
1333
|
fanoutStepId,
|
|
967
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
|
+
} : {}),
|
|
968
1341
|
baselineTree,
|
|
969
1342
|
witnessChain,
|
|
970
1343
|
orderedDiffs,
|
|
@@ -1079,6 +1452,10 @@ export class ConsumerFanoutArtifacts {
|
|
|
1079
1452
|
// not a lockfile in this slice.
|
|
1080
1453
|
const live = this.#currentTransaction(gateToken);
|
|
1081
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
|
+
});
|
|
1082
1459
|
// Same merge algorithm as the witness precompute (temporary index,
|
|
1083
1460
|
// three-way), then the merged tree is checked out. Applying straight to
|
|
1084
1461
|
// the working tree cannot use --3way (it requires the real index to match).
|
|
@@ -1165,7 +1542,7 @@ export class ConsumerFanoutArtifacts {
|
|
|
1165
1542
|
});
|
|
1166
1543
|
}
|
|
1167
1544
|
|
|
1168
|
-
restoreMergeBaseline(transaction, audit) {
|
|
1545
|
+
restoreMergeBaseline(transaction, audit, opts = {}) {
|
|
1169
1546
|
if (!transaction) return undefined;
|
|
1170
1547
|
return this.#mutate(() => {
|
|
1171
1548
|
// Re-find against the fresh journal — the passed reference may predate a
|
|
@@ -1176,6 +1553,7 @@ export class ConsumerFanoutArtifacts {
|
|
|
1176
1553
|
restoreWorkingTree(this.targetCwd, tx.baselineTree);
|
|
1177
1554
|
tx.state = wasBlocked ? 'blocked' : 'rolled_back';
|
|
1178
1555
|
tx.rolledBackAt = now();
|
|
1556
|
+
tx.rollbackReason = opts.reason ?? null;
|
|
1179
1557
|
tx.recovery ??= { baselineRestores: 0 };
|
|
1180
1558
|
tx.recovery.baselineRestores = (tx.recovery.baselineRestores ?? 0) + 1;
|
|
1181
1559
|
tx.recovery.baselineVerifiedTree = tx.baselineTree;
|
|
@@ -1214,6 +1592,20 @@ export class ConsumerFanoutArtifacts {
|
|
|
1214
1592
|
});
|
|
1215
1593
|
}
|
|
1216
1594
|
|
|
1595
|
+
/** C40: a failed restore callback writes nothing. Persist its finding through
|
|
1596
|
+
* a separate guarded mutation, reloading the durable transaction first. */
|
|
1597
|
+
markRollbackFailed(transaction, error) {
|
|
1598
|
+
if (!transaction) return undefined;
|
|
1599
|
+
return this.#mutate(() => {
|
|
1600
|
+
const tx = this.journal.mergeTransactions.find((entry) => entry.gateToken === transaction.gateToken);
|
|
1601
|
+
if (!tx) return undefined;
|
|
1602
|
+
tx.state = 'rollback_failed';
|
|
1603
|
+
tx.failureCode = 'merge_revert_failed';
|
|
1604
|
+
tx.failure = error?.message ?? String(error);
|
|
1605
|
+
return tx;
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1217
1609
|
markGateResolved(transaction, outcome) {
|
|
1218
1610
|
if (!transaction) return undefined;
|
|
1219
1611
|
return this.#mutate(() => {
|
|
@@ -1238,10 +1630,18 @@ export class ConsumerFanoutArtifacts {
|
|
|
1238
1630
|
});
|
|
1239
1631
|
}
|
|
1240
1632
|
|
|
1241
|
-
cleanupWorktrees(reason) {
|
|
1633
|
+
cleanupWorktrees(reason, { dispatchTokens } = {}) {
|
|
1242
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));
|
|
1243
1643
|
for (const record of this.journal.worktrees) {
|
|
1244
|
-
if (record.cleanedAt) continue;
|
|
1644
|
+
if (record.cleanedAt || (selected || this.journal.wave) && !worktreeKeys.has(record.key)) continue;
|
|
1245
1645
|
if (existsSync(record.path)) {
|
|
1246
1646
|
try {
|
|
1247
1647
|
git(this.targetCwd, ['worktree', 'remove', '--force', record.path], { timeout: 60_000 });
|
|
@@ -1254,16 +1654,20 @@ export class ConsumerFanoutArtifacts {
|
|
|
1254
1654
|
record.cleanupReason = reason;
|
|
1255
1655
|
}
|
|
1256
1656
|
for (const issuance of this.journal.issuances) {
|
|
1257
|
-
if (typeof issuance.diff === 'string') {
|
|
1657
|
+
if (eligible(issuance.dispatchToken) && typeof issuance.diff === 'string') {
|
|
1258
1658
|
issuance.diff = null;
|
|
1259
1659
|
issuance.diffDroppedAt = now();
|
|
1260
1660
|
}
|
|
1261
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
|
+
}
|
|
1262
1666
|
for (const transaction of this.journal.mergeTransactions) {
|
|
1263
1667
|
for (const ordered of transaction.orderedDiffs ?? []) {
|
|
1264
|
-
if (typeof ordered.diff === 'string') ordered.diff = null;
|
|
1668
|
+
if (eligible(ordered.dispatchToken) && typeof ordered.diff === 'string') ordered.diff = null;
|
|
1265
1669
|
}
|
|
1266
|
-
if (transaction.orderedDiffs?.length > 0 && !transaction.diffPayloadsDroppedAt) {
|
|
1670
|
+
if (transaction.orderedDiffs?.length > 0 && transaction.orderedDiffs.every(d => d.diff === null) && !transaction.diffPayloadsDroppedAt) {
|
|
1267
1671
|
transaction.diffPayloadsDroppedAt = now();
|
|
1268
1672
|
}
|
|
1269
1673
|
}
|
|
@@ -1297,10 +1701,15 @@ export function verifyConsumerRunRevision({
|
|
|
1297
1701
|
artifactRoot,
|
|
1298
1702
|
specDigest,
|
|
1299
1703
|
resumeRevisionDigest,
|
|
1704
|
+
profilesDigest,
|
|
1300
1705
|
}) {
|
|
1301
1706
|
if (!runId || !existsSync(journalLocation({ runId, targetCwd, artifactRoot }))) return null;
|
|
1302
1707
|
const { journal } = new ConsumerFanoutArtifacts({ runId, targetCwd, artifactRoot });
|
|
1303
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
|
+
}
|
|
1304
1713
|
const fullyPinned = Boolean(journal.revisionDigest) && Boolean(journal.specDigest);
|
|
1305
1714
|
if (!fullyPinned) {
|
|
1306
1715
|
const hasWork = journal.issuances.length > 0
|
|
@@ -1382,13 +1791,14 @@ export function recoverAdvancedConsumerArtifacts({
|
|
|
1382
1791
|
const matchingGateEvents = gateEvents.filter(
|
|
1383
1792
|
(event) => event.stepId === transaction.gateStepId,
|
|
1384
1793
|
);
|
|
1385
|
-
const eventOffset = gateEventOffsets.get(transaction.gateStepId) ?? 0;
|
|
1794
|
+
const eventOffset = transaction.gateOrdinal ?? gateEventOffsets.get(transaction.gateStepId) ?? 0;
|
|
1386
1795
|
const resolvedEvent = matchingGateEvents[eventOffset];
|
|
1387
1796
|
gateEventOffsets.set(transaction.gateStepId, eventOffset + 1);
|
|
1388
1797
|
const resolvedOutcome = transaction.gateOutcome ?? resolvedEvent?.detail?.decision;
|
|
1389
1798
|
const approvedComplete = transaction.state === 'complete' && resolvedOutcome === 'approve';
|
|
1390
1799
|
|
|
1391
|
-
if (approvedComplete && gateState?.status !== 'waiting_gate') {
|
|
1800
|
+
if (approvedComplete && (artifacts.journal.wave || gateState?.status !== 'waiting_gate')) {
|
|
1801
|
+
if (artifacts.journal.wave) artifacts.recoverCheckpoint(transaction);
|
|
1392
1802
|
artifacts.markGateAdvanced({ gateToken: transaction.gateToken, outcome: resolvedOutcome, at: resolvedEvent?.at });
|
|
1393
1803
|
recoveredDecision = true;
|
|
1394
1804
|
approvedAdvanced = true;
|