99 validateContextReadPlan
1010} = require ( './context-read-contract.cjs' )
1111const { readContextPlanObservation } = require ( './context-plan-observation.cjs' )
12+ const { createRuntimeStateStore } = require ( './runtime-state-store.cjs' )
13+
14+ const CONTEXT_SOURCE_LEDGER_SCHEMA = 'ContextSourceObservationLedgerV1'
15+ const CONTEXT_SOURCE_LEDGER_SLOT_COUNT = 128
16+ const CONTEXT_SOURCE_LEDGER_MAX_BYTES = 512 * 1024
17+ const CONTEXT_SOURCE_LEDGER_MAX_OBSERVATIONS = 128
1218
1319const CONTEXT_BINDING_FIELDS = new Set ( [
1420 'schemaVersion' ,
@@ -41,6 +47,49 @@ function lifecycleStatePath({ activeRoot, project, workspaceNamespace }) {
4147 )
4248}
4349
50+ function contextSourceLedgerRelativePath ( contextEpoch ) {
51+ const digest = stableDigest ( String ( contextEpoch || '' ) . trim ( ) )
52+ const slot = Number . parseInt ( digest . slice ( 0 , 8 ) , 16 ) % CONTEXT_SOURCE_LEDGER_SLOT_COUNT
53+ return path . join (
54+ 'context-source-observations' ,
55+ 'v1' ,
56+ `slot-${ String ( slot ) . padStart ( 3 , '0' ) } .json`
57+ )
58+ }
59+
60+ function contextSourceLedgerStore ( target , contextEpoch ) {
61+ return createRuntimeStateStore ( {
62+ activeRoot : target . activeRoot ,
63+ project : target . project ,
64+ relativePath : contextSourceLedgerRelativePath ( contextEpoch ) ,
65+ maxBytes : CONTEXT_SOURCE_LEDGER_MAX_BYTES ,
66+ lockWaitMs : 2000 ,
67+ maxWrites : 0
68+ } )
69+ }
70+
71+ function ledgerIdentity ( binding , target ) {
72+ return {
73+ contextEpoch : binding . contextEpoch ,
74+ planId : binding . planId ,
75+ planContentId : binding . planContentId ,
76+ activeRoot : portableRoot ( target . activeRoot ) ,
77+ project : target . project
78+ }
79+ }
80+
81+ function ledgerIdentityMatches ( value , binding , target ) {
82+ const identity = value ?. identity
83+ return ! ! (
84+ identity &&
85+ identity . contextEpoch === binding . contextEpoch &&
86+ identity . planId === binding . planId &&
87+ identity . planContentId === binding . planContentId &&
88+ comparableRoot ( identity . activeRoot ) === comparableRoot ( target . activeRoot ) &&
89+ identity . project === target . project
90+ )
91+ }
92+
4493function looksLikeWorkspaceNamespaceActiveRoot ( activeRoot , project ) {
4594 const resolved = path . resolve ( String ( activeRoot || '' ) )
4695 const projectName = String ( project || '' ) . trim ( )
@@ -202,7 +251,25 @@ function boundedNumber(value) {
202251 return Number . isFinite ( number ) && number >= 0 ? number : null
203252}
204253
205- function normalizeSourceResult ( raw , plan , binding , target , hostSessionId ) {
254+ function selectedSourceRefsStillMatch ( selected , fsImpl = fs ) {
255+ if ( selected ?. kind === 'memory' ) return true
256+ const refs = Array . isArray ( selected ?. sourceRefs ) ? selected . sourceRefs : [ ]
257+ if ( ! refs . length ) return false
258+ return refs . every ( ref => {
259+ let stat = null
260+ try { stat = fsImpl . statSync ( ref . path ) } catch { }
261+ const actual = {
262+ path : portableRoot ( ref . path ) ,
263+ layer : String ( ref . layer || '' ) ,
264+ exists : ! ! stat ?. isFile ( ) ,
265+ size : stat ?. isFile ( ) ? stat . size : null ,
266+ mtimeMs : stat ?. isFile ( ) ? stat . mtimeMs : null
267+ }
268+ return stableDigest ( actual ) === ref . metadataDigest
269+ } )
270+ }
271+
272+ function normalizeSourceResult ( raw , plan , binding , target , hostSessionId , fsImpl = fs ) {
206273 if ( ! raw || typeof raw !== 'object' || Array . isArray ( raw ) ) return null
207274 const sourceId = String ( raw . sourceId || '' ) . trim ( )
208275 const selected = plan . selectedSources . find ( source => source . sourceId === sourceId )
@@ -217,8 +284,16 @@ function normalizeSourceResult(raw, plan, binding, target, hostSessionId) {
217284 bytes : raw . bytes ?? null ,
218285 chars : raw . chars ?? null
219286 } ) )
287+ const effectiveHostSessionId = Object . prototype . hasOwnProperty . call ( raw , 'hostSessionId' )
288+ ? String ( raw . hostSessionId || '' )
289+ : String ( hostSessionId || '' )
220290 return {
221- observationId : String ( raw . observationId || `mcp-${ stableDigest ( { sourceId, resultDigest, contextEpoch : binding . contextEpoch } ) . slice ( 0 , 20 ) } ` ) ,
291+ observationId : String ( raw . observationId || `mcp-${ stableDigest ( {
292+ sourceId,
293+ resultDigest,
294+ contextEpoch : binding . contextEpoch ,
295+ hostSessionId : effectiveHostSessionId
296+ } ) . slice ( 0 , 20 ) } `) ,
222297 toolCallId : String ( raw . toolCallId || 'mcp-direct' ) ,
223298 sourceId,
224299 contextEpoch : binding . contextEpoch ,
@@ -229,20 +304,101 @@ function normalizeSourceResult(raw, plan, binding, target, hostSessionId) {
229304 successful,
230305 observable : raw . observable !== false ,
231306 transportSuccess : raw . transportSuccess !== false ,
232- sourceRefsMatch : raw . sourceRefsMatch === true ,
307+ sourceRefsMatch : raw . sourceRefsMatch === true && selectedSourceRefsStillMatch ( selected , fsImpl ) ,
233308 schemaMatch : raw . schemaMatch !== false ,
234309 targetMatch : raw . targetMatch !== false ,
235310 resultDigest,
236311 contentIdentity : raw . contentIdentity || null ,
237312 bodyObserved,
238- hostSessionId : String ( raw . hostSessionId || hostSessionId || '' ) ,
313+ hostSessionId : effectiveHostSessionId ,
239314 bytes : boundedNumber ( raw . bytes ) ,
240315 chars : boundedNumber ( raw . chars ) ,
241316 hostDeliveredBytes : boundedNumber ( raw . hostDeliveredBytes ) ,
242317 cache : raw . cache === true
243318 }
244319}
245320
321+ function persistContextSourceLedger ( target , binding , sourceResults , options = { } ) {
322+ const store = contextSourceLedgerStore ( target , binding . contextEpoch )
323+ const write = updateJsonLocked ( store . filePath , current => {
324+ const reusable = current ?. schemaVersion === CONTEXT_SOURCE_LEDGER_SCHEMA &&
325+ ledgerIdentityMatches ( current , binding , target )
326+ const observations = reusable && Array . isArray ( current . observations )
327+ ? current . observations . filter ( item => item && typeof item === 'object' && ! Array . isArray ( item ) )
328+ : [ ]
329+ const byId = new Map ( observations . map ( item => [ String ( item . observationId || '' ) , item ] ) )
330+ for ( const result of sourceResults ) {
331+ const observationId = String ( result . observationId || '' )
332+ const prior = observationId ? byId . get ( observationId ) : null
333+ if ( prior && stableDigest ( prior ) === stableDigest ( result ) ) continue
334+ if ( observationId ) byId . set ( observationId , result )
335+ }
336+ return {
337+ schemaVersion : CONTEXT_SOURCE_LEDGER_SCHEMA ,
338+ identity : ledgerIdentity ( binding , target ) ,
339+ observations : [ ...byId . values ( ) ] . slice ( - CONTEXT_SOURCE_LEDGER_MAX_OBSERVATIONS ) ,
340+ updatedAt : new Date ( options . nowMs || Date . now ( ) ) . toISOString ( )
341+ }
342+ } , options )
343+ return { ...write , filePath : store . filePath }
344+ }
345+
346+ function readMcpContextSourceObservations ( input = { } , options = { } ) {
347+ const activeRoot = portableRoot ( input . activeRoot )
348+ const project = String ( input . project || '' ) . trim ( )
349+ const target = { activeRoot, project }
350+ if ( ! activeRoot || ! project ) return { status : 'skipped' , reasonCode : 'target-incomplete' , sourceResults : [ ] }
351+ const binding = normalizeVerifiedBinding ( input . contextBinding , target )
352+ if ( ! binding ) return { status : 'skipped' , reasonCode : 'binding-unverified' , sourceResults : [ ] }
353+ const plan = input . plan
354+ const validation = validateContextReadPlan ( plan )
355+ if ( ! validation . valid || plan . planId !== binding . planId || plan . planContentId !== binding . planContentId ) {
356+ return { status : 'skipped' , reasonCode : 'plan-binding-mismatch' , sourceResults : [ ] }
357+ }
358+ const store = contextSourceLedgerStore ( target , binding . contextEpoch )
359+ const read = store . read ( )
360+ if ( read . status !== 'fresh' ) {
361+ return { status : read . status , reasonCode : read . errorCode || 'ledger-unavailable' , sourceResults : [ ] , filePath : store . filePath }
362+ }
363+ const ledger = read . value
364+ if ( ledger ?. schemaVersion !== CONTEXT_SOURCE_LEDGER_SCHEMA || ! ledgerIdentityMatches ( ledger , binding , target ) ) {
365+ return { status : 'stale' , reasonCode : 'ledger-identity-mismatch' , sourceResults : [ ] , filePath : store . filePath }
366+ }
367+ const sourceResults = ( Array . isArray ( ledger . observations ) ? ledger . observations : [ ] )
368+ . map ( item => normalizeSourceResult ( item , plan , binding , target , input . hostSessionId , options . fs || fs ) )
369+ . filter ( Boolean )
370+ return {
371+ status : 'fresh' ,
372+ sourceResults,
373+ filePath : store . filePath ,
374+ observationCount : sourceResults . length
375+ }
376+ }
377+
378+ function replayMcpContextSourceObservations ( receipt , plan , input = { } , options = { } ) {
379+ if ( ! receipt || [ 'stale' , 'blocked' ] . includes ( receipt . status ) ) {
380+ return { status : 'skipped' , reasonCode : `receipt-${ receipt ?. status || 'missing' } ` , receipt }
381+ }
382+ const durable = readMcpContextSourceObservations ( { ...input , plan } , options )
383+ if ( durable . status !== 'fresh' || ! durable . sourceResults . length ) {
384+ return { ...durable , receipt }
385+ }
386+ let nextReceipt = receipt
387+ for ( const result of durable . sourceResults ) {
388+ nextReceipt = recordContextReadOutcome ( nextReceipt , plan , result , {
389+ hostSessionId : input . hostSessionId ,
390+ nowMs : options . nowMs
391+ } )
392+ }
393+ return {
394+ status : 'replayed' ,
395+ receipt : nextReceipt ,
396+ sourceResults : durable . sourceResults ,
397+ filePath : durable . filePath ,
398+ observationCount : durable . observationCount
399+ }
400+ }
401+
246402function recordMcpContextSourceObservations ( input = { } , options = { } ) {
247403 const activeRoot = portableRoot ( input . activeRoot )
248404 const project = String ( input . project || '' ) . trim ( )
@@ -273,10 +429,12 @@ function recordMcpContextSourceObservations(input = {}, options = {}) {
273429 return { status : 'skipped' , reasonCode : 'plan-observation-invalid' , errors : validation . errors }
274430 }
275431 const sourceResults = ( Array . isArray ( input . sourceResults ) ? input . sourceResults : [ ] )
276- . map ( item => normalizeSourceResult ( item , observed . plan , binding , target , input . hostSessionId ) )
432+ . map ( item => normalizeSourceResult ( item , observed . plan , binding , target , input . hostSessionId , options . fs || fs ) )
277433 . filter ( Boolean )
278434 if ( ! sourceResults . length ) return { status : 'skipped' , reasonCode : 'source-results-empty' }
279435
436+ const ledgerWrite = persistContextSourceLedger ( target , binding , sourceResults , options )
437+
280438 const statePath = lifecycleStatePath ( target )
281439 const write = updateJsonLocked ( statePath , lifecycle => {
282440 const acquisition = installObservedPlan ( lifecycle , observed . plan , binding , target , input . hostSessionId )
@@ -294,16 +452,25 @@ function recordMcpContextSourceObservations(input = {}, options = {}) {
294452
295453 const refreshed = readJson ( statePath , options . fs || fs )
296454 const receipt = refreshed . contextAcquisition ?. receipt || null
455+ const durable = ledgerWrite . status === 'persisted'
297456 return {
298- status : 'persisted' ,
457+ status : durable ? 'persisted' : 'degraded' ,
458+ ...( durable ? { } : { errorCode : ledgerWrite . errorCode || 'CONTEXT_SOURCE_LEDGER_NOT_PERSISTED' } ) ,
299459 statePath,
460+ ledgerPath : ledgerWrite . filePath ,
461+ ledgerStatus : ledgerWrite . status ,
462+ lifecycleStatus : 'persisted' ,
300463 satisfiedSourceIds : Array . isArray ( receipt ?. satisfiedSourceIds ) ? receipt . satisfiedSourceIds : [ ] ,
301464 missingSourceIds : Array . isArray ( receipt ?. missingSourceIds ) ? receipt . missingSourceIds : [ ] ,
302465 receiptStatus : receipt ?. status || 'unknown'
303466 }
304467}
305468
306469module . exports = {
470+ CONTEXT_SOURCE_LEDGER_SCHEMA ,
471+ contextSourceLedgerRelativePath,
307472 lifecycleStatePath,
473+ readMcpContextSourceObservations,
474+ replayMcpContextSourceObservations,
308475 recordMcpContextSourceObservations
309476}
0 commit comments