@@ -47,6 +47,46 @@ import type { OutcomeCallbacks } from "./outcome-handler.js";
4747import { enforcePostprocess } from "./pre-post-enforce.js" ;
4848import type { PrePostEnforceOptions } from "./pre-post-enforce.js" ;
4949
50+ /**
51+ * Generate a stable stream id. Every enforce() call for a single LLM
52+ * stream carries the same id in metadata so the dashboard can collapse
53+ * N per-chunk rows into one logical "operation" for reviewers. Zero-dep
54+ * UUIDv4 fallback mirrors the one in supply-chain-cyclonedx.ts so the
55+ * SDK's "no runtime deps" rule stays intact.
56+ */
57+ function generateStreamId ( ) : string {
58+ if ( typeof globalThis . crypto !== "undefined" && globalThis . crypto . randomUUID ) {
59+ return `str_${ globalThis . crypto . randomUUID ( ) } ` ;
60+ }
61+ const bytes = new Uint8Array ( 16 ) ;
62+ if ( typeof globalThis . crypto !== "undefined" && globalThis . crypto . getRandomValues ) {
63+ globalThis . crypto . getRandomValues ( bytes ) ;
64+ } else {
65+ for ( let i = 0 ; i < 16 ; i ++ ) bytes [ i ] = Math . floor ( Math . random ( ) * 256 ) ;
66+ }
67+ const hex = Array . from ( bytes , ( b ) => b . toString ( 16 ) . padStart ( 2 , "0" ) ) . join ( "" ) ;
68+ return `str_${ hex } ` ;
69+ }
70+
71+ /**
72+ * Merge a streamId + slice index into the options.metadata for each
73+ * per-chunk enforce call. Preserves any caller-supplied metadata.
74+ */
75+ function withStreamMeta < O extends PrePostEnforceOptions > (
76+ options : O ,
77+ streamId : string ,
78+ sliceIndex : number ,
79+ ) : O {
80+ return {
81+ ...options ,
82+ metadata : {
83+ ...( options . metadata ?? { } ) ,
84+ streamId,
85+ streamSlice : sliceIndex ,
86+ } ,
87+ } ;
88+ }
89+
5090// ─── Types ────────────────────────────────────────────────────
5191
5292export type StreamMode = "buffered" | "sliding" | "per-chunk" ;
@@ -88,16 +128,21 @@ export async function* enforcePostprocessStream<ChunkT>(
88128 options : StreamEnforceOptions < ChunkT > ,
89129) : AsyncIterable < ChunkT > {
90130 const mode : StreamMode = options . streamMode ?? "buffered" ;
131+ // One id per stream — every enforce call this helper triggers carries
132+ // it in metadata so the cloud dashboard can collapse repeated rows
133+ // into a single logical operation. Buffered mode also tags its single
134+ // call so buffered-vs-per-chunk audit shape is consistent.
135+ const streamId = generateStreamId ( ) ;
91136
92137 if ( mode === "buffered" ) {
93- yield * runBuffered ( governance , source , options ) ;
138+ yield * runBuffered ( governance , source , options , streamId ) ;
94139 return ;
95140 }
96141 if ( mode === "per-chunk" ) {
97- yield * runPerChunk ( governance , source , options ) ;
142+ yield * runPerChunk ( governance , source , options , streamId ) ;
98143 return ;
99144 }
100- yield * runSliding ( governance , source , options ) ;
145+ yield * runSliding ( governance , source , options , streamId ) ;
101146}
102147
103148// ─── Buffered mode ────────────────────────────────────────────
@@ -106,6 +151,7 @@ async function* runBuffered<ChunkT>(
106151 governance : GovernanceInstance ,
107152 source : AsyncIterable < ChunkT > ,
108153 options : StreamEnforceOptions < ChunkT > ,
154+ streamId : string ,
109155) : AsyncIterable < ChunkT > {
110156 const chunks : ChunkT [ ] = [ ] ;
111157 const texts : string [ ] = [ ] ;
@@ -121,7 +167,7 @@ async function* runBuffered<ChunkT>(
121167 }
122168
123169 const result = await enforcePostprocess ( governance , combined , {
124- ...options ,
170+ ...withStreamMeta ( options , streamId , 0 ) ,
125171 toolName : options . toolName ?? "stream:buffered" ,
126172 } ) ;
127173
@@ -147,7 +193,9 @@ async function* runPerChunk<ChunkT>(
147193 governance : GovernanceInstance ,
148194 source : AsyncIterable < ChunkT > ,
149195 options : StreamEnforceOptions < ChunkT > ,
196+ streamId : string ,
150197) : AsyncIterable < ChunkT > {
198+ let sliceIndex = 0 ;
151199 for await ( const chunk of source ) {
152200 const text = options . extractText ( chunk ) ;
153201 if ( ! text ) {
@@ -156,7 +204,7 @@ async function* runPerChunk<ChunkT>(
156204 }
157205
158206 const result = await enforcePostprocess ( governance , text , {
159- ...options ,
207+ ...withStreamMeta ( options , streamId , sliceIndex ++ ) ,
160208 toolName : options . toolName ?? "stream:per-chunk" ,
161209 } ) ;
162210
@@ -176,18 +224,20 @@ async function* runSliding<ChunkT>(
176224 governance : GovernanceInstance ,
177225 source : AsyncIterable < ChunkT > ,
178226 options : StreamEnforceOptions < ChunkT > ,
227+ streamId : string ,
179228) : AsyncIterable < ChunkT > {
180229 const window : ChunkT [ ] = [ ] ;
181230 const windowTexts : string [ ] = [ ] ;
182231 const lookbackChunks = options . streamLookbackChunks ?? 2 ;
183232 const lookbackChars = options . streamLookbackChars ;
233+ const sliceCounter = { value : 0 } ;
184234
185235 for await ( const chunk of source ) {
186236 window . push ( chunk ) ;
187237 windowTexts . push ( options . extractText ( chunk ) ) ;
188238
189239 while ( shouldFlush ( window . length , windowTexts , lookbackChunks , lookbackChars ) ) {
190- yield * flushOldest ( governance , window , windowTexts , options ) ;
240+ yield * flushOldest ( governance , window , windowTexts , options , streamId , sliceCounter ) ;
191241 }
192242 }
193243
@@ -200,7 +250,7 @@ async function* runSliding<ChunkT>(
200250 return ;
201251 }
202252 const result = await enforcePostprocess ( governance , tailText , {
203- ...options ,
253+ ...withStreamMeta ( options , streamId , sliceCounter . value ++ ) ,
204254 toolName : options . toolName ?? "stream:sliding-tail" ,
205255 } ) ;
206256 if ( result . text === tailText ) {
@@ -234,6 +284,8 @@ async function* flushOldest<ChunkT>(
234284 window : ChunkT [ ] ,
235285 windowTexts : string [ ] ,
236286 options : StreamEnforceOptions < ChunkT > ,
287+ streamId : string ,
288+ sliceCounter : { value : number } ,
237289) : AsyncIterable < ChunkT > {
238290 // Scan the full lookback window (oldest + lookback tail) before flushing
239291 // the oldest chunk. This gives the scanner context straddling boundaries.
@@ -246,7 +298,7 @@ async function* flushOldest<ChunkT>(
246298 }
247299
248300 const result = await enforcePostprocess ( governance , scanText , {
249- ...options ,
301+ ...withStreamMeta ( options , streamId , sliceCounter . value ++ ) ,
250302 toolName : options . toolName ?? "stream:sliding" ,
251303 } ) ;
252304
0 commit comments