@fluxpointstudios/orynq-sdk-process-trace 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/dist/index.cjs +574 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +573 -9
- package/dist/index.d.ts +573 -9
- package/dist/index.js +562 -9
- package/dist/index.js.map +1 -1
- package/package.json +17 -3
- package/src/__tests__/bundle.test.ts +942 -860
- package/src/__tests__/governance-round4.test.ts +149 -0
- package/src/__tests__/governance.test.ts +382 -0
- package/src/__tests__/hardening-round2.test.ts +173 -0
- package/src/__tests__/hardening-round3.test.ts +208 -0
- package/src/__tests__/integration.test.ts +611 -611
- package/src/__tests__/merkle.test.ts +756 -756
- package/src/__tests__/model-manifest.test.ts +136 -0
- package/src/__tests__/rolling-hash.test.ts +622 -622
- package/src/__tests__/trace-builder.test.ts +1012 -1012
- package/src/__tests__/types.test.ts +420 -414
- package/src/bundle.ts +1004 -810
- package/src/disclosure.ts +527 -527
- package/src/governance.ts +711 -0
- package/src/index.ts +334 -265
- package/src/manifest.ts +687 -687
- package/src/merkle.ts +428 -428
- package/src/model-manifest.ts +0 -0
- package/src/rolling-hash.ts +375 -366
- package/src/trace-builder.ts +791 -725
- package/src/types.ts +713 -522
package/src/manifest.ts
CHANGED
|
@@ -1,687 +1,687 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Manifest creation and verification for off-chain trace storage.
|
|
3
|
-
*
|
|
4
|
-
* Location: packages/process-trace/src/manifest.ts
|
|
5
|
-
*
|
|
6
|
-
* This module provides functionality for creating manifests and chunks from trace bundles,
|
|
7
|
-
* enabling efficient off-chain storage of trace data. The manifest serves as a public-safe
|
|
8
|
-
* entry point for trace retrieval, while chunks contain the actual span and event data.
|
|
9
|
-
*
|
|
10
|
-
* Key features:
|
|
11
|
-
* - Creates manifests with cryptographic commitments for integrity verification
|
|
12
|
-
* - Chunks trace data by size for efficient storage and retrieval
|
|
13
|
-
* - Provides verification functions to ensure manifest and chunk integrity
|
|
14
|
-
* - Supports reconstruction of trace bundles from manifests and chunks
|
|
15
|
-
*
|
|
16
|
-
* Storage Layout (for consumers):
|
|
17
|
-
* ```
|
|
18
|
-
* <storageUri>/
|
|
19
|
-
* manifest.json # TraceManifest (public-safe)
|
|
20
|
-
* chunks/
|
|
21
|
-
* <hash1>.json # Or .json.gz if compressed by consumer
|
|
22
|
-
* <hash2>.json
|
|
23
|
-
* ```
|
|
24
|
-
*
|
|
25
|
-
* Used by:
|
|
26
|
-
* - Storage adapters for persisting trace data
|
|
27
|
-
* - Retrieval workflows for reconstructing traces
|
|
28
|
-
* - Verification workflows for validating stored traces
|
|
29
|
-
*
|
|
30
|
-
* @example
|
|
31
|
-
* ```typescript
|
|
32
|
-
* // Create manifest for storage
|
|
33
|
-
* const { manifest, chunks } = await createManifest(bundle, { chunkSize: 500_000 });
|
|
34
|
-
*
|
|
35
|
-
* // Store chunks (consumer handles actual storage)
|
|
36
|
-
* for (const chunk of chunks) {
|
|
37
|
-
* const path = getChunkPath(chunk.info);
|
|
38
|
-
* await storage.write(path, chunk.content);
|
|
39
|
-
* }
|
|
40
|
-
*
|
|
41
|
-
* // Store manifest
|
|
42
|
-
* await storage.write("manifest.json", JSON.stringify(manifest));
|
|
43
|
-
*
|
|
44
|
-
* // Later: verify
|
|
45
|
-
* const result = await verifyManifest(manifest, loadedChunks);
|
|
46
|
-
* if (!result.valid) {
|
|
47
|
-
* console.error("Manifest verification failed:", result.errors);
|
|
48
|
-
* }
|
|
49
|
-
* ```
|
|
50
|
-
*/
|
|
51
|
-
|
|
52
|
-
import { sha256StringHex, canonicalize } from "@fluxpointstudios/orynq-sdk-core/utils";
|
|
53
|
-
import type {
|
|
54
|
-
TraceManifest,
|
|
55
|
-
TraceBundle,
|
|
56
|
-
ChunkInfo,
|
|
57
|
-
Chunk,
|
|
58
|
-
CreateManifestOptions,
|
|
59
|
-
ManifestVerificationResult,
|
|
60
|
-
TraceSpan,
|
|
61
|
-
TraceEvent,
|
|
62
|
-
TraceRun,
|
|
63
|
-
} from "./types.js";
|
|
64
|
-
import { HASH_DOMAIN_PREFIXES } from "./types.js";
|
|
65
|
-
|
|
66
|
-
// =============================================================================
|
|
67
|
-
// CONSTANTS
|
|
68
|
-
// =============================================================================
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Default chunk size in bytes (1MB of uncompressed JSON).
|
|
72
|
-
* This is the target size for chunking; actual chunks may be slightly larger
|
|
73
|
-
* to avoid splitting spans across chunks.
|
|
74
|
-
*/
|
|
75
|
-
const DEFAULT_CHUNK_SIZE = 1_000_000;
|
|
76
|
-
|
|
77
|
-
// =============================================================================
|
|
78
|
-
// CHUNK CONTENT TYPE
|
|
79
|
-
// =============================================================================
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Content format for a chunk.
|
|
83
|
-
* Contains spans and their associated events.
|
|
84
|
-
*/
|
|
85
|
-
interface ChunkContent {
|
|
86
|
-
spans: TraceSpan[];
|
|
87
|
-
events: TraceEvent[];
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// =============================================================================
|
|
91
|
-
// MANIFEST CREATION
|
|
92
|
-
// =============================================================================
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Create a manifest and chunks from a trace bundle.
|
|
96
|
-
*
|
|
97
|
-
* Chunks contain private span/event data for off-chain storage. Each chunk
|
|
98
|
-
* includes a subset of spans and their associated events, grouped to fit
|
|
99
|
-
* within the target chunk size.
|
|
100
|
-
*
|
|
101
|
-
* The manifest contains:
|
|
102
|
-
* - Metadata from the bundle (runId, agentId, timestamps, etc.)
|
|
103
|
-
* - Cryptographic commitments (rootHash, merkleRoot)
|
|
104
|
-
* - Chunk information (index, hash, size, spanIds)
|
|
105
|
-
* - The complete publicView for quick access
|
|
106
|
-
*
|
|
107
|
-
* @param bundle - Finalized trace bundle to create manifest from
|
|
108
|
-
* @param options - Optional chunking options
|
|
109
|
-
* @param options.chunkSize - Target chunk size in bytes (default: 1MB)
|
|
110
|
-
* @param options.compression - Compression hint for consumers (default: "none")
|
|
111
|
-
* @returns Promise resolving to manifest and array of chunks
|
|
112
|
-
*
|
|
113
|
-
* @example
|
|
114
|
-
* ```typescript
|
|
115
|
-
* const { manifest, chunks } = await createManifest(bundle, {
|
|
116
|
-
* chunkSize: 500_000, // 500KB chunks
|
|
117
|
-
* });
|
|
118
|
-
*
|
|
119
|
-
* console.log(`Created ${chunks.length} chunks`);
|
|
120
|
-
* console.log(`Manifest hash: ${manifest.manifestHash}`);
|
|
121
|
-
* ```
|
|
122
|
-
*/
|
|
123
|
-
export async function createManifest(
|
|
124
|
-
bundle: TraceBundle,
|
|
125
|
-
options?: CreateManifestOptions
|
|
126
|
-
): Promise<{ manifest: TraceManifest; chunks: Chunk[] }> {
|
|
127
|
-
const chunkSize = options?.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
|
128
|
-
const compression = options?.compression ?? "none";
|
|
129
|
-
|
|
130
|
-
const run = bundle.privateRun;
|
|
131
|
-
|
|
132
|
-
// Build event lookup map for efficient access
|
|
133
|
-
const eventMap = new Map<string, TraceEvent>();
|
|
134
|
-
for (const event of run.events) {
|
|
135
|
-
eventMap.set(event.id, event);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Group spans into chunks based on size
|
|
139
|
-
const chunks: Chunk[] = [];
|
|
140
|
-
let currentChunkSpans: TraceSpan[] = [];
|
|
141
|
-
let currentChunkEvents: TraceEvent[] = [];
|
|
142
|
-
let currentChunkSize = 0;
|
|
143
|
-
let chunkIndex = 0;
|
|
144
|
-
|
|
145
|
-
// Sort spans by spanSeq for deterministic ordering
|
|
146
|
-
const sortedSpans = [...run.spans].sort((a, b) => a.spanSeq - b.spanSeq);
|
|
147
|
-
|
|
148
|
-
for (const span of sortedSpans) {
|
|
149
|
-
// Get events for this span
|
|
150
|
-
const spanEvents = span.eventIds
|
|
151
|
-
.map((id) => eventMap.get(id))
|
|
152
|
-
.filter((e): e is TraceEvent => e !== undefined)
|
|
153
|
-
.sort((a, b) => a.seq - b.seq);
|
|
154
|
-
|
|
155
|
-
// Estimate size of this span and its events
|
|
156
|
-
const spanJson = JSON.stringify(span);
|
|
157
|
-
const eventsJson = spanEvents.map((e) => JSON.stringify(e)).join("");
|
|
158
|
-
const spanSize = spanJson.length + eventsJson.length;
|
|
159
|
-
|
|
160
|
-
// If adding this span would exceed chunk size and we have content, finalize current chunk
|
|
161
|
-
if (currentChunkSize + spanSize > chunkSize && currentChunkSpans.length > 0) {
|
|
162
|
-
const chunk = await createChunk(
|
|
163
|
-
chunkIndex,
|
|
164
|
-
currentChunkSpans,
|
|
165
|
-
currentChunkEvents,
|
|
166
|
-
compression
|
|
167
|
-
);
|
|
168
|
-
chunks.push(chunk);
|
|
169
|
-
chunkIndex++;
|
|
170
|
-
|
|
171
|
-
// Reset for next chunk
|
|
172
|
-
currentChunkSpans = [];
|
|
173
|
-
currentChunkEvents = [];
|
|
174
|
-
currentChunkSize = 0;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Add span and its events to current chunk
|
|
178
|
-
currentChunkSpans.push(span);
|
|
179
|
-
currentChunkEvents.push(...spanEvents);
|
|
180
|
-
currentChunkSize += spanSize;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Create final chunk if there's remaining content
|
|
184
|
-
if (currentChunkSpans.length > 0) {
|
|
185
|
-
const chunk = await createChunk(
|
|
186
|
-
chunkIndex,
|
|
187
|
-
currentChunkSpans,
|
|
188
|
-
currentChunkEvents,
|
|
189
|
-
compression
|
|
190
|
-
);
|
|
191
|
-
chunks.push(chunk);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Build manifest without hash first
|
|
195
|
-
const manifestWithoutHash: Omit<TraceManifest, "manifestHash"> = {
|
|
196
|
-
formatVersion: bundle.formatVersion,
|
|
197
|
-
runId: run.id,
|
|
198
|
-
agentId: run.agentId,
|
|
199
|
-
rootHash: bundle.rootHash,
|
|
200
|
-
merkleRoot: bundle.merkleRoot,
|
|
201
|
-
totalEvents: run.events.length,
|
|
202
|
-
totalSpans: run.spans.length,
|
|
203
|
-
startedAt: run.startedAt,
|
|
204
|
-
endedAt: run.endedAt ?? run.startedAt,
|
|
205
|
-
durationMs: run.durationMs ?? 0,
|
|
206
|
-
chunks: chunks.map((c) => c.info),
|
|
207
|
-
publicView: bundle.publicView,
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
// Compute manifest hash
|
|
211
|
-
const manifestHash = await computeManifestHash(manifestWithoutHash);
|
|
212
|
-
|
|
213
|
-
// Build complete manifest
|
|
214
|
-
const manifest: TraceManifest = {
|
|
215
|
-
...manifestWithoutHash,
|
|
216
|
-
manifestHash,
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
return { manifest, chunks };
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Internal helper to create a chunk from spans and events.
|
|
224
|
-
*
|
|
225
|
-
* @param index - Chunk sequence number
|
|
226
|
-
* @param spans - Spans to include in this chunk
|
|
227
|
-
* @param events - Events to include in this chunk
|
|
228
|
-
* @param compression - Compression hint
|
|
229
|
-
* @returns Promise resolving to the complete Chunk
|
|
230
|
-
*/
|
|
231
|
-
async function createChunk(
|
|
232
|
-
index: number,
|
|
233
|
-
spans: TraceSpan[],
|
|
234
|
-
events: TraceEvent[],
|
|
235
|
-
compression: "gzip" | "none"
|
|
236
|
-
): Promise<Chunk> {
|
|
237
|
-
// Build chunk content
|
|
238
|
-
const content: ChunkContent = {
|
|
239
|
-
spans,
|
|
240
|
-
events,
|
|
241
|
-
};
|
|
242
|
-
|
|
243
|
-
// Serialize to JSON (deterministic ordering via canonicalize)
|
|
244
|
-
const contentJson = canonicalize(content);
|
|
245
|
-
|
|
246
|
-
// Compute hash of content BEFORE any compression
|
|
247
|
-
const hash = await sha256StringHex(contentJson);
|
|
248
|
-
|
|
249
|
-
// Get span IDs for this chunk
|
|
250
|
-
const spanIds = spans.map((s) => s.id);
|
|
251
|
-
|
|
252
|
-
// Build chunk info
|
|
253
|
-
const info: ChunkInfo = {
|
|
254
|
-
index,
|
|
255
|
-
hash,
|
|
256
|
-
size: contentJson.length,
|
|
257
|
-
compression,
|
|
258
|
-
spanIds,
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
return {
|
|
262
|
-
info,
|
|
263
|
-
content: contentJson,
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// =============================================================================
|
|
268
|
-
// MANIFEST HASH COMPUTATION
|
|
269
|
-
// =============================================================================
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Compute the manifest hash.
|
|
273
|
-
*
|
|
274
|
-
* The manifest hash is computed as:
|
|
275
|
-
* `H("poi-trace:manifest:v1|" + canonical(manifestWithoutHash))`
|
|
276
|
-
*
|
|
277
|
-
* This hash serves as a cryptographic commitment to the manifest contents,
|
|
278
|
-
* enabling integrity verification of stored manifests.
|
|
279
|
-
*
|
|
280
|
-
* @param manifest - Manifest object without the manifestHash field
|
|
281
|
-
* @returns Promise resolving to the manifest hash as a hex string
|
|
282
|
-
*
|
|
283
|
-
* @example
|
|
284
|
-
* ```typescript
|
|
285
|
-
* const manifestHash = await computeManifestHash(manifestWithoutHash);
|
|
286
|
-
* const completeManifest = { ...manifestWithoutHash, manifestHash };
|
|
287
|
-
* ```
|
|
288
|
-
*/
|
|
289
|
-
export async function computeManifestHash(
|
|
290
|
-
manifest: Omit<TraceManifest, "manifestHash">
|
|
291
|
-
): Promise<string> {
|
|
292
|
-
// Canonicalize the manifest for deterministic serialization
|
|
293
|
-
const canonical = canonicalize(manifest);
|
|
294
|
-
|
|
295
|
-
// Apply domain separation and hash
|
|
296
|
-
const prefixedData = HASH_DOMAIN_PREFIXES.manifest + canonical;
|
|
297
|
-
|
|
298
|
-
return sha256StringHex(prefixedData);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// =============================================================================
|
|
302
|
-
// MANIFEST VERIFICATION
|
|
303
|
-
// =============================================================================
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Verify a manifest against its chunks.
|
|
307
|
-
*
|
|
308
|
-
* Performs comprehensive validation including:
|
|
309
|
-
* - Manifest hash matches recomputed hash
|
|
310
|
-
* - Each chunk hash matches its content
|
|
311
|
-
* - All chunk indices are present
|
|
312
|
-
* - Root hash in manifest is present
|
|
313
|
-
* - Merkle root in manifest is present
|
|
314
|
-
*
|
|
315
|
-
* @param manifest - The manifest to verify
|
|
316
|
-
* @param chunks - The chunks referenced by the manifest
|
|
317
|
-
* @returns Promise resolving to verification result with errors and check statuses
|
|
318
|
-
*
|
|
319
|
-
* @example
|
|
320
|
-
* ```typescript
|
|
321
|
-
* const result = await verifyManifest(manifest, chunks);
|
|
322
|
-
* if (!result.valid) {
|
|
323
|
-
* console.error("Verification failed:", result.errors);
|
|
324
|
-
* } else {
|
|
325
|
-
* console.log("Manifest and chunks are valid");
|
|
326
|
-
* }
|
|
327
|
-
* ```
|
|
328
|
-
*/
|
|
329
|
-
export async function verifyManifest(
|
|
330
|
-
manifest: TraceManifest,
|
|
331
|
-
chunks: Chunk[]
|
|
332
|
-
): Promise<ManifestVerificationResult> {
|
|
333
|
-
const errors: string[] = [];
|
|
334
|
-
const warnings: string[] = [];
|
|
335
|
-
const checks = {
|
|
336
|
-
manifestHashValid: false,
|
|
337
|
-
chunkHashesValid: false,
|
|
338
|
-
rootHashMatches: false,
|
|
339
|
-
merkleRootMatches: false,
|
|
340
|
-
};
|
|
341
|
-
|
|
342
|
-
// ---------------------------------------------------------------------------
|
|
343
|
-
// Verify Manifest Hash
|
|
344
|
-
// ---------------------------------------------------------------------------
|
|
345
|
-
|
|
346
|
-
try {
|
|
347
|
-
// Create manifest without hash for recomputation
|
|
348
|
-
const manifestWithoutHash: Omit<TraceManifest, "manifestHash"> = {
|
|
349
|
-
formatVersion: manifest.formatVersion,
|
|
350
|
-
runId: manifest.runId,
|
|
351
|
-
agentId: manifest.agentId,
|
|
352
|
-
rootHash: manifest.rootHash,
|
|
353
|
-
merkleRoot: manifest.merkleRoot,
|
|
354
|
-
totalEvents: manifest.totalEvents,
|
|
355
|
-
totalSpans: manifest.totalSpans,
|
|
356
|
-
startedAt: manifest.startedAt,
|
|
357
|
-
endedAt: manifest.endedAt,
|
|
358
|
-
durationMs: manifest.durationMs,
|
|
359
|
-
chunks: manifest.chunks,
|
|
360
|
-
publicView: manifest.publicView,
|
|
361
|
-
};
|
|
362
|
-
|
|
363
|
-
const computedHash = await computeManifestHash(manifestWithoutHash);
|
|
364
|
-
|
|
365
|
-
if (manifest.manifestHash === computedHash) {
|
|
366
|
-
checks.manifestHashValid = true;
|
|
367
|
-
} else {
|
|
368
|
-
errors.push(
|
|
369
|
-
`Manifest hash mismatch: expected ${manifest.manifestHash}, computed ${computedHash}`
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
|
-
} catch (error) {
|
|
373
|
-
errors.push(
|
|
374
|
-
`Failed to compute manifest hash: ${error instanceof Error ? error.message : String(error)}`
|
|
375
|
-
);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// ---------------------------------------------------------------------------
|
|
379
|
-
// Verify Chunk Hashes
|
|
380
|
-
// ---------------------------------------------------------------------------
|
|
381
|
-
|
|
382
|
-
// Build chunk lookup by index
|
|
383
|
-
const chunkByIndex = new Map<number, Chunk>();
|
|
384
|
-
for (const chunk of chunks) {
|
|
385
|
-
chunkByIndex.set(chunk.info.index, chunk);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
let allChunkHashesValid = true;
|
|
389
|
-
|
|
390
|
-
for (const chunkInfo of manifest.chunks) {
|
|
391
|
-
const chunk = chunkByIndex.get(chunkInfo.index);
|
|
392
|
-
|
|
393
|
-
if (!chunk) {
|
|
394
|
-
errors.push(`Missing chunk at index ${chunkInfo.index}`);
|
|
395
|
-
allChunkHashesValid = false;
|
|
396
|
-
continue;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
try {
|
|
400
|
-
// Compute hash of chunk content
|
|
401
|
-
const computedHash = await sha256StringHex(chunk.content);
|
|
402
|
-
|
|
403
|
-
if (computedHash !== chunkInfo.hash) {
|
|
404
|
-
errors.push(
|
|
405
|
-
`Chunk ${chunkInfo.index} hash mismatch: expected ${chunkInfo.hash}, computed ${computedHash}`
|
|
406
|
-
);
|
|
407
|
-
allChunkHashesValid = false;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// Verify size matches
|
|
411
|
-
if (chunk.content.length !== chunkInfo.size) {
|
|
412
|
-
errors.push(
|
|
413
|
-
`Chunk ${chunkInfo.index} size mismatch: expected ${chunkInfo.size}, got ${chunk.content.length}`
|
|
414
|
-
);
|
|
415
|
-
allChunkHashesValid = false;
|
|
416
|
-
}
|
|
417
|
-
} catch (error) {
|
|
418
|
-
errors.push(
|
|
419
|
-
`Failed to verify chunk ${chunkInfo.index}: ${error instanceof Error ? error.message : String(error)}`
|
|
420
|
-
);
|
|
421
|
-
allChunkHashesValid = false;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// Check for extra chunks not in manifest
|
|
426
|
-
for (const chunk of chunks) {
|
|
427
|
-
const inManifest = manifest.chunks.some((c) => c.index === chunk.info.index);
|
|
428
|
-
if (!inManifest) {
|
|
429
|
-
warnings.push(`Extra chunk at index ${chunk.info.index} not referenced in manifest`);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
checks.chunkHashesValid = allChunkHashesValid;
|
|
434
|
-
|
|
435
|
-
// ---------------------------------------------------------------------------
|
|
436
|
-
// Verify Root Hash Presence
|
|
437
|
-
// ---------------------------------------------------------------------------
|
|
438
|
-
|
|
439
|
-
if (manifest.rootHash && manifest.rootHash.length > 0) {
|
|
440
|
-
checks.rootHashMatches = true;
|
|
441
|
-
} else {
|
|
442
|
-
errors.push("Manifest is missing rootHash");
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// ---------------------------------------------------------------------------
|
|
446
|
-
// Verify Merkle Root Presence
|
|
447
|
-
// ---------------------------------------------------------------------------
|
|
448
|
-
|
|
449
|
-
if (manifest.merkleRoot && manifest.merkleRoot.length > 0) {
|
|
450
|
-
checks.merkleRootMatches = true;
|
|
451
|
-
} else {
|
|
452
|
-
errors.push("Manifest is missing merkleRoot");
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// ---------------------------------------------------------------------------
|
|
456
|
-
// Additional Warnings
|
|
457
|
-
// ---------------------------------------------------------------------------
|
|
458
|
-
|
|
459
|
-
if (manifest.chunks.length === 0) {
|
|
460
|
-
warnings.push("Manifest has no chunks - trace data may be empty");
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
if (manifest.totalSpans === 0 && manifest.chunks.length > 0) {
|
|
464
|
-
warnings.push("Manifest reports 0 spans but has chunks");
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// Determine overall validity
|
|
468
|
-
const valid =
|
|
469
|
-
checks.manifestHashValid &&
|
|
470
|
-
checks.chunkHashesValid &&
|
|
471
|
-
checks.rootHashMatches &&
|
|
472
|
-
checks.merkleRootMatches;
|
|
473
|
-
|
|
474
|
-
return {
|
|
475
|
-
valid,
|
|
476
|
-
errors,
|
|
477
|
-
warnings,
|
|
478
|
-
checks,
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// =============================================================================
|
|
483
|
-
// BUNDLE RECONSTRUCTION
|
|
484
|
-
// =============================================================================
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* Reconstruct a trace bundle from manifest and chunks.
|
|
488
|
-
*
|
|
489
|
-
* This is the inverse operation of createManifest. It parses all chunk
|
|
490
|
-
* contents, merges spans and events, and reconstructs the complete
|
|
491
|
-
* TraceBundle.
|
|
492
|
-
*
|
|
493
|
-
* Note: The reconstructed bundle will use the publicView from the manifest
|
|
494
|
-
* and construct a privateRun from the chunk data.
|
|
495
|
-
*
|
|
496
|
-
* @param manifest - The manifest describing the trace
|
|
497
|
-
* @param chunks - All chunks referenced by the manifest
|
|
498
|
-
* @returns Promise resolving to the reconstructed TraceBundle
|
|
499
|
-
* @throws Error if required chunks are missing or corrupted
|
|
500
|
-
*
|
|
501
|
-
* @example
|
|
502
|
-
* ```typescript
|
|
503
|
-
* // Load manifest and chunks from storage
|
|
504
|
-
* const manifest = JSON.parse(await storage.read("manifest.json"));
|
|
505
|
-
* const chunks = await loadChunks(manifest.chunks);
|
|
506
|
-
*
|
|
507
|
-
* // Reconstruct the bundle
|
|
508
|
-
* const bundle = await reconstructBundleFromManifest(manifest, chunks);
|
|
509
|
-
*
|
|
510
|
-
* // Now you can access the full trace data
|
|
511
|
-
* console.log(`Reconstructed ${bundle.privateRun.events.length} events`);
|
|
512
|
-
* ```
|
|
513
|
-
*/
|
|
514
|
-
export async function reconstructBundleFromManifest(
|
|
515
|
-
manifest: TraceManifest,
|
|
516
|
-
chunks: Chunk[]
|
|
517
|
-
): Promise<TraceBundle> {
|
|
518
|
-
// Build chunk lookup by index
|
|
519
|
-
const chunkByIndex = new Map<number, Chunk>();
|
|
520
|
-
for (const chunk of chunks) {
|
|
521
|
-
chunkByIndex.set(chunk.info.index, chunk);
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// Parse all chunks and merge spans/events
|
|
525
|
-
const allSpans: TraceSpan[] = [];
|
|
526
|
-
const allEvents: TraceEvent[] = [];
|
|
527
|
-
|
|
528
|
-
// Process chunks in order
|
|
529
|
-
const sortedChunkInfos = [...manifest.chunks].sort((a, b) => a.index - b.index);
|
|
530
|
-
|
|
531
|
-
for (const chunkInfo of sortedChunkInfos) {
|
|
532
|
-
const chunk = chunkByIndex.get(chunkInfo.index);
|
|
533
|
-
|
|
534
|
-
if (!chunk) {
|
|
535
|
-
throw new Error(`Missing chunk at index ${chunkInfo.index}`);
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// Parse chunk content
|
|
539
|
-
const { spans, events } = parseChunkContent(chunk.content);
|
|
540
|
-
|
|
541
|
-
allSpans.push(...spans);
|
|
542
|
-
allEvents.push(...events);
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
// Sort spans by spanSeq and events by seq for proper ordering
|
|
546
|
-
allSpans.sort((a, b) => a.spanSeq - b.spanSeq);
|
|
547
|
-
allEvents.sort((a, b) => a.seq - b.seq);
|
|
548
|
-
|
|
549
|
-
// Compute the next sequence numbers
|
|
550
|
-
const nextSeq = allEvents.length > 0
|
|
551
|
-
? Math.max(...allEvents.map(e => e.seq)) + 1
|
|
552
|
-
: 0;
|
|
553
|
-
const nextSpanSeq = allSpans.length > 0
|
|
554
|
-
? Math.max(...allSpans.map(s => s.spanSeq)) + 1
|
|
555
|
-
: 0;
|
|
556
|
-
|
|
557
|
-
// Reconstruct the trace run
|
|
558
|
-
// Note: We need to derive rollingHash from events if possible, but that would
|
|
559
|
-
// require recomputing. For now, we store a placeholder and note that full
|
|
560
|
-
// verification would need the original rollingHash.
|
|
561
|
-
const privateRun: TraceRun = {
|
|
562
|
-
id: manifest.runId,
|
|
563
|
-
schemaVersion: manifest.formatVersion,
|
|
564
|
-
agentId: manifest.agentId,
|
|
565
|
-
status: manifest.publicView.status as "running" | "completed" | "failed" | "cancelled",
|
|
566
|
-
startedAt: manifest.startedAt,
|
|
567
|
-
endedAt: manifest.endedAt,
|
|
568
|
-
durationMs: manifest.durationMs,
|
|
569
|
-
events: allEvents,
|
|
570
|
-
spans: allSpans,
|
|
571
|
-
rollingHash: "", // Would need to be recomputed for full verification
|
|
572
|
-
rootHash: manifest.rootHash,
|
|
573
|
-
nextSeq,
|
|
574
|
-
nextSpanSeq,
|
|
575
|
-
};
|
|
576
|
-
|
|
577
|
-
// Build the complete bundle
|
|
578
|
-
const bundle: TraceBundle = {
|
|
579
|
-
formatVersion: manifest.formatVersion,
|
|
580
|
-
publicView: manifest.publicView,
|
|
581
|
-
privateRun,
|
|
582
|
-
merkleRoot: manifest.merkleRoot,
|
|
583
|
-
rootHash: manifest.rootHash,
|
|
584
|
-
};
|
|
585
|
-
|
|
586
|
-
// Add manifestHash if present
|
|
587
|
-
if (manifest.manifestHash !== undefined) {
|
|
588
|
-
bundle.manifestHash = manifest.manifestHash;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
return bundle;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// =============================================================================
|
|
595
|
-
// CHUNK PATH UTILITIES
|
|
596
|
-
// =============================================================================
|
|
597
|
-
|
|
598
|
-
/**
|
|
599
|
-
* Get the storage path for a chunk.
|
|
600
|
-
*
|
|
601
|
-
* Returns the relative path where the chunk should be stored.
|
|
602
|
-
* The consumer is responsible for adding any compression suffix
|
|
603
|
-
* (e.g., ".gz" if compressed) and handling the actual storage.
|
|
604
|
-
*
|
|
605
|
-
* @param chunkInfo - Information about the chunk
|
|
606
|
-
* @returns The relative storage path for the chunk
|
|
607
|
-
*
|
|
608
|
-
* @example
|
|
609
|
-
* ```typescript
|
|
610
|
-
* const path = getChunkPath(chunk.info);
|
|
611
|
-
* // Returns: "chunks/abc123...def.json"
|
|
612
|
-
*
|
|
613
|
-
* // Consumer adds compression suffix if needed
|
|
614
|
-
* const storagePath = chunk.info.compression === "gzip"
|
|
615
|
-
* ? path + ".gz"
|
|
616
|
-
* : path;
|
|
617
|
-
* ```
|
|
618
|
-
*/
|
|
619
|
-
export function getChunkPath(chunkInfo: ChunkInfo): string {
|
|
620
|
-
return `chunks/${chunkInfo.hash}.json`;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// =============================================================================
|
|
624
|
-
// CHUNK CONTENT PARSING
|
|
625
|
-
// =============================================================================
|
|
626
|
-
|
|
627
|
-
/**
|
|
628
|
-
* Parse chunk content from JSON string.
|
|
629
|
-
*
|
|
630
|
-
* Parses the serialized chunk content and returns the spans and events
|
|
631
|
-
* contained within. This function handles the internal chunk format.
|
|
632
|
-
*
|
|
633
|
-
* @param content - JSON string of chunk content
|
|
634
|
-
* @returns Object containing spans and events arrays
|
|
635
|
-
* @throws Error if content cannot be parsed or is malformed
|
|
636
|
-
*
|
|
637
|
-
* @example
|
|
638
|
-
* ```typescript
|
|
639
|
-
* const { spans, events } = parseChunkContent(chunk.content);
|
|
640
|
-
* console.log(`Chunk contains ${spans.length} spans and ${events.length} events`);
|
|
641
|
-
* ```
|
|
642
|
-
*/
|
|
643
|
-
export function parseChunkContent(content: string): { spans: TraceSpan[]; events: TraceEvent[] } {
|
|
644
|
-
try {
|
|
645
|
-
const parsed = JSON.parse(content) as unknown;
|
|
646
|
-
|
|
647
|
-
// Validate parsed content has expected structure
|
|
648
|
-
if (typeof parsed !== "object" || parsed === null) {
|
|
649
|
-
throw new Error("Chunk content must be an object");
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
const obj = parsed as Record<string, unknown>;
|
|
653
|
-
|
|
654
|
-
if (!Array.isArray(obj.spans)) {
|
|
655
|
-
throw new Error("Chunk content must have a 'spans' array");
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
if (!Array.isArray(obj.events)) {
|
|
659
|
-
throw new Error("Chunk content must have an 'events' array");
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// Type cast with validation
|
|
663
|
-
const spans = obj.spans as TraceSpan[];
|
|
664
|
-
const events = obj.events as TraceEvent[];
|
|
665
|
-
|
|
666
|
-
// Basic validation of spans
|
|
667
|
-
for (const span of spans) {
|
|
668
|
-
if (typeof span.id !== "string" || typeof span.spanSeq !== "number") {
|
|
669
|
-
throw new Error("Invalid span structure in chunk content");
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
// Basic validation of events
|
|
674
|
-
for (const event of events) {
|
|
675
|
-
if (typeof event.id !== "string" || typeof event.seq !== "number") {
|
|
676
|
-
throw new Error("Invalid event structure in chunk content");
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
return { spans, events };
|
|
681
|
-
} catch (error) {
|
|
682
|
-
if (error instanceof SyntaxError) {
|
|
683
|
-
throw new Error(`Invalid JSON in chunk content: ${error.message}`);
|
|
684
|
-
}
|
|
685
|
-
throw error;
|
|
686
|
-
}
|
|
687
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Manifest creation and verification for off-chain trace storage.
|
|
3
|
+
*
|
|
4
|
+
* Location: packages/process-trace/src/manifest.ts
|
|
5
|
+
*
|
|
6
|
+
* This module provides functionality for creating manifests and chunks from trace bundles,
|
|
7
|
+
* enabling efficient off-chain storage of trace data. The manifest serves as a public-safe
|
|
8
|
+
* entry point for trace retrieval, while chunks contain the actual span and event data.
|
|
9
|
+
*
|
|
10
|
+
* Key features:
|
|
11
|
+
* - Creates manifests with cryptographic commitments for integrity verification
|
|
12
|
+
* - Chunks trace data by size for efficient storage and retrieval
|
|
13
|
+
* - Provides verification functions to ensure manifest and chunk integrity
|
|
14
|
+
* - Supports reconstruction of trace bundles from manifests and chunks
|
|
15
|
+
*
|
|
16
|
+
* Storage Layout (for consumers):
|
|
17
|
+
* ```
|
|
18
|
+
* <storageUri>/
|
|
19
|
+
* manifest.json # TraceManifest (public-safe)
|
|
20
|
+
* chunks/
|
|
21
|
+
* <hash1>.json # Or .json.gz if compressed by consumer
|
|
22
|
+
* <hash2>.json
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Used by:
|
|
26
|
+
* - Storage adapters for persisting trace data
|
|
27
|
+
* - Retrieval workflows for reconstructing traces
|
|
28
|
+
* - Verification workflows for validating stored traces
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* // Create manifest for storage
|
|
33
|
+
* const { manifest, chunks } = await createManifest(bundle, { chunkSize: 500_000 });
|
|
34
|
+
*
|
|
35
|
+
* // Store chunks (consumer handles actual storage)
|
|
36
|
+
* for (const chunk of chunks) {
|
|
37
|
+
* const path = getChunkPath(chunk.info);
|
|
38
|
+
* await storage.write(path, chunk.content);
|
|
39
|
+
* }
|
|
40
|
+
*
|
|
41
|
+
* // Store manifest
|
|
42
|
+
* await storage.write("manifest.json", JSON.stringify(manifest));
|
|
43
|
+
*
|
|
44
|
+
* // Later: verify
|
|
45
|
+
* const result = await verifyManifest(manifest, loadedChunks);
|
|
46
|
+
* if (!result.valid) {
|
|
47
|
+
* console.error("Manifest verification failed:", result.errors);
|
|
48
|
+
* }
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { sha256StringHex, canonicalize } from "@fluxpointstudios/orynq-sdk-core/utils";
|
|
53
|
+
import type {
|
|
54
|
+
TraceManifest,
|
|
55
|
+
TraceBundle,
|
|
56
|
+
ChunkInfo,
|
|
57
|
+
Chunk,
|
|
58
|
+
CreateManifestOptions,
|
|
59
|
+
ManifestVerificationResult,
|
|
60
|
+
TraceSpan,
|
|
61
|
+
TraceEvent,
|
|
62
|
+
TraceRun,
|
|
63
|
+
} from "./types.js";
|
|
64
|
+
import { HASH_DOMAIN_PREFIXES } from "./types.js";
|
|
65
|
+
|
|
66
|
+
// =============================================================================
|
|
67
|
+
// CONSTANTS
|
|
68
|
+
// =============================================================================
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Default chunk size in bytes (1MB of uncompressed JSON).
|
|
72
|
+
* This is the target size for chunking; actual chunks may be slightly larger
|
|
73
|
+
* to avoid splitting spans across chunks.
|
|
74
|
+
*/
|
|
75
|
+
const DEFAULT_CHUNK_SIZE = 1_000_000;
|
|
76
|
+
|
|
77
|
+
// =============================================================================
|
|
78
|
+
// CHUNK CONTENT TYPE
|
|
79
|
+
// =============================================================================
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Content format for a chunk.
|
|
83
|
+
* Contains spans and their associated events.
|
|
84
|
+
*/
|
|
85
|
+
interface ChunkContent {
|
|
86
|
+
spans: TraceSpan[];
|
|
87
|
+
events: TraceEvent[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// =============================================================================
|
|
91
|
+
// MANIFEST CREATION
|
|
92
|
+
// =============================================================================
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create a manifest and chunks from a trace bundle.
|
|
96
|
+
*
|
|
97
|
+
* Chunks contain private span/event data for off-chain storage. Each chunk
|
|
98
|
+
* includes a subset of spans and their associated events, grouped to fit
|
|
99
|
+
* within the target chunk size.
|
|
100
|
+
*
|
|
101
|
+
* The manifest contains:
|
|
102
|
+
* - Metadata from the bundle (runId, agentId, timestamps, etc.)
|
|
103
|
+
* - Cryptographic commitments (rootHash, merkleRoot)
|
|
104
|
+
* - Chunk information (index, hash, size, spanIds)
|
|
105
|
+
* - The complete publicView for quick access
|
|
106
|
+
*
|
|
107
|
+
* @param bundle - Finalized trace bundle to create manifest from
|
|
108
|
+
* @param options - Optional chunking options
|
|
109
|
+
* @param options.chunkSize - Target chunk size in bytes (default: 1MB)
|
|
110
|
+
* @param options.compression - Compression hint for consumers (default: "none")
|
|
111
|
+
* @returns Promise resolving to manifest and array of chunks
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```typescript
|
|
115
|
+
* const { manifest, chunks } = await createManifest(bundle, {
|
|
116
|
+
* chunkSize: 500_000, // 500KB chunks
|
|
117
|
+
* });
|
|
118
|
+
*
|
|
119
|
+
* console.log(`Created ${chunks.length} chunks`);
|
|
120
|
+
* console.log(`Manifest hash: ${manifest.manifestHash}`);
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
export async function createManifest(
|
|
124
|
+
bundle: TraceBundle,
|
|
125
|
+
options?: CreateManifestOptions
|
|
126
|
+
): Promise<{ manifest: TraceManifest; chunks: Chunk[] }> {
|
|
127
|
+
const chunkSize = options?.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
|
128
|
+
const compression = options?.compression ?? "none";
|
|
129
|
+
|
|
130
|
+
const run = bundle.privateRun;
|
|
131
|
+
|
|
132
|
+
// Build event lookup map for efficient access
|
|
133
|
+
const eventMap = new Map<string, TraceEvent>();
|
|
134
|
+
for (const event of run.events) {
|
|
135
|
+
eventMap.set(event.id, event);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Group spans into chunks based on size
|
|
139
|
+
const chunks: Chunk[] = [];
|
|
140
|
+
let currentChunkSpans: TraceSpan[] = [];
|
|
141
|
+
let currentChunkEvents: TraceEvent[] = [];
|
|
142
|
+
let currentChunkSize = 0;
|
|
143
|
+
let chunkIndex = 0;
|
|
144
|
+
|
|
145
|
+
// Sort spans by spanSeq for deterministic ordering
|
|
146
|
+
const sortedSpans = [...run.spans].sort((a, b) => a.spanSeq - b.spanSeq);
|
|
147
|
+
|
|
148
|
+
for (const span of sortedSpans) {
|
|
149
|
+
// Get events for this span
|
|
150
|
+
const spanEvents = span.eventIds
|
|
151
|
+
.map((id) => eventMap.get(id))
|
|
152
|
+
.filter((e): e is TraceEvent => e !== undefined)
|
|
153
|
+
.sort((a, b) => a.seq - b.seq);
|
|
154
|
+
|
|
155
|
+
// Estimate size of this span and its events
|
|
156
|
+
const spanJson = JSON.stringify(span);
|
|
157
|
+
const eventsJson = spanEvents.map((e) => JSON.stringify(e)).join("");
|
|
158
|
+
const spanSize = spanJson.length + eventsJson.length;
|
|
159
|
+
|
|
160
|
+
// If adding this span would exceed chunk size and we have content, finalize current chunk
|
|
161
|
+
if (currentChunkSize + spanSize > chunkSize && currentChunkSpans.length > 0) {
|
|
162
|
+
const chunk = await createChunk(
|
|
163
|
+
chunkIndex,
|
|
164
|
+
currentChunkSpans,
|
|
165
|
+
currentChunkEvents,
|
|
166
|
+
compression
|
|
167
|
+
);
|
|
168
|
+
chunks.push(chunk);
|
|
169
|
+
chunkIndex++;
|
|
170
|
+
|
|
171
|
+
// Reset for next chunk
|
|
172
|
+
currentChunkSpans = [];
|
|
173
|
+
currentChunkEvents = [];
|
|
174
|
+
currentChunkSize = 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Add span and its events to current chunk
|
|
178
|
+
currentChunkSpans.push(span);
|
|
179
|
+
currentChunkEvents.push(...spanEvents);
|
|
180
|
+
currentChunkSize += spanSize;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Create final chunk if there's remaining content
|
|
184
|
+
if (currentChunkSpans.length > 0) {
|
|
185
|
+
const chunk = await createChunk(
|
|
186
|
+
chunkIndex,
|
|
187
|
+
currentChunkSpans,
|
|
188
|
+
currentChunkEvents,
|
|
189
|
+
compression
|
|
190
|
+
);
|
|
191
|
+
chunks.push(chunk);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Build manifest without hash first
|
|
195
|
+
const manifestWithoutHash: Omit<TraceManifest, "manifestHash"> = {
|
|
196
|
+
formatVersion: bundle.formatVersion,
|
|
197
|
+
runId: run.id,
|
|
198
|
+
agentId: run.agentId,
|
|
199
|
+
rootHash: bundle.rootHash,
|
|
200
|
+
merkleRoot: bundle.merkleRoot,
|
|
201
|
+
totalEvents: run.events.length,
|
|
202
|
+
totalSpans: run.spans.length,
|
|
203
|
+
startedAt: run.startedAt,
|
|
204
|
+
endedAt: run.endedAt ?? run.startedAt,
|
|
205
|
+
durationMs: run.durationMs ?? 0,
|
|
206
|
+
chunks: chunks.map((c) => c.info),
|
|
207
|
+
publicView: bundle.publicView,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// Compute manifest hash
|
|
211
|
+
const manifestHash = await computeManifestHash(manifestWithoutHash);
|
|
212
|
+
|
|
213
|
+
// Build complete manifest
|
|
214
|
+
const manifest: TraceManifest = {
|
|
215
|
+
...manifestWithoutHash,
|
|
216
|
+
manifestHash,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return { manifest, chunks };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Internal helper to create a chunk from spans and events.
|
|
224
|
+
*
|
|
225
|
+
* @param index - Chunk sequence number
|
|
226
|
+
* @param spans - Spans to include in this chunk
|
|
227
|
+
* @param events - Events to include in this chunk
|
|
228
|
+
* @param compression - Compression hint
|
|
229
|
+
* @returns Promise resolving to the complete Chunk
|
|
230
|
+
*/
|
|
231
|
+
async function createChunk(
|
|
232
|
+
index: number,
|
|
233
|
+
spans: TraceSpan[],
|
|
234
|
+
events: TraceEvent[],
|
|
235
|
+
compression: "gzip" | "none"
|
|
236
|
+
): Promise<Chunk> {
|
|
237
|
+
// Build chunk content
|
|
238
|
+
const content: ChunkContent = {
|
|
239
|
+
spans,
|
|
240
|
+
events,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Serialize to JSON (deterministic ordering via canonicalize)
|
|
244
|
+
const contentJson = canonicalize(content);
|
|
245
|
+
|
|
246
|
+
// Compute hash of content BEFORE any compression
|
|
247
|
+
const hash = await sha256StringHex(contentJson);
|
|
248
|
+
|
|
249
|
+
// Get span IDs for this chunk
|
|
250
|
+
const spanIds = spans.map((s) => s.id);
|
|
251
|
+
|
|
252
|
+
// Build chunk info
|
|
253
|
+
const info: ChunkInfo = {
|
|
254
|
+
index,
|
|
255
|
+
hash,
|
|
256
|
+
size: contentJson.length,
|
|
257
|
+
compression,
|
|
258
|
+
spanIds,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
info,
|
|
263
|
+
content: contentJson,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// =============================================================================
|
|
268
|
+
// MANIFEST HASH COMPUTATION
|
|
269
|
+
// =============================================================================
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Compute the manifest hash.
|
|
273
|
+
*
|
|
274
|
+
* The manifest hash is computed as:
|
|
275
|
+
* `H("poi-trace:manifest:v1|" + canonical(manifestWithoutHash))`
|
|
276
|
+
*
|
|
277
|
+
* This hash serves as a cryptographic commitment to the manifest contents,
|
|
278
|
+
* enabling integrity verification of stored manifests.
|
|
279
|
+
*
|
|
280
|
+
* @param manifest - Manifest object without the manifestHash field
|
|
281
|
+
* @returns Promise resolving to the manifest hash as a hex string
|
|
282
|
+
*
|
|
283
|
+
* @example
|
|
284
|
+
* ```typescript
|
|
285
|
+
* const manifestHash = await computeManifestHash(manifestWithoutHash);
|
|
286
|
+
* const completeManifest = { ...manifestWithoutHash, manifestHash };
|
|
287
|
+
* ```
|
|
288
|
+
*/
|
|
289
|
+
export async function computeManifestHash(
|
|
290
|
+
manifest: Omit<TraceManifest, "manifestHash">
|
|
291
|
+
): Promise<string> {
|
|
292
|
+
// Canonicalize the manifest for deterministic serialization
|
|
293
|
+
const canonical = canonicalize(manifest);
|
|
294
|
+
|
|
295
|
+
// Apply domain separation and hash
|
|
296
|
+
const prefixedData = HASH_DOMAIN_PREFIXES.manifest + canonical;
|
|
297
|
+
|
|
298
|
+
return sha256StringHex(prefixedData);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// =============================================================================
|
|
302
|
+
// MANIFEST VERIFICATION
|
|
303
|
+
// =============================================================================
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Verify a manifest against its chunks.
|
|
307
|
+
*
|
|
308
|
+
* Performs comprehensive validation including:
|
|
309
|
+
* - Manifest hash matches recomputed hash
|
|
310
|
+
* - Each chunk hash matches its content
|
|
311
|
+
* - All chunk indices are present
|
|
312
|
+
* - Root hash in manifest is present
|
|
313
|
+
* - Merkle root in manifest is present
|
|
314
|
+
*
|
|
315
|
+
* @param manifest - The manifest to verify
|
|
316
|
+
* @param chunks - The chunks referenced by the manifest
|
|
317
|
+
* @returns Promise resolving to verification result with errors and check statuses
|
|
318
|
+
*
|
|
319
|
+
* @example
|
|
320
|
+
* ```typescript
|
|
321
|
+
* const result = await verifyManifest(manifest, chunks);
|
|
322
|
+
* if (!result.valid) {
|
|
323
|
+
* console.error("Verification failed:", result.errors);
|
|
324
|
+
* } else {
|
|
325
|
+
* console.log("Manifest and chunks are valid");
|
|
326
|
+
* }
|
|
327
|
+
* ```
|
|
328
|
+
*/
|
|
329
|
+
export async function verifyManifest(
|
|
330
|
+
manifest: TraceManifest,
|
|
331
|
+
chunks: Chunk[]
|
|
332
|
+
): Promise<ManifestVerificationResult> {
|
|
333
|
+
const errors: string[] = [];
|
|
334
|
+
const warnings: string[] = [];
|
|
335
|
+
const checks = {
|
|
336
|
+
manifestHashValid: false,
|
|
337
|
+
chunkHashesValid: false,
|
|
338
|
+
rootHashMatches: false,
|
|
339
|
+
merkleRootMatches: false,
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// Verify Manifest Hash
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
// Create manifest without hash for recomputation
|
|
348
|
+
const manifestWithoutHash: Omit<TraceManifest, "manifestHash"> = {
|
|
349
|
+
formatVersion: manifest.formatVersion,
|
|
350
|
+
runId: manifest.runId,
|
|
351
|
+
agentId: manifest.agentId,
|
|
352
|
+
rootHash: manifest.rootHash,
|
|
353
|
+
merkleRoot: manifest.merkleRoot,
|
|
354
|
+
totalEvents: manifest.totalEvents,
|
|
355
|
+
totalSpans: manifest.totalSpans,
|
|
356
|
+
startedAt: manifest.startedAt,
|
|
357
|
+
endedAt: manifest.endedAt,
|
|
358
|
+
durationMs: manifest.durationMs,
|
|
359
|
+
chunks: manifest.chunks,
|
|
360
|
+
publicView: manifest.publicView,
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const computedHash = await computeManifestHash(manifestWithoutHash);
|
|
364
|
+
|
|
365
|
+
if (manifest.manifestHash === computedHash) {
|
|
366
|
+
checks.manifestHashValid = true;
|
|
367
|
+
} else {
|
|
368
|
+
errors.push(
|
|
369
|
+
`Manifest hash mismatch: expected ${manifest.manifestHash}, computed ${computedHash}`
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
} catch (error) {
|
|
373
|
+
errors.push(
|
|
374
|
+
`Failed to compute manifest hash: ${error instanceof Error ? error.message : String(error)}`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// Verify Chunk Hashes
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
// Build chunk lookup by index
|
|
383
|
+
const chunkByIndex = new Map<number, Chunk>();
|
|
384
|
+
for (const chunk of chunks) {
|
|
385
|
+
chunkByIndex.set(chunk.info.index, chunk);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
let allChunkHashesValid = true;
|
|
389
|
+
|
|
390
|
+
for (const chunkInfo of manifest.chunks) {
|
|
391
|
+
const chunk = chunkByIndex.get(chunkInfo.index);
|
|
392
|
+
|
|
393
|
+
if (!chunk) {
|
|
394
|
+
errors.push(`Missing chunk at index ${chunkInfo.index}`);
|
|
395
|
+
allChunkHashesValid = false;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
// Compute hash of chunk content
|
|
401
|
+
const computedHash = await sha256StringHex(chunk.content);
|
|
402
|
+
|
|
403
|
+
if (computedHash !== chunkInfo.hash) {
|
|
404
|
+
errors.push(
|
|
405
|
+
`Chunk ${chunkInfo.index} hash mismatch: expected ${chunkInfo.hash}, computed ${computedHash}`
|
|
406
|
+
);
|
|
407
|
+
allChunkHashesValid = false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Verify size matches
|
|
411
|
+
if (chunk.content.length !== chunkInfo.size) {
|
|
412
|
+
errors.push(
|
|
413
|
+
`Chunk ${chunkInfo.index} size mismatch: expected ${chunkInfo.size}, got ${chunk.content.length}`
|
|
414
|
+
);
|
|
415
|
+
allChunkHashesValid = false;
|
|
416
|
+
}
|
|
417
|
+
} catch (error) {
|
|
418
|
+
errors.push(
|
|
419
|
+
`Failed to verify chunk ${chunkInfo.index}: ${error instanceof Error ? error.message : String(error)}`
|
|
420
|
+
);
|
|
421
|
+
allChunkHashesValid = false;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Check for extra chunks not in manifest
|
|
426
|
+
for (const chunk of chunks) {
|
|
427
|
+
const inManifest = manifest.chunks.some((c) => c.index === chunk.info.index);
|
|
428
|
+
if (!inManifest) {
|
|
429
|
+
warnings.push(`Extra chunk at index ${chunk.info.index} not referenced in manifest`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
checks.chunkHashesValid = allChunkHashesValid;
|
|
434
|
+
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// Verify Root Hash Presence
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
if (manifest.rootHash && manifest.rootHash.length > 0) {
|
|
440
|
+
checks.rootHashMatches = true;
|
|
441
|
+
} else {
|
|
442
|
+
errors.push("Manifest is missing rootHash");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// Verify Merkle Root Presence
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
if (manifest.merkleRoot && manifest.merkleRoot.length > 0) {
|
|
450
|
+
checks.merkleRootMatches = true;
|
|
451
|
+
} else {
|
|
452
|
+
errors.push("Manifest is missing merkleRoot");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// Additional Warnings
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
if (manifest.chunks.length === 0) {
|
|
460
|
+
warnings.push("Manifest has no chunks - trace data may be empty");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (manifest.totalSpans === 0 && manifest.chunks.length > 0) {
|
|
464
|
+
warnings.push("Manifest reports 0 spans but has chunks");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Determine overall validity
|
|
468
|
+
const valid =
|
|
469
|
+
checks.manifestHashValid &&
|
|
470
|
+
checks.chunkHashesValid &&
|
|
471
|
+
checks.rootHashMatches &&
|
|
472
|
+
checks.merkleRootMatches;
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
valid,
|
|
476
|
+
errors,
|
|
477
|
+
warnings,
|
|
478
|
+
checks,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// =============================================================================
|
|
483
|
+
// BUNDLE RECONSTRUCTION
|
|
484
|
+
// =============================================================================
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Reconstruct a trace bundle from manifest and chunks.
|
|
488
|
+
*
|
|
489
|
+
* This is the inverse operation of createManifest. It parses all chunk
|
|
490
|
+
* contents, merges spans and events, and reconstructs the complete
|
|
491
|
+
* TraceBundle.
|
|
492
|
+
*
|
|
493
|
+
* Note: The reconstructed bundle will use the publicView from the manifest
|
|
494
|
+
* and construct a privateRun from the chunk data.
|
|
495
|
+
*
|
|
496
|
+
* @param manifest - The manifest describing the trace
|
|
497
|
+
* @param chunks - All chunks referenced by the manifest
|
|
498
|
+
* @returns Promise resolving to the reconstructed TraceBundle
|
|
499
|
+
* @throws Error if required chunks are missing or corrupted
|
|
500
|
+
*
|
|
501
|
+
* @example
|
|
502
|
+
* ```typescript
|
|
503
|
+
* // Load manifest and chunks from storage
|
|
504
|
+
* const manifest = JSON.parse(await storage.read("manifest.json"));
|
|
505
|
+
* const chunks = await loadChunks(manifest.chunks);
|
|
506
|
+
*
|
|
507
|
+
* // Reconstruct the bundle
|
|
508
|
+
* const bundle = await reconstructBundleFromManifest(manifest, chunks);
|
|
509
|
+
*
|
|
510
|
+
* // Now you can access the full trace data
|
|
511
|
+
* console.log(`Reconstructed ${bundle.privateRun.events.length} events`);
|
|
512
|
+
* ```
|
|
513
|
+
*/
|
|
514
|
+
export async function reconstructBundleFromManifest(
|
|
515
|
+
manifest: TraceManifest,
|
|
516
|
+
chunks: Chunk[]
|
|
517
|
+
): Promise<TraceBundle> {
|
|
518
|
+
// Build chunk lookup by index
|
|
519
|
+
const chunkByIndex = new Map<number, Chunk>();
|
|
520
|
+
for (const chunk of chunks) {
|
|
521
|
+
chunkByIndex.set(chunk.info.index, chunk);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Parse all chunks and merge spans/events
|
|
525
|
+
const allSpans: TraceSpan[] = [];
|
|
526
|
+
const allEvents: TraceEvent[] = [];
|
|
527
|
+
|
|
528
|
+
// Process chunks in order
|
|
529
|
+
const sortedChunkInfos = [...manifest.chunks].sort((a, b) => a.index - b.index);
|
|
530
|
+
|
|
531
|
+
for (const chunkInfo of sortedChunkInfos) {
|
|
532
|
+
const chunk = chunkByIndex.get(chunkInfo.index);
|
|
533
|
+
|
|
534
|
+
if (!chunk) {
|
|
535
|
+
throw new Error(`Missing chunk at index ${chunkInfo.index}`);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Parse chunk content
|
|
539
|
+
const { spans, events } = parseChunkContent(chunk.content);
|
|
540
|
+
|
|
541
|
+
allSpans.push(...spans);
|
|
542
|
+
allEvents.push(...events);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Sort spans by spanSeq and events by seq for proper ordering
|
|
546
|
+
allSpans.sort((a, b) => a.spanSeq - b.spanSeq);
|
|
547
|
+
allEvents.sort((a, b) => a.seq - b.seq);
|
|
548
|
+
|
|
549
|
+
// Compute the next sequence numbers
|
|
550
|
+
const nextSeq = allEvents.length > 0
|
|
551
|
+
? Math.max(...allEvents.map(e => e.seq)) + 1
|
|
552
|
+
: 0;
|
|
553
|
+
const nextSpanSeq = allSpans.length > 0
|
|
554
|
+
? Math.max(...allSpans.map(s => s.spanSeq)) + 1
|
|
555
|
+
: 0;
|
|
556
|
+
|
|
557
|
+
// Reconstruct the trace run
|
|
558
|
+
// Note: We need to derive rollingHash from events if possible, but that would
|
|
559
|
+
// require recomputing. For now, we store a placeholder and note that full
|
|
560
|
+
// verification would need the original rollingHash.
|
|
561
|
+
const privateRun: TraceRun = {
|
|
562
|
+
id: manifest.runId,
|
|
563
|
+
schemaVersion: manifest.formatVersion,
|
|
564
|
+
agentId: manifest.agentId,
|
|
565
|
+
status: manifest.publicView.status as "running" | "completed" | "failed" | "cancelled",
|
|
566
|
+
startedAt: manifest.startedAt,
|
|
567
|
+
endedAt: manifest.endedAt,
|
|
568
|
+
durationMs: manifest.durationMs,
|
|
569
|
+
events: allEvents,
|
|
570
|
+
spans: allSpans,
|
|
571
|
+
rollingHash: "", // Would need to be recomputed for full verification
|
|
572
|
+
rootHash: manifest.rootHash,
|
|
573
|
+
nextSeq,
|
|
574
|
+
nextSpanSeq,
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
// Build the complete bundle
|
|
578
|
+
const bundle: TraceBundle = {
|
|
579
|
+
formatVersion: manifest.formatVersion,
|
|
580
|
+
publicView: manifest.publicView,
|
|
581
|
+
privateRun,
|
|
582
|
+
merkleRoot: manifest.merkleRoot,
|
|
583
|
+
rootHash: manifest.rootHash,
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
// Add manifestHash if present
|
|
587
|
+
if (manifest.manifestHash !== undefined) {
|
|
588
|
+
bundle.manifestHash = manifest.manifestHash;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return bundle;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// =============================================================================
|
|
595
|
+
// CHUNK PATH UTILITIES
|
|
596
|
+
// =============================================================================
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Get the storage path for a chunk.
|
|
600
|
+
*
|
|
601
|
+
* Returns the relative path where the chunk should be stored.
|
|
602
|
+
* The consumer is responsible for adding any compression suffix
|
|
603
|
+
* (e.g., ".gz" if compressed) and handling the actual storage.
|
|
604
|
+
*
|
|
605
|
+
* @param chunkInfo - Information about the chunk
|
|
606
|
+
* @returns The relative storage path for the chunk
|
|
607
|
+
*
|
|
608
|
+
* @example
|
|
609
|
+
* ```typescript
|
|
610
|
+
* const path = getChunkPath(chunk.info);
|
|
611
|
+
* // Returns: "chunks/abc123...def.json"
|
|
612
|
+
*
|
|
613
|
+
* // Consumer adds compression suffix if needed
|
|
614
|
+
* const storagePath = chunk.info.compression === "gzip"
|
|
615
|
+
* ? path + ".gz"
|
|
616
|
+
* : path;
|
|
617
|
+
* ```
|
|
618
|
+
*/
|
|
619
|
+
export function getChunkPath(chunkInfo: ChunkInfo): string {
|
|
620
|
+
return `chunks/${chunkInfo.hash}.json`;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// =============================================================================
|
|
624
|
+
// CHUNK CONTENT PARSING
|
|
625
|
+
// =============================================================================
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Parse chunk content from JSON string.
|
|
629
|
+
*
|
|
630
|
+
* Parses the serialized chunk content and returns the spans and events
|
|
631
|
+
* contained within. This function handles the internal chunk format.
|
|
632
|
+
*
|
|
633
|
+
* @param content - JSON string of chunk content
|
|
634
|
+
* @returns Object containing spans and events arrays
|
|
635
|
+
* @throws Error if content cannot be parsed or is malformed
|
|
636
|
+
*
|
|
637
|
+
* @example
|
|
638
|
+
* ```typescript
|
|
639
|
+
* const { spans, events } = parseChunkContent(chunk.content);
|
|
640
|
+
* console.log(`Chunk contains ${spans.length} spans and ${events.length} events`);
|
|
641
|
+
* ```
|
|
642
|
+
*/
|
|
643
|
+
export function parseChunkContent(content: string): { spans: TraceSpan[]; events: TraceEvent[] } {
|
|
644
|
+
try {
|
|
645
|
+
const parsed = JSON.parse(content) as unknown;
|
|
646
|
+
|
|
647
|
+
// Validate parsed content has expected structure
|
|
648
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
649
|
+
throw new Error("Chunk content must be an object");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const obj = parsed as Record<string, unknown>;
|
|
653
|
+
|
|
654
|
+
if (!Array.isArray(obj.spans)) {
|
|
655
|
+
throw new Error("Chunk content must have a 'spans' array");
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (!Array.isArray(obj.events)) {
|
|
659
|
+
throw new Error("Chunk content must have an 'events' array");
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Type cast with validation
|
|
663
|
+
const spans = obj.spans as TraceSpan[];
|
|
664
|
+
const events = obj.events as TraceEvent[];
|
|
665
|
+
|
|
666
|
+
// Basic validation of spans
|
|
667
|
+
for (const span of spans) {
|
|
668
|
+
if (typeof span.id !== "string" || typeof span.spanSeq !== "number") {
|
|
669
|
+
throw new Error("Invalid span structure in chunk content");
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Basic validation of events
|
|
674
|
+
for (const event of events) {
|
|
675
|
+
if (typeof event.id !== "string" || typeof event.seq !== "number") {
|
|
676
|
+
throw new Error("Invalid event structure in chunk content");
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return { spans, events };
|
|
681
|
+
} catch (error) {
|
|
682
|
+
if (error instanceof SyntaxError) {
|
|
683
|
+
throw new Error(`Invalid JSON in chunk content: ${error.message}`);
|
|
684
|
+
}
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
}
|