@chengxianglibra/dsh-data-analysis 0.1.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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/cordis.patch.yml +11 -0
  4. package/lib/bin/environment.js +91 -0
  5. package/lib/bridges.js +11 -0
  6. package/lib/client.js +744 -0
  7. package/lib/compatibility.js +66 -0
  8. package/lib/datasource/access.js +60 -0
  9. package/lib/datasource/bridge-programs.js +80 -0
  10. package/lib/datasource/bridge.js +204 -0
  11. package/lib/datasource/credentials.js +24 -0
  12. package/lib/datasource/index.js +4 -0
  13. package/lib/datasource/shell-env.js +386 -0
  14. package/lib/datasource/test.js +65 -0
  15. package/lib/disclosure/activation.js +369 -0
  16. package/lib/disclosure/bridge-program.js +11 -0
  17. package/lib/disclosure/bridge.js +65 -0
  18. package/lib/disclosure/help.js +227 -0
  19. package/lib/disclosure/index.js +3 -0
  20. package/lib/environment/binding.js +273 -0
  21. package/lib/environment/doctor.js +123 -0
  22. package/lib/environment/errors.js +11 -0
  23. package/lib/environment/index.js +7 -0
  24. package/lib/environment/runtime.js +558 -0
  25. package/lib/environment/source.js +6 -0
  26. package/lib/environment/subprocess.js +178 -0
  27. package/lib/environment/summary.js +19 -0
  28. package/lib/environment/workspace.js +49 -0
  29. package/lib/evidence/bridge-program.js +153 -0
  30. package/lib/evidence/bridge.js +166 -0
  31. package/lib/evidence/index.js +2 -0
  32. package/lib/evidence/sources.js +263 -0
  33. package/lib/index.js +7 -0
  34. package/lib/plugin.js +179 -0
  35. package/lib/types/bridges.d.ts +11 -0
  36. package/lib/types/client.d.ts +125 -0
  37. package/lib/types/compatibility.d.ts +29 -0
  38. package/lib/types/datasource/access.d.ts +27 -0
  39. package/lib/types/datasource/bridge.d.ts +53 -0
  40. package/lib/types/datasource/index.d.ts +8 -0
  41. package/lib/types/datasource/shell-env.d.ts +38 -0
  42. package/lib/types/datasource/test.d.ts +32 -0
  43. package/lib/types/disclosure/activation.d.ts +73 -0
  44. package/lib/types/disclosure/bridge.d.ts +28 -0
  45. package/lib/types/disclosure/help.d.ts +64 -0
  46. package/lib/types/disclosure/index.d.ts +5 -0
  47. package/lib/types/environment/binding.d.ts +26 -0
  48. package/lib/types/environment/doctor.d.ts +8 -0
  49. package/lib/types/environment/errors.d.ts +7 -0
  50. package/lib/types/environment/index.d.ts +9 -0
  51. package/lib/types/environment/runtime.d.ts +17 -0
  52. package/lib/types/environment/source.d.ts +7 -0
  53. package/lib/types/environment/subprocess.d.ts +19 -0
  54. package/lib/types/environment/types.d.ts +96 -0
  55. package/lib/types/environment/workspace.d.ts +10 -0
  56. package/lib/types/evidence/bridge.d.ts +40 -0
  57. package/lib/types/evidence/index.d.ts +4 -0
  58. package/lib/types/evidence/sources.d.ts +57 -0
  59. package/lib/types/index.d.ts +9 -0
  60. package/lib/types/plugin.d.ts +43 -0
  61. package/package.json +149 -0
  62. package/python/report-kit/dist/dsh_data_analysis_report_kit-3.0.0-py3-none-any.whl +0 -0
  63. package/skills/dsh-data-analysis-report/SKILL.md +57 -0
  64. package/skills/dsh-data-analysis-report/assets/marivo-artifact.js +113 -0
  65. package/skills/dsh-data-analysis-report/assets/marivo-session-dag.js +1201 -0
  66. package/skills/dsh-data-analysis-report/assets/report-data.js +449 -0
@@ -0,0 +1,1201 @@
1
+ ;((scope) => {
2
+ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
3
+ const TRACE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
4
+ const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
5
+ const QUERY_SQL_MAX_BYTES = 64 * 1024
6
+ const traces = new Map()
7
+ let nextId = 0
8
+
9
+ class ReportTraceError extends Error {
10
+ constructor(code, path, message) {
11
+ super(`${path}: ${message}`)
12
+ this.name = 'ReportTraceError'
13
+ this.code = code
14
+ this.path = path
15
+ }
16
+ }
17
+
18
+ function fail(path, message, code = 'trace-invalid') {
19
+ throw new ReportTraceError(code, path, message)
20
+ }
21
+
22
+ function isObject(value) {
23
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
24
+ }
25
+
26
+ function object(value, path) {
27
+ if (!isObject(value)) fail(path, 'must be an object')
28
+ return value
29
+ }
30
+
31
+ function exactKeys(value, path, required) {
32
+ const allowed = new Set(required)
33
+ for (const key of required) {
34
+ if (!Object.hasOwn(value, key)) fail(`${path}.${key}`, 'is required')
35
+ }
36
+ for (const key of Object.keys(value)) {
37
+ if (!allowed.has(key)) fail(`${path}.${key}`, 'is not allowed')
38
+ }
39
+ }
40
+
41
+ function string(value, path, nullable = false) {
42
+ if (nullable && value === null) return
43
+ if (typeof value !== 'string' || value.length === 0 || value.length > 2048) {
44
+ fail(path, 'must be a non-empty bounded string')
45
+ }
46
+ }
47
+
48
+ function timestamp(value, path) {
49
+ if (
50
+ typeof value !== 'string' ||
51
+ !TIMESTAMP.test(value) ||
52
+ !Number.isFinite(Date.parse(value))
53
+ ) {
54
+ fail(path, 'must be an RFC 3339 timestamp')
55
+ }
56
+ }
57
+
58
+ function querySql(value, path) {
59
+ if (
60
+ typeof value !== 'string' ||
61
+ value.length === 0 ||
62
+ new TextEncoder().encode(value).length > QUERY_SQL_MAX_BYTES
63
+ ) {
64
+ fail(path, `must be a non-empty UTF-8 string of at most ${QUERY_SQL_MAX_BYTES} bytes`)
65
+ }
66
+ }
67
+
68
+ function integer(value, path, nullable = false) {
69
+ if (nullable && value === null) return
70
+ if (!Number.isSafeInteger(value) || value < 0) fail(path, 'must be a non-negative safe integer')
71
+ }
72
+
73
+ function number(value, path) {
74
+ if (value !== null && (typeof value !== 'number' || !Number.isFinite(value))) {
75
+ fail(path, 'must be null or a finite number')
76
+ }
77
+ }
78
+
79
+ function identityArray(value, path, maximum = Number.MAX_SAFE_INTEGER) {
80
+ if (!Array.isArray(value) || value.length > maximum) {
81
+ fail(path, `must contain at most ${maximum} identities`)
82
+ }
83
+ const seen = new Set()
84
+ value.forEach((item, index) => {
85
+ string(item, `${path}[${index}]`)
86
+ if (seen.has(item)) fail(`${path}[${index}]`, 'must be unique')
87
+ seen.add(item)
88
+ })
89
+ return seen
90
+ }
91
+
92
+ function validateQuality(value, path) {
93
+ if (value === null) return
94
+ const quality = object(value, path)
95
+ const fields = [
96
+ 'coverage',
97
+ 'null_rate',
98
+ 'sample_size',
99
+ 'sample_coverage_min',
100
+ 'sample_coverage_avg',
101
+ 'sample_coverage_partial_buckets',
102
+ 'zero_denominator_rows',
103
+ 'evaluated_check_count',
104
+ 'failed_check_count',
105
+ 'warning_check_count',
106
+ ]
107
+ exactKeys(quality, path, fields)
108
+ for (const field of ['coverage', 'null_rate', 'sample_coverage_min', 'sample_coverage_avg']) {
109
+ number(quality[field], `${path}.${field}`)
110
+ }
111
+ for (const field of fields.filter(
112
+ (candidate) =>
113
+ !['coverage', 'null_rate', 'sample_coverage_min', 'sample_coverage_avg'].includes(
114
+ candidate,
115
+ ),
116
+ )) {
117
+ integer(quality[field], `${path}.${field}`, true)
118
+ }
119
+ }
120
+
121
+ function validateArtifact(value, path) {
122
+ const artifact = object(value, path)
123
+ exactKeys(artifact, path, [
124
+ 'ref',
125
+ 'family',
126
+ 'semantic_shape',
127
+ 'created_at',
128
+ 'produced_by_run',
129
+ 'analysis_purpose',
130
+ 'row_count',
131
+ 'materialization',
132
+ 'evidence',
133
+ 'quality',
134
+ 'issue_counts',
135
+ ])
136
+ string(artifact.ref, `${path}.ref`)
137
+ string(artifact.family, `${path}.family`)
138
+ string(artifact.semantic_shape, `${path}.semantic_shape`, true)
139
+ timestamp(artifact.created_at, `${path}.created_at`)
140
+ string(artifact.produced_by_run, `${path}.produced_by_run`, true)
141
+ string(artifact.analysis_purpose, `${path}.analysis_purpose`, true)
142
+ integer(artifact.row_count, `${path}.row_count`)
143
+ string(artifact.materialization, `${path}.materialization`)
144
+ const evidence = object(artifact.evidence, `${path}.evidence`)
145
+ exactKeys(evidence, `${path}.evidence`, [
146
+ 'status',
147
+ 'finding_count',
148
+ 'digest_present',
149
+ 'digest_item_count',
150
+ 'omitted_item_count',
151
+ ])
152
+ string(evidence.status, `${path}.evidence.status`)
153
+ integer(evidence.finding_count, `${path}.evidence.finding_count`)
154
+ if (typeof evidence.digest_present !== 'boolean') {
155
+ fail(`${path}.evidence.digest_present`, 'must be boolean')
156
+ }
157
+ integer(evidence.digest_item_count, `${path}.evidence.digest_item_count`)
158
+ integer(evidence.omitted_item_count, `${path}.evidence.omitted_item_count`)
159
+ validateQuality(artifact.quality, `${path}.quality`)
160
+ const issueCounts = object(artifact.issue_counts, `${path}.issue_counts`)
161
+ exactKeys(issueCounts, `${path}.issue_counts`, ['warning', 'blocking'])
162
+ integer(issueCounts.warning, `${path}.issue_counts.warning`)
163
+ integer(issueCounts.blocking, `${path}.issue_counts.blocking`)
164
+ return artifact
165
+ }
166
+
167
+ function validateQueries(value, path, detail, omitted) {
168
+ if (!Array.isArray(value) || value.length > 100) fail(path, 'must contain at most 100 queries')
169
+ integer(omitted, `${path}_omitted`)
170
+ if (detail === 'reader' && value.length !== 0) fail(path, 'must be omitted in reader detail')
171
+ value.forEach((candidate, index) => {
172
+ const queryPath = `${path}[${index}]`
173
+ const query = object(candidate, queryPath)
174
+ const fields = [
175
+ 'query_id',
176
+ 'datasource',
177
+ 'dialect',
178
+ 'sql',
179
+ 'sql_digest',
180
+ 'row_count',
181
+ 'duration_ms',
182
+ 'started_at',
183
+ 'finished_at',
184
+ 'status',
185
+ ]
186
+ exactKeys(query, queryPath, fields)
187
+ for (const field of ['query_id', 'datasource', 'dialect', 'sql_digest', 'status']) {
188
+ string(query[field], `${queryPath}.${field}`)
189
+ }
190
+ querySql(query.sql, `${queryPath}.sql`)
191
+ integer(query.row_count, `${queryPath}.row_count`)
192
+ integer(query.duration_ms, `${queryPath}.duration_ms`)
193
+ timestamp(query.started_at, `${queryPath}.started_at`)
194
+ timestamp(query.finished_at, `${queryPath}.finished_at`)
195
+ })
196
+ }
197
+
198
+ function validateRun(value, path, detail) {
199
+ const run = object(value, path)
200
+ string(run.lifecycle, `${path}.lifecycle`)
201
+ const common = [
202
+ 'run_id',
203
+ 'lifecycle',
204
+ 'capability_id',
205
+ 'analysis_purpose',
206
+ 'input_artifact_refs',
207
+ 'started_at',
208
+ ]
209
+ if (run.lifecycle === 'incomplete') exactKeys(run, path, common)
210
+ else if (run.lifecycle === 'succeeded') {
211
+ exactKeys(run, path, [
212
+ ...common,
213
+ 'finished_at',
214
+ 'output_artifact_ref',
215
+ 'output_mode',
216
+ 'queries',
217
+ 'queries_omitted',
218
+ ])
219
+ } else if (run.lifecycle === 'failed')
220
+ exactKeys(run, path, [...common, 'failed_at', 'failure', 'queries', 'queries_omitted'])
221
+ else fail(`${path}.lifecycle`, 'is unsupported')
222
+ string(run.run_id, `${path}.run_id`)
223
+ string(run.capability_id, `${path}.capability_id`)
224
+ string(run.analysis_purpose, `${path}.analysis_purpose`, true)
225
+ identityArray(run.input_artifact_refs, `${path}.input_artifact_refs`)
226
+ timestamp(run.started_at, `${path}.started_at`)
227
+ if (run.lifecycle === 'succeeded') {
228
+ timestamp(run.finished_at, `${path}.finished_at`)
229
+ string(run.output_artifact_ref, `${path}.output_artifact_ref`)
230
+ if (!['produced', 'reused'].includes(run.output_mode))
231
+ fail(`${path}.output_mode`, 'is unsupported')
232
+ validateQueries(run.queries, `${path}.queries`, detail, run.queries_omitted)
233
+ }
234
+ if (run.lifecycle === 'failed') {
235
+ timestamp(run.failed_at, `${path}.failed_at`)
236
+ const failure = object(run.failure, `${path}.failure`)
237
+ exactKeys(failure, `${path}.failure`, ['error_type', 'location'])
238
+ string(failure.error_type, `${path}.failure.error_type`)
239
+ string(failure.location, `${path}.failure.location`, true)
240
+ validateQueries(run.queries, `${path}.queries`, detail, run.queries_omitted)
241
+ }
242
+ return run
243
+ }
244
+
245
+ function validateTrace(value, registrationId) {
246
+ const trace = object(value, '$')
247
+ exactKeys(trace, '$', [
248
+ 'schema',
249
+ 'detail',
250
+ 'trace_id',
251
+ 'emitted_at',
252
+ 'session_id',
253
+ 'report_artifact_refs',
254
+ 'artifacts',
255
+ 'runs',
256
+ 'edges',
257
+ 'root_run_ids',
258
+ 'head_artifact_refs',
259
+ 'failed_run_ids',
260
+ 'incomplete_run_ids',
261
+ 'boundary_artifact_refs',
262
+ 'boundary_run_ids',
263
+ 'truncated',
264
+ 'projection',
265
+ 'read_boundaries',
266
+ ])
267
+ if (trace.schema !== 'dsh-data-analysis-session-trace/v2') fail('$.schema', 'is unsupported')
268
+ if (!['reader', 'audit'].includes(trace.detail)) fail('$.detail', 'is unsupported')
269
+ if (typeof trace.trace_id !== 'string' || !TRACE_ID.test(trace.trace_id))
270
+ fail('$.trace_id', 'is invalid')
271
+ if (trace.trace_id !== registrationId) {
272
+ fail('$.trace_id', 'must match the registered id', 'trace-id-mismatch')
273
+ }
274
+ timestamp(trace.emitted_at, '$.emitted_at')
275
+ string(trace.session_id, '$.session_id')
276
+ const reportRefs = identityArray(trace.report_artifact_refs, '$.report_artifact_refs', 20)
277
+ if (reportRefs.size === 0) fail('$.report_artifact_refs', 'must not be empty')
278
+ if (!Array.isArray(trace.artifacts) || trace.artifacts.length > 200) {
279
+ fail('$.artifacts', 'must contain at most 200 Artifacts')
280
+ }
281
+ if (!Array.isArray(trace.runs) || trace.runs.length > 200)
282
+ fail('$.runs', 'must contain at most 200 Runs')
283
+ if (trace.artifacts.length + trace.runs.length > 200)
284
+ fail('$', 'must contain at most 200 nodes')
285
+ const artifacts = trace.artifacts.map((artifact, index) =>
286
+ validateArtifact(artifact, `$.artifacts[${index}]`),
287
+ )
288
+ const runs = trace.runs.map((run, index) => validateRun(run, `$.runs[${index}]`, trace.detail))
289
+ const artifactSet = new Set(artifacts.map((artifact) => artifact.ref))
290
+ const runSet = new Set(runs.map((run) => run.run_id))
291
+ if (artifactSet.size !== artifacts.length) fail('$.artifacts', 'refs must be unique')
292
+ if (runSet.size !== runs.length) fail('$.runs', 'run_ids must be unique')
293
+ for (const ref of reportRefs)
294
+ if (!artifactSet.has(ref)) fail('$.report_artifact_refs', 'contains a dangling ref')
295
+
296
+ const roots = identityArray(trace.root_run_ids, '$.root_run_ids')
297
+ const heads = identityArray(trace.head_artifact_refs, '$.head_artifact_refs')
298
+ const failed = identityArray(trace.failed_run_ids, '$.failed_run_ids')
299
+ const incomplete = identityArray(trace.incomplete_run_ids, '$.incomplete_run_ids')
300
+ const boundaryArtifacts = identityArray(
301
+ trace.boundary_artifact_refs,
302
+ '$.boundary_artifact_refs',
303
+ )
304
+ const boundaryRuns = identityArray(trace.boundary_run_ids, '$.boundary_run_ids')
305
+ for (const id of [...roots, ...failed, ...incomplete, ...boundaryRuns]) {
306
+ if (!runSet.has(id)) fail('$', 'contains a dangling Run identity')
307
+ }
308
+ for (const ref of [...heads, ...boundaryArtifacts]) {
309
+ if (!artifactSet.has(ref)) fail('$', 'contains a dangling Artifact identity')
310
+ }
311
+ if (typeof trace.truncated !== 'boolean') fail('$.truncated', 'must be boolean')
312
+ if (!trace.truncated && (boundaryArtifacts.size > 0 || boundaryRuns.size > 0)) {
313
+ fail('$', 'boundary identities require truncated=true')
314
+ }
315
+ if (!Array.isArray(trace.edges) || trace.edges.length > 1000)
316
+ fail('$.edges', 'must contain at most 1000 edges')
317
+ const edgeKeys = new Set()
318
+ trace.edges.forEach((candidate, index) => {
319
+ const path = `$.edges[${index}]`
320
+ const edge = object(candidate, path)
321
+ exactKeys(edge, path, ['kind', 'run_id', 'artifact_ref'])
322
+ if (!['consumes', 'produces', 'reuses'].includes(edge.kind))
323
+ fail(`${path}.kind`, 'is unsupported')
324
+ string(edge.run_id, `${path}.run_id`)
325
+ string(edge.artifact_ref, `${path}.artifact_ref`)
326
+ if (!runSet.has(edge.run_id) || !artifactSet.has(edge.artifact_ref))
327
+ fail(path, 'contains a dangling identity')
328
+ const key = `${edge.kind}\u0000${edge.run_id}\u0000${edge.artifact_ref}`
329
+ if (edgeKeys.has(key)) fail(path, 'must be unique')
330
+ edgeKeys.add(key)
331
+ })
332
+ for (const run of runs) {
333
+ for (const ref of run.input_artifact_refs) {
334
+ if (!artifactSet.has(ref) && !boundaryRuns.has(run.run_id))
335
+ fail('$.runs', 'contains a dangling input ref')
336
+ }
337
+ if (run.lifecycle === 'succeeded') {
338
+ if (!artifactSet.has(run.output_artifact_ref) && !boundaryRuns.has(run.run_id)) {
339
+ fail('$.runs', 'contains a dangling output ref')
340
+ }
341
+ }
342
+ }
343
+ for (const artifact of artifacts) {
344
+ if (
345
+ artifact.produced_by_run !== null &&
346
+ !runSet.has(artifact.produced_by_run) &&
347
+ !boundaryArtifacts.has(artifact.ref)
348
+ ) {
349
+ fail('$.artifacts', 'contains a dangling producer')
350
+ }
351
+ }
352
+
353
+ const projection = object(trace.projection, '$.projection')
354
+ exactKeys(projection, '$.projection', ['run_arguments', 'failure_values', 'query_bind_values'])
355
+ if (
356
+ projection.run_arguments !== 'omitted' ||
357
+ projection.failure_values !== 'omitted' ||
358
+ projection.query_bind_values !== 'omitted'
359
+ ) {
360
+ fail('$.projection', 'must omit private values')
361
+ }
362
+ if (
363
+ !Array.isArray(trace.read_boundaries) ||
364
+ trace.read_boundaries.length !== 3 ||
365
+ trace.read_boundaries[0] !== 'semantic_authority_not_checked' ||
366
+ trace.read_boundaries[1] !== 'datasource_freshness_not_checked' ||
367
+ trace.read_boundaries[2] !== 'report_entailment_not_checked'
368
+ ) {
369
+ fail('$.read_boundaries', 'must contain the closed V1 boundary sequence')
370
+ }
371
+ return trace
372
+ }
373
+
374
+ function deepFreeze(value, seen = new WeakSet()) {
375
+ if ((!isObject(value) && !Array.isArray(value)) || seen.has(value)) return value
376
+ seen.add(value)
377
+ for (const child of Object.values(value)) deepFreeze(child, seen)
378
+ return Object.freeze(value)
379
+ }
380
+
381
+ function validateId(id) {
382
+ if (typeof id !== 'string' || !TRACE_ID.test(id)) fail('$id', 'is invalid', 'trace-id-invalid')
383
+ }
384
+
385
+ function register(id, trace) {
386
+ validateId(id)
387
+ if (traces.has(id)) fail('$id', 'is already registered', 'trace-id-duplicate')
388
+ traces.set(id, deepFreeze(validateTrace(trace, id)))
389
+ }
390
+
391
+ function get(id) {
392
+ validateId(id)
393
+ const trace = traces.get(id)
394
+ if (!trace) fail('$id', 'is not registered', 'trace-not-found')
395
+ return trace
396
+ }
397
+
398
+ function has(id) {
399
+ validateId(id)
400
+ return traces.has(id)
401
+ }
402
+
403
+ function list() {
404
+ return Object.freeze([...traces.keys()])
405
+ }
406
+
407
+ function svgElement(tag, attributes = {}) {
408
+ const node = document.createElementNS(SVG_NAMESPACE, tag)
409
+ for (const [name, value] of Object.entries(attributes)) node.setAttribute(name, String(value))
410
+ return node
411
+ }
412
+
413
+ function text(tag, value, className) {
414
+ const node = document.createElement(tag)
415
+ if (className) node.className = className
416
+ node.textContent = String(value)
417
+ return node
418
+ }
419
+
420
+ function runLabel(run) {
421
+ return run.analysis_purpose || run.capability_id
422
+ }
423
+
424
+ function artifactLabel(artifact) {
425
+ return artifact.family
426
+ }
427
+
428
+ function compactLabel(value) {
429
+ return value.length <= 18 ? value : `${value.slice(0, 17)}…`
430
+ }
431
+
432
+ function graphComponents(trace) {
433
+ const ordered = [
434
+ ...trace.runs.map((run) => `run:${run.run_id}`),
435
+ ...trace.artifacts.map((artifact) => `artifact:${artifact.ref}`),
436
+ ]
437
+ const neighbors = new Map(ordered.map((node) => [node, new Set()]))
438
+ for (const edge of trace.edges) {
439
+ const run = `run:${edge.run_id}`
440
+ const artifact = `artifact:${edge.artifact_ref}`
441
+ neighbors.get(run).add(artifact)
442
+ neighbors.get(artifact).add(run)
443
+ }
444
+ const seen = new Set()
445
+ const components = []
446
+ for (const start of ordered) {
447
+ if (seen.has(start)) continue
448
+ const pending = [start]
449
+ const nodes = new Set()
450
+ while (pending.length > 0) {
451
+ const node = pending.shift()
452
+ if (seen.has(node)) continue
453
+ seen.add(node)
454
+ nodes.add(node)
455
+ for (const neighbor of neighbors.get(node)) if (!seen.has(neighbor)) pending.push(neighbor)
456
+ }
457
+ components.push({
458
+ nodes,
459
+ runs: trace.runs.filter((run) => nodes.has(`run:${run.run_id}`)),
460
+ artifacts: trace.artifacts.filter((artifact) => nodes.has(`artifact:${artifact.ref}`)),
461
+ edges: trace.edges.filter(
462
+ (edge) => nodes.has(`run:${edge.run_id}`) && nodes.has(`artifact:${edge.artifact_ref}`),
463
+ ),
464
+ })
465
+ }
466
+ return components
467
+ }
468
+
469
+ function positions(component) {
470
+ const ordered = [
471
+ ...component.runs.map((run) => `run:${run.run_id}`),
472
+ ...component.artifacts.map((artifact) => `artifact:${artifact.ref}`),
473
+ ]
474
+ const predecessor = new Map(ordered.map((node) => [node, []]))
475
+ for (const edge of component.edges) {
476
+ const source =
477
+ edge.kind === 'consumes' ? `artifact:${edge.artifact_ref}` : `run:${edge.run_id}`
478
+ const target =
479
+ edge.kind === 'consumes' ? `run:${edge.run_id}` : `artifact:${edge.artifact_ref}`
480
+ predecessor.get(target).push(source)
481
+ }
482
+ const levels = new Map()
483
+ while (levels.size < ordered.length) {
484
+ let progressed = false
485
+ for (const node of ordered) {
486
+ if (levels.has(node)) continue
487
+ const parents = predecessor.get(node)
488
+ if (parents.every((parent) => levels.has(parent))) {
489
+ levels.set(
490
+ node,
491
+ parents.length === 0 ? 0 : Math.max(...parents.map((parent) => levels.get(parent))) + 1,
492
+ )
493
+ progressed = true
494
+ }
495
+ }
496
+ if (!progressed) fail('$.edges', 'cannot lay out a cyclic graph')
497
+ }
498
+ const byLevel = new Map()
499
+ for (const node of ordered) {
500
+ const level = levels.get(node)
501
+ const nodes = byLevel.get(level) ?? []
502
+ nodes.push(node)
503
+ byLevel.set(level, nodes)
504
+ }
505
+ const result = new Map()
506
+ for (const [level, nodes] of byLevel) {
507
+ nodes.forEach((node, index) => {
508
+ result.set(node, { x: 40 + level * 230, y: 40 + index * 126 })
509
+ })
510
+ }
511
+ return result
512
+ }
513
+
514
+ function nodeGroup(kind, title, subtitle, position, attributes, live, activate) {
515
+ const group = svgElement('g', {
516
+ class:
517
+ attributes['data-boundary'] === 'true' ? 'trace-node trace-boundary-node' : 'trace-node',
518
+ 'data-node-kind': kind,
519
+ role: 'button',
520
+ tabindex: 0,
521
+ transform: `translate(${position.x} ${position.y})`,
522
+ 'aria-label': `${kind === 'run' ? '分析动作' : 'Frame'}:${title};${subtitle}`,
523
+ ...attributes,
524
+ })
525
+ if (kind === 'run') group.append(svgElement('rect', { width: 170, height: 76, rx: 12 }))
526
+ else group.append(svgElement('path', { d: 'M18 0 H152 L170 38 L152 76 H18 L0 38 Z' }))
527
+ const heading = svgElement('text', { x: 85, y: 31, 'text-anchor': 'middle' })
528
+ heading.textContent = compactLabel(title)
529
+ const detail = svgElement('text', { x: 85, y: 53, 'text-anchor': 'middle' })
530
+ detail.textContent = compactLabel(subtitle)
531
+ group.append(heading, detail)
532
+ group.addEventListener('focus', () => {
533
+ live.textContent = `${kind === 'run' ? '分析动作' : 'Frame'}:${title};${subtitle}`
534
+ })
535
+ group.addEventListener('click', activate)
536
+ group.addEventListener('keydown', (event) => {
537
+ if (event.key === 'Enter' || event.key === ' ') activate()
538
+ })
539
+ return group
540
+ }
541
+
542
+ function definitionList(entries, className = 'trace-detail-list') {
543
+ const list = document.createElement('dl')
544
+ list.className = className
545
+ for (const [label, value] of entries) {
546
+ list.append(text('dt', label), text('dd', value === null || value === '' ? '—' : value))
547
+ }
548
+ return list
549
+ }
550
+
551
+ function elapsed(startedAt, finishedAt) {
552
+ if (!finishedAt) return '进行中'
553
+ return `${Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt))} ms`
554
+ }
555
+
556
+ function matchingDataset(sessionId, artifactRef) {
557
+ const registry = scope.ReportData
558
+ if (!registry || typeof registry.list !== 'function') return null
559
+ for (const id of registry.list()) {
560
+ const dataset = registry.get(id)
561
+ if (
562
+ dataset.source.kind === 'marivo_artifact' &&
563
+ dataset.source.artifact.session_id === sessionId &&
564
+ dataset.source.artifact.ref === artifactRef
565
+ ) {
566
+ return dataset
567
+ }
568
+ }
569
+ return null
570
+ }
571
+
572
+ function previewTable(dataset) {
573
+ const section = document.createElement('section')
574
+ section.className = 'trace-frame-preview'
575
+ section.append(text('h5', '数据预览'))
576
+ section.append(
577
+ text(
578
+ 'p',
579
+ `显示 ${Math.min(10, dataset.table.rows.length)} / 快照 ${dataset.table.written_rows} / 总计 ${dataset.table.total_rows} / 省略 ${dataset.table.omitted_rows} 行`,
580
+ 'trace-frame-count',
581
+ ),
582
+ )
583
+ const wrap = document.createElement('div')
584
+ wrap.className = 'table-wrap trace-frame-table-wrap'
585
+ const table = document.createElement('table')
586
+ table.className = 'data-table trace-frame-table'
587
+ table.append(text('caption', 'Artifact dataset 有界预览', 'sr-only'))
588
+ const header = document.createElement('tr')
589
+ for (const column of dataset.table.columns) {
590
+ const cell = text('th', column.name)
591
+ cell.scope = 'col'
592
+ header.append(cell)
593
+ }
594
+ const head = document.createElement('thead')
595
+ head.append(header)
596
+ const body = document.createElement('tbody')
597
+ for (const row of dataset.table.rows.slice(0, 10)) {
598
+ const item = document.createElement('tr')
599
+ for (const value of row) item.append(text('td', value === null ? '—' : value))
600
+ body.append(item)
601
+ }
602
+ table.append(head, body)
603
+ wrap.append(table)
604
+ section.append(wrap)
605
+ return section
606
+ }
607
+
608
+ function sqlTokens(sql) {
609
+ const tokens = []
610
+ let index = 0
611
+ while (index < sql.length) {
612
+ const character = sql[index]
613
+ if (/\s/.test(character)) {
614
+ index += 1
615
+ continue
616
+ }
617
+ if (sql.startsWith('--', index)) {
618
+ const end = sql.indexOf('\n', index)
619
+ tokens.push({ kind: 'comment', text: sql.slice(index, end < 0 ? sql.length : end) })
620
+ index = end < 0 ? sql.length : end + 1
621
+ continue
622
+ }
623
+ if (sql.startsWith('/*', index)) {
624
+ const end = sql.indexOf('*/', index + 2)
625
+ const stop = end < 0 ? sql.length : end + 2
626
+ tokens.push({ kind: 'comment', text: sql.slice(index, stop) })
627
+ index = stop
628
+ continue
629
+ }
630
+ if (character === "'" || character === '"' || character === '`') {
631
+ const quote = character
632
+ let end = index + 1
633
+ while (end < sql.length) {
634
+ if (sql[end] === quote) {
635
+ if (sql[end + 1] === quote) {
636
+ end += 2
637
+ continue
638
+ }
639
+ end += 1
640
+ break
641
+ }
642
+ end += 1
643
+ }
644
+ tokens.push({ kind: 'quoted', text: sql.slice(index, end) })
645
+ index = end
646
+ continue
647
+ }
648
+ const word = sql.slice(index).match(/^[A-Za-z_][A-Za-z0-9_$]*/)?.[0]
649
+ if (word) {
650
+ tokens.push({ kind: 'word', text: word })
651
+ index += word.length
652
+ continue
653
+ }
654
+ const number = sql.slice(index).match(/^\d+(?:\.\d+)?(?:[Ee][+-]?\d+)?/)?.[0]
655
+ if (number) {
656
+ tokens.push({ kind: 'number', text: number })
657
+ index += number.length
658
+ continue
659
+ }
660
+ const operator = ['->>', '->', '<=', '>=', '<>', '!=', '||', '::'].find((value) =>
661
+ sql.startsWith(value, index),
662
+ )
663
+ if (operator) {
664
+ tokens.push({ kind: 'operator', text: operator })
665
+ index += operator.length
666
+ continue
667
+ }
668
+ tokens.push({
669
+ kind: ['(', ')', ',', '.', ';'].includes(character) ? 'punctuation' : 'operator',
670
+ text: character,
671
+ })
672
+ index += 1
673
+ }
674
+ return tokens
675
+ }
676
+
677
+ function formatSql(sql) {
678
+ const tokens = sqlTokens(sql)
679
+ const lines = []
680
+ let line = ''
681
+ let indentation = 0
682
+ let continuation = 0
683
+ let previous = null
684
+ const parentheses = []
685
+ const scopes = [{ depth: 0, clause: null }]
686
+ const clauseKeywords = new Set([
687
+ 'SELECT',
688
+ 'FROM',
689
+ 'WHERE',
690
+ 'GROUP',
691
+ 'HAVING',
692
+ 'ORDER',
693
+ 'LIMIT',
694
+ 'OFFSET',
695
+ 'FETCH',
696
+ 'UNION',
697
+ 'EXCEPT',
698
+ 'INTERSECT',
699
+ 'JOIN',
700
+ 'ON',
701
+ ])
702
+ const flush = (extra = 0) => {
703
+ if (line.trim()) lines.push(line.trimEnd())
704
+ line = ''
705
+ continuation = extra
706
+ previous = null
707
+ }
708
+ const write = (token) => {
709
+ if (!line) line = ' '.repeat(indentation + continuation)
710
+ const current = token.text
711
+ const prior = previous?.text
712
+ const keyword = previous?.kind === 'word' ? prior.toUpperCase() : ''
713
+ const spaceBefore =
714
+ previous !== null &&
715
+ ![',', '.', ')', ';'].includes(current) &&
716
+ !['(', '.'].includes(prior) &&
717
+ !(
718
+ current === '(' &&
719
+ previous.kind === 'word' &&
720
+ !['IN', 'EXISTS', 'FROM', 'JOIN', 'ON', 'WHERE'].includes(keyword)
721
+ )
722
+ if (spaceBefore && !line.endsWith(' ')) line += ' '
723
+ line += current
724
+ previous = token
725
+ }
726
+ const nextWord = (start) => {
727
+ for (let index = start; index < tokens.length; index += 1) {
728
+ if (tokens[index].kind !== 'comment') return tokens[index].text.toUpperCase()
729
+ }
730
+ return ''
731
+ }
732
+
733
+ tokens.forEach((token, index) => {
734
+ const upper = token.kind === 'word' ? token.text.toUpperCase() : ''
735
+ const next = nextWord(index + 1)
736
+ if (token.kind === 'comment') {
737
+ flush()
738
+ write(token)
739
+ flush()
740
+ return
741
+ }
742
+ if (token.text === '(') {
743
+ const subquery = next === 'SELECT' || next === 'WITH'
744
+ write(token)
745
+ parentheses.push({ subquery })
746
+ if (subquery) {
747
+ indentation += 1
748
+ scopes.push({ depth: parentheses.length, clause: null })
749
+ flush()
750
+ }
751
+ return
752
+ }
753
+ if (token.text === ')') {
754
+ const parenthesis = parentheses.pop()
755
+ if (parenthesis?.subquery) {
756
+ indentation = Math.max(0, indentation - 1)
757
+ scopes.pop()
758
+ flush()
759
+ }
760
+ write(token)
761
+ return
762
+ }
763
+ const joinedClause =
764
+ ['LEFT', 'RIGHT', 'FULL', 'INNER', 'CROSS'].includes(upper) && next === 'JOIN'
765
+ if (clauseKeywords.has(upper) || joinedClause) {
766
+ flush()
767
+ write(token)
768
+ scopes.at(-1).clause = upper.toLowerCase()
769
+ return
770
+ }
771
+ if (
772
+ (upper === 'AND' || upper === 'OR') &&
773
+ ['where', 'on', 'having'].includes(scopes.at(-1).clause)
774
+ ) {
775
+ flush(1)
776
+ write(token)
777
+ return
778
+ }
779
+ if (token.text === ',') {
780
+ write(token)
781
+ const scope = scopes.at(-1)
782
+ if (
783
+ parentheses.length === scope.depth &&
784
+ ['select', 'group', 'order'].includes(scope.clause)
785
+ ) {
786
+ flush(1)
787
+ }
788
+ return
789
+ }
790
+ write(token)
791
+ if (token.text === ';') flush()
792
+ })
793
+ flush()
794
+ return lines.join('\n')
795
+ }
796
+
797
+ function queryDetail(query, index, total) {
798
+ const disclosure = document.createElement('details')
799
+ disclosure.className = 'trace-query'
800
+ if (total === 1) disclosure.open = true
801
+ disclosure.append(
802
+ text(
803
+ 'summary',
804
+ `Query ${index + 1} · ${query.datasource} · ${query.status} · ${query.duration_ms} ms`,
805
+ ),
806
+ )
807
+ const body = document.createElement('div')
808
+ body.className = 'trace-query-body'
809
+ body.append(
810
+ definitionList(
811
+ [
812
+ ['数据源', query.datasource],
813
+ ['方言', query.dialect],
814
+ ['状态', query.status],
815
+ ['返回行数', query.row_count],
816
+ ['耗时', `${query.duration_ms} ms`],
817
+ ['开始', query.started_at],
818
+ ['完成', query.finished_at],
819
+ ['Query ID', query.query_id],
820
+ ['SQL digest', query.sql_digest],
821
+ ],
822
+ 'trace-query-meta',
823
+ ),
824
+ )
825
+ const sql = document.createElement('pre')
826
+ sql.className = 'trace-query-sql'
827
+ sql.setAttribute('aria-label', `Query ${index + 1} SQL`)
828
+ sql.append(text('code', formatSql(query.sql)))
829
+ body.append(sql)
830
+ disclosure.append(body)
831
+ return disclosure
832
+ }
833
+
834
+ function querySection(run) {
835
+ if (!Object.hasOwn(run, 'queries')) return null
836
+ if (run.queries.length === 0) {
837
+ if (run.queries_omitted === 0) return null
838
+ return text(
839
+ 'p',
840
+ `该动作执行了 ${run.queries_omitted} 条 Query;reader 投影未包含 SQL。`,
841
+ 'trace-query-omitted',
842
+ )
843
+ }
844
+ const section = document.createElement('section')
845
+ section.className = 'trace-query-section'
846
+ section.append(text('h5', `查询详情(${run.queries.length})`))
847
+ run.queries.forEach((query, index) => {
848
+ section.append(queryDetail(query, index, run.queries.length))
849
+ })
850
+ return section
851
+ }
852
+
853
+ function runDetail(trace, run) {
854
+ const article = document.createElement('article')
855
+ article.className = 'trace-detail'
856
+ article.dataset.nodeKey = `run:${run.run_id}`
857
+ article.append(text('p', `分析动作 · ${run.lifecycle}`, 'trace-detail-kind'))
858
+ article.append(text('h4', runLabel(run)))
859
+ const inputFrames = run.input_artifact_refs
860
+ .map((ref) => trace.artifacts.find((artifact) => artifact.ref === ref))
861
+ .filter(Boolean)
862
+ .map(artifactLabel)
863
+ const outputFrame =
864
+ run.lifecycle === 'succeeded'
865
+ ? trace.artifacts.find((artifact) => artifact.ref === run.output_artifact_ref)
866
+ : null
867
+ const entries = [
868
+ ['能力', run.capability_id],
869
+ ['状态', run.lifecycle],
870
+ ['开始', run.started_at],
871
+ [
872
+ run.lifecycle === 'failed' ? '失败' : '完成',
873
+ run.lifecycle === 'succeeded'
874
+ ? run.finished_at
875
+ : run.lifecycle === 'failed'
876
+ ? run.failed_at
877
+ : null,
878
+ ],
879
+ [
880
+ '耗时',
881
+ elapsed(
882
+ run.started_at,
883
+ run.lifecycle === 'succeeded'
884
+ ? run.finished_at
885
+ : run.lifecycle === 'failed'
886
+ ? run.failed_at
887
+ : null,
888
+ ),
889
+ ],
890
+ ['输入 Frame', inputFrames.join('、') || '无'],
891
+ ]
892
+ if (run.lifecycle === 'succeeded') {
893
+ entries.push(['输出 Frame', outputFrame ? artifactLabel(outputFrame) : '链路边界外'])
894
+ entries.push(['输出模式', run.output_mode])
895
+ }
896
+ if (run.lifecycle === 'failed') entries.push(['失败类型', run.failure.error_type])
897
+ article.append(definitionList(entries))
898
+ const queries = querySection(run)
899
+ if (queries) article.append(queries)
900
+ return article
901
+ }
902
+
903
+ function artifactDetail(trace, artifact) {
904
+ const article = document.createElement('article')
905
+ article.className = 'trace-detail'
906
+ article.dataset.nodeKey = `artifact:${artifact.ref}`
907
+ article.append(text('h4', artifactLabel(artifact)))
908
+ const dataset = matchingDataset(trace.session_id, artifact.ref)
909
+ if (dataset) {
910
+ const component = scope.MarivoArtifact
911
+ if (!component || typeof component.render !== 'function') {
912
+ fail('$MarivoArtifact', 'must be loaded before rendering a Session DAG')
913
+ }
914
+ const summary = document.createElement('div')
915
+ summary.className = 'trace-artifact-summary'
916
+ component.render(summary, dataset.dataset_id)
917
+ article.append(summary, previewTable(dataset))
918
+ } else {
919
+ article.append(text('p', '此页面未注册该 Artifact 的 dataset 预览。', 'trace-frame-count'))
920
+ }
921
+ return article
922
+ }
923
+
924
+ function traceLegend(trace, componentCount) {
925
+ const counts = [
926
+ `${trace.runs.length} 个分析动作`,
927
+ `${trace.artifacts.length} 个 Frame`,
928
+ `${componentCount} 条链路`,
929
+ ]
930
+ const overview = document.createElement('header')
931
+ overview.className = 'trace-overview'
932
+ overview.append(text('p', counts.join(' · '), 'trace-count'))
933
+ const legend = document.createElement('div')
934
+ legend.className = 'trace-legend'
935
+ for (const [label, className] of [
936
+ ['分析动作', 'trace-legend-run'],
937
+ ['Frame', 'trace-legend-artifact'],
938
+ ]) {
939
+ legend.append(text('span', label, className))
940
+ }
941
+ overview.append(legend)
942
+ return overview
943
+ }
944
+
945
+ function readBoundaryDisclosure() {
946
+ return text(
947
+ 'p',
948
+ '该链路记录分析执行关系,不证明当前语义权威、数据源新鲜度或报告结论正确性。',
949
+ 'trace-boundary',
950
+ )
951
+ }
952
+
953
+ function renderComponent(trace, component, componentIndex, live) {
954
+ const section = document.createElement('section')
955
+ section.className = 'trace-component'
956
+ const heading = document.createElement('div')
957
+ heading.className = 'trace-component-heading'
958
+ heading.append(text('h3', `分析链路 ${componentIndex + 1}`))
959
+ const controls = document.createElement('div')
960
+ controls.className = 'trace-controls'
961
+ controls.setAttribute('aria-label', '链路缩放')
962
+ heading.append(controls)
963
+ section.append(heading)
964
+
965
+ const workspace = document.createElement('div')
966
+ workspace.className = 'trace-workspace'
967
+ const canvasWrap = document.createElement('div')
968
+ canvasWrap.className = 'trace-canvas-wrap'
969
+ const detailsPanel = document.createElement('aside')
970
+ detailsPanel.className = 'trace-detail-panel'
971
+ detailsPanel.setAttribute('aria-live', 'polite')
972
+ const detailByKey = new Map()
973
+ for (const run of component.runs) {
974
+ const detail = runDetail(trace, run)
975
+ detailByKey.set(`run:${run.run_id}`, detail)
976
+ detailsPanel.append(detail)
977
+ }
978
+ for (const artifact of component.artifacts) {
979
+ const detail = artifactDetail(trace, artifact)
980
+ detailByKey.set(`artifact:${artifact.ref}`, detail)
981
+ detailsPanel.append(detail)
982
+ }
983
+ const nodesByKey = new Map()
984
+ const activate = (key) => {
985
+ for (const [candidate, detail] of detailByKey) {
986
+ const selected = candidate === key
987
+ detail.hidden = !selected
988
+ detail.dataset.active = String(selected)
989
+ detail.setAttribute('aria-hidden', String(!selected))
990
+ }
991
+ for (const [candidate, node] of nodesByKey) node.dataset.selected = String(candidate === key)
992
+ }
993
+
994
+ const layout = positions(component)
995
+ const maxX = Math.max(...[...layout.values()].map((position) => position.x), 40)
996
+ const maxY = Math.max(...[...layout.values()].map((position) => position.y), 40)
997
+ const svgId = ++nextId
998
+ const svg = svgElement('svg', {
999
+ class: 'trace-svg',
1000
+ role: 'group',
1001
+ viewBox: `0 0 ${maxX + 230} ${maxY + 130}`,
1002
+ 'aria-labelledby': `trace-title-${svgId}`,
1003
+ })
1004
+ const title = svgElement('title', { id: `trace-title-${svgId}` })
1005
+ title.textContent = `分析链路 ${componentIndex + 1}`
1006
+ const description = svgElement('desc')
1007
+ description.textContent =
1008
+ '选择分析动作或 Frame 节点可查看审计详情;成功状态不表示报告结论可信。'
1009
+ const definitions = svgElement('defs')
1010
+ const marker = svgElement('marker', {
1011
+ id: `trace-arrow-${svgId}`,
1012
+ markerHeight: 8,
1013
+ markerWidth: 8,
1014
+ orient: 'auto',
1015
+ refX: 7,
1016
+ refY: 4,
1017
+ viewBox: '0 0 8 8',
1018
+ })
1019
+ marker.append(svgElement('path', { d: 'M0 0 L8 4 L0 8 Z', fill: 'currentColor' }))
1020
+ definitions.append(marker)
1021
+ const viewport = svgElement('g', { 'data-trace-viewport': '' })
1022
+ svg.append(title, description, definitions, viewport)
1023
+ for (const edge of component.edges) {
1024
+ const sourceKey =
1025
+ edge.kind === 'consumes' ? `artifact:${edge.artifact_ref}` : `run:${edge.run_id}`
1026
+ const targetKey =
1027
+ edge.kind === 'consumes' ? `run:${edge.run_id}` : `artifact:${edge.artifact_ref}`
1028
+ const sourcePosition = layout.get(sourceKey)
1029
+ const targetPosition = layout.get(targetKey)
1030
+ const run = component.runs.find((candidate) => candidate.run_id === edge.run_id)
1031
+ const artifact = component.artifacts.find((candidate) => candidate.ref === edge.artifact_ref)
1032
+ const sourceLabel = edge.kind === 'consumes' ? artifactLabel(artifact) : runLabel(run)
1033
+ const targetLabel = edge.kind === 'consumes' ? runLabel(run) : artifactLabel(artifact)
1034
+ const line = svgElement('line', {
1035
+ class: 'trace-edge',
1036
+ 'data-kind': edge.kind,
1037
+ role: 'img',
1038
+ x1: sourcePosition.x + 170,
1039
+ x2: targetPosition.x,
1040
+ y1: sourcePosition.y + 38,
1041
+ y2: targetPosition.y + 38,
1042
+ 'marker-end': `url(#trace-arrow-${svgId})`,
1043
+ 'aria-label': `连接:${sourceLabel} 到 ${targetLabel}`,
1044
+ })
1045
+ viewport.append(line)
1046
+ }
1047
+ for (const run of component.runs) {
1048
+ const key = `run:${run.run_id}`
1049
+ const node = nodeGroup(
1050
+ 'run',
1051
+ runLabel(run),
1052
+ run.capability_id,
1053
+ layout.get(key),
1054
+ {
1055
+ 'data-lifecycle': run.lifecycle,
1056
+ 'data-boundary': String(trace.boundary_run_ids.includes(run.run_id)),
1057
+ },
1058
+ live,
1059
+ () => activate(key),
1060
+ )
1061
+ nodesByKey.set(key, node)
1062
+ viewport.append(node)
1063
+ }
1064
+ for (const artifact of component.artifacts) {
1065
+ const key = `artifact:${artifact.ref}`
1066
+ const node = nodeGroup(
1067
+ 'artifact',
1068
+ artifactLabel(artifact),
1069
+ `${artifact.row_count} 行`,
1070
+ layout.get(key),
1071
+ {
1072
+ 'data-boundary': String(trace.boundary_artifact_refs.includes(artifact.ref)),
1073
+ 'data-report-artifact': String(trace.report_artifact_refs.includes(artifact.ref)),
1074
+ },
1075
+ live,
1076
+ () => activate(key),
1077
+ )
1078
+ nodesByKey.set(key, node)
1079
+ viewport.append(node)
1080
+ }
1081
+ canvasWrap.append(svg)
1082
+ workspace.append(canvasWrap, detailsPanel)
1083
+ section.append(workspace)
1084
+ let scale = 1
1085
+ const applyScale = () => viewport.setAttribute('transform', `scale(${scale})`)
1086
+ for (const [label, action] of [
1087
+ ['−', 'out'],
1088
+ ['+', 'in'],
1089
+ ['重置', 'reset'],
1090
+ ]) {
1091
+ const button = text('button', label)
1092
+ button.type = 'button'
1093
+ button.setAttribute(
1094
+ 'aria-label',
1095
+ action === 'out' ? '缩小' : action === 'in' ? '放大' : '重置缩放',
1096
+ )
1097
+ button.addEventListener('click', () => {
1098
+ scale =
1099
+ action === 'reset'
1100
+ ? 1
1101
+ : Math.min(2.5, Math.max(0.5, scale * (action === 'in' ? 1.2 : 0.8)))
1102
+ applyScale()
1103
+ })
1104
+ controls.append(button)
1105
+ }
1106
+ const reportArtifact = component.artifacts.find((artifact) =>
1107
+ trace.report_artifact_refs.includes(artifact.ref),
1108
+ )
1109
+ const initialKey = reportArtifact
1110
+ ? `artifact:${reportArtifact.ref}`
1111
+ : component.artifacts.length > 0
1112
+ ? `artifact:${component.artifacts[0].ref}`
1113
+ : `run:${component.runs[0].run_id}`
1114
+ activate(initialKey)
1115
+ return section
1116
+ }
1117
+
1118
+ function renderSessionGraph(container, trace) {
1119
+ if (!(container instanceof Element))
1120
+ fail('$container', 'must be an Element', 'container-invalid')
1121
+ const value = validateTrace(trace, trace?.trace_id)
1122
+ container.replaceChildren()
1123
+ const live = text('p', '', 'sr-only')
1124
+ live.setAttribute('aria-live', 'polite')
1125
+ const liveId = `trace-live-${++nextId}`
1126
+ live.id = liveId
1127
+ if (value.truncated)
1128
+ container.append(text('p', '有界链路:以下视图包含截断边界。', 'trace-boundary'))
1129
+ const components = graphComponents(value)
1130
+ container.append(traceLegend(value, components.length))
1131
+ for (const [index, component] of components.entries()) {
1132
+ container.append(renderComponent(value, component, index, live))
1133
+ }
1134
+ container.append(live, readBoundaryDisclosure())
1135
+ return container
1136
+ }
1137
+
1138
+ function tracesForRender(traceIds) {
1139
+ if (!Array.isArray(traceIds) || traceIds.length === 0 || traceIds.length > 20) {
1140
+ fail('$traceIds', 'must contain between 1 and 20 registered trace ids')
1141
+ }
1142
+ const seen = new Set()
1143
+ return traceIds.map((id, index) => {
1144
+ validateId(id)
1145
+ if (seen.has(id)) fail(`$traceIds[${index}]`, 'must be unique')
1146
+ seen.add(id)
1147
+ return [id, get(id)]
1148
+ })
1149
+ }
1150
+
1151
+ function renderSessionGraphs(container, traceIds = list()) {
1152
+ if (!(container instanceof Element))
1153
+ fail('$container', 'must be an Element', 'container-invalid')
1154
+ const selected = tracesForRender(traceIds)
1155
+ const sessions = new Map()
1156
+ for (const entry of selected) {
1157
+ const sessionId = entry[1].session_id
1158
+ const session = sessions.get(sessionId) ?? []
1159
+ session.push(entry)
1160
+ sessions.set(sessionId, session)
1161
+ }
1162
+ container.replaceChildren()
1163
+ container.append(
1164
+ text(
1165
+ 'p',
1166
+ `${sessions.size} 个 Session · ${selected.length} 个聚焦 Graph`,
1167
+ 'trace-session-count',
1168
+ ),
1169
+ )
1170
+ let sessionIndex = 0
1171
+ for (const [sessionId, entries] of sessions) {
1172
+ sessionIndex += 1
1173
+ const section = document.createElement('section')
1174
+ section.className = 'trace-session'
1175
+ const heading = document.createElement('header')
1176
+ heading.className = 'trace-session-heading'
1177
+ heading.append(text('h2', `Session ${sessionIndex}`), text('code', sessionId))
1178
+ section.append(heading)
1179
+ for (const [traceId, trace] of entries) {
1180
+ const graph = document.createElement('section')
1181
+ graph.className = 'trace-session-graph'
1182
+ graph.append(text('h3', `聚焦 Graph · ${traceId}`))
1183
+ const host = document.createElement('div')
1184
+ host.className = 'trace-session-graph-host'
1185
+ renderSessionGraph(host, trace)
1186
+ graph.append(host)
1187
+ section.append(graph)
1188
+ }
1189
+ container.append(section)
1190
+ }
1191
+ return container
1192
+ }
1193
+
1194
+ const api = Object.freeze({ get, has, list, register, renderSessionGraph, renderSessionGraphs })
1195
+ Object.defineProperty(scope, 'ReportTrace', {
1196
+ configurable: false,
1197
+ enumerable: true,
1198
+ value: api,
1199
+ writable: false,
1200
+ })
1201
+ })(globalThis)