sparkforensics-cli 0.1.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.
Files changed (60) hide show
  1. package/bin/sparkforensics-analyze.mjs +288 -0
  2. package/package.json +29 -0
  3. package/vendor-core/analyzer.js +167 -0
  4. package/vendor-core/assert-never.js +3 -0
  5. package/vendor-core/cli/budgets.js +203 -0
  6. package/vendor-core/cli/collect-run.js +107 -0
  7. package/vendor-core/core-count.js +68 -0
  8. package/vendor-core/core-locality-ratio.js +54 -0
  9. package/vendor-core/core-time-series.js +92 -0
  10. package/vendor-core/core-usage-locality.js +70 -0
  11. package/vendor-core/detectors.js +1989 -0
  12. package/vendor-core/docs-config.js +72 -0
  13. package/vendor-core/docs-site-config.js +23 -0
  14. package/vendor-core/efficiency-model.js +62 -0
  15. package/vendor-core/etl-phases.js +28 -0
  16. package/vendor-core/event-handlers.js +906 -0
  17. package/vendor-core/event-schemas.js +405 -0
  18. package/vendor-core/evidence-availability.js +121 -0
  19. package/vendor-core/evidence-report.js +459 -0
  20. package/vendor-core/finding-action-label.js +97 -0
  21. package/vendor-core/finding-filter-predicate.js +38 -0
  22. package/vendor-core/format-utils.js +167 -0
  23. package/vendor-core/impact-band.js +50 -0
  24. package/vendor-core/impact-estimator.js +428 -0
  25. package/vendor-core/ingest.js +139 -0
  26. package/vendor-core/job-groups.js +30 -0
  27. package/vendor-core/load-vendored.js +24 -0
  28. package/vendor-core/lz4-block.js +135 -0
  29. package/vendor-core/mcp-error.js +3 -0
  30. package/vendor-core/mcp-server-factory.js +115 -0
  31. package/vendor-core/mcp-tools.js +331 -0
  32. package/vendor-core/model-assembler.js +76 -0
  33. package/vendor-core/occupancy.js +202 -0
  34. package/vendor-core/parser-worker.js +249 -0
  35. package/vendor-core/plan-dot.js +25 -0
  36. package/vendor-core/plan-duration-attribution.js +185 -0
  37. package/vendor-core/plan-graph-model.js +171 -0
  38. package/vendor-core/plan-node-detail.js +159 -0
  39. package/vendor-core/plan-summary.js +233 -0
  40. package/vendor-core/plan-tree-walk.js +29 -0
  41. package/vendor-core/proxy.js +157 -0
  42. package/vendor-core/recommendation-rollup.js +197 -0
  43. package/vendor-core/redact.js +175 -0
  44. package/vendor-core/rolling-log-reassembly.js +52 -0
  45. package/vendor-core/run-aggregates.js +44 -0
  46. package/vendor-core/run-comparison.js +458 -0
  47. package/vendor-core/scaling-sim.js +73 -0
  48. package/vendor-core/session-snapshot.js +79 -0
  49. package/vendor-core/shs-fetch.js +196 -0
  50. package/vendor-core/shs-load.js +121 -0
  51. package/vendor-core/shs-request.js +101 -0
  52. package/vendor-core/shs-schemas.js +13 -0
  53. package/vendor-core/snappy-block.js +140 -0
  54. package/vendor-core/stage-quantiles.js +199 -0
  55. package/vendor-core/threshold-summary.js +35 -0
  56. package/vendor-core/types.js +286 -0
  57. package/vendor-core/vendor/fflate.js +2695 -0
  58. package/vendor-core/vendor/fzstd.js +768 -0
  59. package/vendor-core/wall-clock.js +36 -0
  60. package/vendor-core/wasted-core-hours.js +68 -0
@@ -0,0 +1,197 @@
1
+ // Explicit .ts extensions (unlike a bundler, plain Node's ESM resolver -
2
+ // the runtime bin/sparkforensics-analyze.mjs and src/mcp-tools.ts both run
3
+ // under, now that src/evidence-report.ts imports this module too - requires
4
+ // the exact specifier, not an extensionless one a bundler would resolve for
5
+ // you): this file's prior only consumer, src/view/widgets/FixTheseFirst.tsx,
6
+ // went through Vite, which tolerates the extensionless form, so this never
7
+ // surfaced until the CLI/MCP path started importing it.
8
+ import { mergeIntervals } from './wall-clock.js';
9
+ import { worstImpactBand, IMPACT_BAND_ORDER } from './format-utils.js';
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+ /** The cross-finding grouping layer the spec calls for: given every stage
18
+ * touched by a group of findings, collect their intervals and merge once
19
+ * across the combined set, instead of summing each finding's own
20
+ * already-clipped wallClock figure (which double-counts concurrent stages
21
+ * across different findings of the same detector type). Reuses the generic
22
+ * mergeIntervals primitive (src/wall-clock.ts) rather than widening the
23
+ * single-finding-scoped estimateMultiStage/unionMs in src/occupancy.ts. */
24
+ export function computeStageUnionMs(
25
+ stageIds ,
26
+ stages ,
27
+ ) {
28
+ const intervals = [];
29
+ for (const id of stageIds) {
30
+ const stage = stages.get(id);
31
+ if (!stage) continue;
32
+ // Skip half-open stages rather than defaulting a missing bound to 0: an
33
+ // incomplete/truncated log (the case `incompleteRun` flags) leaves
34
+ // `completedAt` unset, and coercing it to 0 would contribute a hugely
35
+ // negative interval and a nonsense negative recoverable-time figure.
36
+ // Same filter `computeWallClock` (src/wall-clock.ts) already applies.
37
+ if (stage.submittedAt == null || stage.completedAt == null) continue;
38
+ intervals.push([stage.submittedAt, stage.completedAt]);
39
+ }
40
+ return mergeIntervals(intervals).reduce((sum, [a, b]) => sum + (b - a), 0);
41
+ }
42
+
43
+
44
+
45
+
46
+
47
+
48
+ function groupByType(findings ) {
49
+ const groups = new Map ();
50
+ for (const finding of findings) {
51
+ const group = groups.get(finding.type) ?? [];
52
+ group.push(finding);
53
+ groups.set(finding.type, group);
54
+ }
55
+ return groups;
56
+ }
57
+
58
+ function stageIdsOf(finding ) {
59
+ if (finding.stageIds) return finding.stageIds;
60
+ if (finding.stageId != null) return [finding.stageId];
61
+ return [];
62
+ }
63
+
64
+ function buildTimeGroup(
65
+ type ,
66
+ findings ,
67
+ stages ,
68
+ ) {
69
+ const stageIds = [...new Set(findings.flatMap(stageIdsOf))];
70
+ const naiveSum = findings.reduce((sum, f) => sum + (f.impactEstimate?.wallClock?.high ?? 0), 0);
71
+ // Only cap to stage union if there are stages to compare; stageless findings use naive sum.
72
+ const recoverableMsHigh = stageIds.length > 0
73
+ ? Math.min(naiveSum, computeStageUnionMs(stageIds, stages))
74
+ : naiveSum;
75
+ return { kind: 'time', type, findingCount: findings.length, stageCount: stageIds.length, recoverableMsHigh, findings };
76
+ }
77
+
78
+ function buildResourceGroup(type , findings ) {
79
+ const unit = findings[0].impactEstimate .rawWaste .unit;
80
+ const total = findings.reduce((sum, f) => sum + (f.impactEstimate?.rawWaste?.value ?? 0), 0);
81
+ return { kind: 'resource', type, findingCount: findings.length, unit, total, findings };
82
+ }
83
+
84
+ function buildCountGroup(type , findings ) {
85
+ const byImpactBand = {};
86
+ for (const finding of findings) {
87
+ byImpactBand[finding.impactBand] = (byImpactBand[finding.impactBand] ?? 0) + 1;
88
+ }
89
+ return { kind: 'count', type, findingCount: findings.length, byImpactBand, findings };
90
+ }
91
+
92
+ const KIND_ORDER = { time: 0, resource: 1, count: 2 };
93
+
94
+ export function buildRecommendationRollup(
95
+ findings ,
96
+ stages ,
97
+ ) {
98
+ const groups = [];
99
+ for (const [type, typeFindings] of groupByType(findings)) {
100
+ const timeFindings = typeFindings.filter((f) => f.impactEstimate?.wallClock != null);
101
+ const resourceFindings = typeFindings.filter(
102
+ (f) => f.impactEstimate?.wallClock == null && f.impactEstimate?.rawWaste != null,
103
+ );
104
+ const countFindings = typeFindings.filter(
105
+ (f) => f.impactEstimate?.wallClock == null && f.impactEstimate?.rawWaste == null,
106
+ );
107
+ if (timeFindings.length > 0) groups.push(buildTimeGroup(type, timeFindings, stages));
108
+ // Group resource findings by (type, unit) to prevent mixing incompatible units.
109
+ if (resourceFindings.length > 0) {
110
+ const byUnit = new Map ();
111
+ for (const finding of resourceFindings) {
112
+ const unit = finding.impactEstimate .rawWaste .unit;
113
+ const group = byUnit.get(unit) ?? [];
114
+ group.push(finding);
115
+ byUnit.set(unit, group);
116
+ }
117
+ for (const resourceGroup of byUnit.values()) {
118
+ groups.push(buildResourceGroup(type, resourceGroup));
119
+ }
120
+ }
121
+ if (countFindings.length > 0) groups.push(buildCountGroup(type, countFindings));
122
+ }
123
+ return groups.sort((a, b) => {
124
+ const kindDelta = KIND_ORDER[a.kind] - KIND_ORDER[b.kind];
125
+ if (kindDelta !== 0) return kindDelta;
126
+ if (a.kind === 'time' && b.kind === 'time') return b.recoverableMsHigh - a.recoverableMsHigh;
127
+ // resource/count groups carry no shared magnitude to rank by (spec section
128
+ // 5 forbids comparing incompatible units); break ties by worst impact band
129
+ // instead, the same tie-break FixTheseFirst used pre-consolidation.
130
+ const aImpactBand = IMPACT_BAND_ORDER[worstImpactBand(a.findings) ?? 'info'] ?? 9;
131
+ const bImpactBand = IMPACT_BAND_ORDER[worstImpactBand(b.findings) ?? 'info'] ?? 9;
132
+ return aImpactBand - bImpactBand;
133
+ });
134
+ }
135
+
136
+ /** True when a finding is a candidate for the "fix these first" ranking at
137
+ * all. Shared by `src/evidence-report.ts` (CLI/MCP path) and
138
+ * `src/view/widgets/FixTheseFirst.tsx` (the dashboard widget) so these two
139
+ * hardcoded exclusions can't drift between the two surfaces (they used to be
140
+ * duplicated, near-verbatim, in both files). Doesn't check
141
+ * `REGISTRY[finding.type] != null` (FixTheseFirst.tsx's own extra check):
142
+ * `REGISTRY` lives in a `.tsx` file, not importable from this core module;
143
+ * FixTheseFirst.tsx layers that extra check on top of this one instead. */
144
+ export function isEligible(finding ) {
145
+ // incompleteRun is a pipeline-completeness signal, not an addressable fix:
146
+ // hardcoded here by type, not read from Detector.fixEffort. That field
147
+ // exists only to back its own contract test today (tests/fix-effort.test.js);
148
+ // nothing in ranking/exclusion logic reads it.
149
+ if (finding.type === 'incompleteRun') return false;
150
+ // memoryUtilization's memoryBand/dataUnavailable variant reports a missing
151
+ // -evidence caveat (spark.eventLog.logStageExecutorMetrics=true not on for
152
+ // this run), not an optimization: the exact same fact already lives in the
153
+ // Evidence availability ledger's own `executorMetrics` entry
154
+ // (src/evidence-availability.ts), so it belongs there, not here.
155
+ if (finding.type === 'memoryUtilization' && finding.variant === 'memoryBand' && finding.dataUnavailable) return false;
156
+ return true;
157
+ }
158
+
159
+ /** Ranking tier for a single finding: time-based findings (a real `wallClock`
160
+ * claim) are the only ones whose magnitudes share a unit, so they are the
161
+ * only ones ranked numerically. `resourceOnly` findings carry `rawWaste` in
162
+ * whatever unit their detector emitted, which the spec (section 5) forbids
163
+ * comparing against a time figure or against another resourceOnly finding's
164
+ * own raw magnitude; they rank by impact band instead, below every time-based
165
+ * finding. Informational findings (no magnitude at all) rank last, also by
166
+ * impact band. */
167
+ const TIER_TIME = 0;
168
+ const TIER_RESOURCE = 1;
169
+ const TIER_INFORMATIONAL = 2;
170
+
171
+ function tierOf(finding ) {
172
+ const estimate = finding.impactEstimate;
173
+ if (estimate?.wallClock) return TIER_TIME;
174
+ if (estimate?.rawWaste) return TIER_RESOURCE;
175
+ return TIER_INFORMATIONAL;
176
+ }
177
+
178
+ /** Ranks a group's members so the "representative" finding (the one whose
179
+ * action label/tag donate to the group's row) is always the highest-impact
180
+ * one, not just the first one `buildRecommendationRollup` happened to
181
+ * collect. Used to pick each type-group's own highest-impact member and to
182
+ * order that group's expanded list; cross-group ordering is
183
+ * `buildRecommendationRollup`'s own job, not this function's. */
184
+ export function rankFindings(findings ) {
185
+ return [...findings].sort((a, b) => {
186
+ const tierDelta = tierOf(a) - tierOf(b);
187
+ if (tierDelta !== 0) return tierDelta;
188
+ if (tierOf(a) === TIER_TIME) {
189
+ // Same unit (ms) on both sides: the one comparison that is meaningful.
190
+ // .high, matching deriveImpactBand (src/impact-band.ts) and
191
+ // triage-target.ts, so this ordering, the badge color, and the "click
192
+ // to investigate" target all agree on the same figure.
193
+ return b.impactEstimate .wallClock .high - a.impactEstimate .wallClock .high;
194
+ }
195
+ return (IMPACT_BAND_ORDER[a.impactBand] ?? 9) - (IMPACT_BAND_ORDER[b.impactBand] ?? 9);
196
+ });
197
+ }
@@ -0,0 +1,175 @@
1
+ // Identifier redaction for the portable evidence report. Replaces the
2
+ // application id and every host name with stable pseudonyms (`app-1`,
3
+ // `host-1`, ...) so a report can be shared outside the environment that
4
+ // produced it without leaking infrastructure identity. Opt-in (default OFF)
5
+ // at the call site; raw task records are excluded from the report regardless.
6
+ //
7
+ // Deterministic (sorted assignment), idempotent (pseudonyms map to themselves),
8
+ // and non-mutating (returns a fresh, deep-copied tree).
9
+
10
+ // Host / IP identifier patterns. Used to enumerate host names that surface only
11
+ // inside free text: recommendation strings, `stageFailed`'s failure-reason
12
+ // value, SQL relation/node names, never as a structured `host` field, so
13
+ // redaction reaches those residuals too. Pseudonyms (`host-1`) match neither
14
+ // pattern, keeping the scan idempotent.
15
+ const HOST_PATTERNS = [
16
+ // EC2-style ip-10-1-2-3 with an optional dotted domain (ip-10-1-2-3.ec2.internal).
17
+ // Each domain label must start with an alphanumeric, so a trailing sentence
18
+ // period ("… bad node ip-10-1-2-3.") is left out of the match.
19
+ /\bip(?:-\d{1,3}){4}(?:\.[a-z0-9][a-z0-9-]*)*/gi,
20
+ /\b(?:\d{1,3}\.){3}\d{1,3}\b/g, // bare IPv4: 10.1.2.3
21
+ ];
22
+
23
+ // Spark application id (e.g. application_1690000000000_0001). Besides
24
+ // redactReport's structured summary.app.id field, an app id can also surface
25
+ // as a residual free-text token (e.g. embedded in a finding's evidence or
26
+ // recommendation string) — same as a host/IP token embedded in a stage name
27
+ // or plan detail. redactComparison has no structured app-id field at all
28
+ // (baselineLabel/candidateLabel are caller-supplied labels, not Spark app
29
+ // ids), so free text is its *only* source of app ids.
30
+ const APP_ID_PATTERNS = [/\bapplication_\d{10,}_\d+\b/g];
31
+
32
+ // Walk every string in the tree once, collecting matches for each `{ patterns,
33
+ // out }` sink. One shared traversal for every token kind (instead of one
34
+ // traversal per kind) keeps redactComparison's dual host+app-id scan the same
35
+ // cost as the single-kind scan redactReport/redactAppIdentity already do.
36
+ function scanTokens(node , sinks ) {
37
+ if (typeof node === 'string') {
38
+ for (const { patterns, out } of sinks) {
39
+ for (const re of patterns) {
40
+ const found = node.match(re);
41
+ if (found) for (const m of found) out.add(m);
42
+ }
43
+ }
44
+ return;
45
+ }
46
+ if (Array.isArray(node)) {
47
+ for (const n of node) scanTokens(n, sinks);
48
+ return;
49
+ }
50
+ if (node && typeof node === 'object') {
51
+ for (const v of Object.values(node)) scanTokens(v, sinks);
52
+ }
53
+ }
54
+
55
+ // Walk every string in the tree, collecting host/IP tokens into `hosts`.
56
+ function scanHostTokens(node , hosts ) {
57
+ scanTokens(node, [{ patterns: HOST_PATTERNS, out: hosts }]);
58
+ }
59
+
60
+ // Known locations of the identifiers, so we don't have to guess which strings
61
+ // are sensitive: the app id lives at summary.app.id; host names live on each
62
+ // finding's `host` field. The real report nests that host under `evidence.host`
63
+ // (host is not a first-class report column), so read both shapes, plus a
64
+ // free-text scan for hosts/IPs and app ids that appear only inside string
65
+ // values (e.g. a finding's evidence/recommendation text).
66
+
67
+
68
+
69
+
70
+
71
+
72
+
73
+
74
+
75
+ function collectIds(report ) {
76
+ const appIds = new Set ();
77
+ const hosts = new Set ();
78
+ const appId = report?.summary?.app?.id;
79
+ if (typeof appId === 'string' && appId.length > 0) appIds.add(appId);
80
+ for (const f of report?.findings ?? []) {
81
+ for (const h of [f?.host, f?.evidence?.host]) {
82
+ if (typeof h === 'string' && h.length > 0) hosts.add(h);
83
+ }
84
+ }
85
+ // Free-text scan for both host/IP tokens and app-id tokens: an app id can
86
+ // surface in a finding's evidence/recommendation text (e.g. "retry app
87
+ // application_1690000000000_0001 failed") same as redactComparison's scan.
88
+ scanTokens(report, [{ patterns: HOST_PATTERNS, out: hosts }, { patterns: APP_ID_PATTERNS, out: appIds }]);
89
+ return { appIds, hosts };
90
+ }
91
+
92
+ // Numeric-aware sorted assignment => deterministic numbering that is stable
93
+ // across passes. A lexicographic sort orders `host-1, host-10, host-11, host-2`
94
+ // so a second pass over already-pseudonymized ids would re-slot `host-10`→`2`
95
+ // at >=10 items and break idempotency; a numeric-aware sort keeps `host-2`
96
+ // before `host-10`, so each pseudonym maps back to itself.
97
+ function buildMap(ids , prefix ) {
98
+ const map = new Map ();
99
+ [...ids]
100
+ .sort((a, b) => a.localeCompare(b, 'en', { numeric: true }))
101
+ .forEach((id, i) => map.set(id, `${prefix}-${i + 1}`));
102
+ return map;
103
+ }
104
+
105
+ // Deep-replace every string occurrence of each identifier across the whole
106
+ // tree (covers ids embedded in free-text recommendations, not just the
107
+ // canonical fields). Longest-first so no identifier is a prefix-shadow of
108
+ // another. Returns a fresh tree, never mutates the input.
109
+ function deepReplace(node , replacements ) {
110
+ if (typeof node === 'string') {
111
+ let s = node;
112
+ for (const [from, to] of replacements) s = s.split(from).join(to);
113
+ return s;
114
+ }
115
+ if (Array.isArray(node)) return node.map((n) => deepReplace(n, replacements));
116
+ if (node && typeof node === 'object') {
117
+ const out = {};
118
+ for (const [k, v] of Object.entries(node)) out[k] = deepReplace(v, replacements);
119
+ return out;
120
+ }
121
+ return node;
122
+ }
123
+
124
+ // Shared by every redact* export below: turns collected app-id/host sets into
125
+ // the longest-first replacement list and applies it. Centralizes the
126
+ // buildMap+sort+deepReplace sequence so each narrower redact* function only
127
+ // has to say what it collects, not how replacement is carried out.
128
+ function applyReplacements (node , ids ) {
129
+ const merged = [
130
+ ...(ids.appIds ? buildMap(ids.appIds, 'app') : []),
131
+ ...buildMap(ids.hosts, 'host'),
132
+ ];
133
+ // Replace longer identifiers first to avoid partial-substring clobbering.
134
+ merged.sort((a, b) => b[0].length - a[0].length);
135
+ return deepReplace(node, merged) ;
136
+ }
137
+
138
+ export function redactReport (report ) {
139
+ const { appIds, hosts } = collectIds(report);
140
+ return applyReplacements(report, { appIds, hosts });
141
+ }
142
+
143
+ // Narrow counterpart to redactReport(), for getRunSummary()'s standalone app
144
+ // object (no findings tree to walk). There's exactly one app id here, so no
145
+ // Set/Map/sort is needed for it; name/sparkVersion still go through the
146
+ // shared host/IP scan-and-replace since either can carry a host token as
147
+ // free text.
148
+ export function redactAppIdentity(
149
+ app ,
150
+ ) {
151
+ const hosts = new Set ();
152
+ scanHostTokens(app.name, hosts);
153
+ scanHostTokens(app.sparkVersion, hosts);
154
+ return {
155
+ id: typeof app.id === 'string' && app.id.length > 0 ? 'app-1' : app.id,
156
+ name: applyReplacements(app.name, { hosts }),
157
+ sparkVersion: applyReplacements(app.sparkVersion, { hosts }),
158
+ };
159
+ }
160
+
161
+ // Run-comparison counterpart: no single app-id *field* to pseudonymize
162
+ // (baselineLabel/candidateLabel are caller-supplied labels, not Spark app
163
+ // ids), but stage names surface throughout the tree (FindingsDeltaRow.stages,
164
+ // baseStages/candStages[].name) and, like the evidence report's findings, can
165
+ // carry a host/IP token or an app id as free text (e.g. `collect at
166
+ // application_1690000000000_0001 worker-10-1-2-3.scala:45`). Scans the whole
167
+ // comparison tree rather than enumerating those fields individually, so a
168
+ // future CompareRunsResult field carrying free text is covered without this
169
+ // function needing to change.
170
+ export function redactComparison (comparison ) {
171
+ const hosts = new Set ();
172
+ const appIds = new Set ();
173
+ scanTokens(comparison, [{ patterns: HOST_PATTERNS, out: hosts }, { patterns: APP_ID_PATTERNS, out: appIds }]);
174
+ return applyReplacements(comparison, { appIds, hosts });
175
+ }
@@ -0,0 +1,52 @@
1
+ // Rolling event-log directory/zip member-name reassembly. Pulled out of
2
+ // shs-fetch.ts (Task: bundle-size fix) so DropZone.tsx's drag-drop path can
3
+ // import just this reassembly logic without pulling in the Zod schemas and
4
+ // vendored zstd/gzip decompressors that the rest of shs-fetch.ts (and the
5
+ // wider parser-worker.ts module graph) depend on. Keep this file free of any
6
+ // import beyond plain JS/TS built-ins.
7
+
8
+ export function naturalCompare(a , b ) {
9
+ const tokenize = (s ) => s.match(/(\d+)|(\D+)/g) ?? [s];
10
+ const ax = tokenize(a), bx = tokenize(b);
11
+ const len = Math.max(ax.length, bx.length);
12
+ for (let i = 0; i < len; i++) {
13
+ const av = ax[i] ?? '', bv = bx[i] ?? '';
14
+ if (av === bv) continue;
15
+ const an = Number(av), bn = Number(bv);
16
+ if (!Number.isNaN(an) && !Number.isNaN(bn)) return an - bn;
17
+ return av < bv ? -1 : 1;
18
+ }
19
+ return 0;
20
+ }
21
+
22
+ // Reassemble a rolling `eventlog_v2_*` directory's (or SHS zip's) member
23
+ // names into the ordered list that should actually be parsed: drop the
24
+ // zero-byte `appstatus_*` completion marker, drop every non-compact
25
+ // `events_*` file at or below the most recent `*.compact` file's index
26
+ // (already merged into it, so reading them again double-counts events), and
27
+ // sort the remainder in ascending numeric-index order. Throws if the
28
+ // resulting index sequence has a gap (a missing roll file).
29
+ export function reassembleRollingEntries(names ) {
30
+ const withoutMarker = names.filter(n => !n.toLowerCase().startsWith('appstatus'));
31
+ const eventNames = withoutMarker.filter(n => /^events_\d+_/.test(n));
32
+
33
+ const indexOf = (n ) => parseInt(n.match(/^events_(\d+)_/) [1], 10);
34
+
35
+ const compactIndices = eventNames.filter(n => n.endsWith('.compact')).map(indexOf);
36
+ const highestCompactIndex = compactIndices.length > 0 ? Math.max(...compactIndices) : -1;
37
+
38
+ const kept = eventNames.filter(n => {
39
+ const index = indexOf(n);
40
+ return n.endsWith('.compact') ? index === highestCompactIndex : index > highestCompactIndex;
41
+ });
42
+
43
+ const sorted = kept.sort(naturalCompare);
44
+ const indices = sorted.map(indexOf);
45
+ for (let i = 1; i < indices.length; i++) {
46
+ if (indices[i] !== indices[i - 1] + 1) {
47
+ throw new Error(`Rolling event-log directory is missing file(s) between index ${indices[i - 1]} and ${indices[i]}.`);
48
+ }
49
+ }
50
+
51
+ return sorted;
52
+ }
@@ -0,0 +1,44 @@
1
+ // Whole-run aggregation pass. Runs INSIDE parser-worker.js over the retained
2
+ // per-task launch/finish timestamps in taskStore, so raw task data never
3
+ // reaches the main thread: only the compact summary below is posted. Pure and
4
+ // worker-agnostic so it is unit-testable without a Worker.
5
+ //
6
+ // Uses computeCoreTimeSeries in coreCount mode (O(n log n)) to derive the
7
+ // busy-core histogram; the time-bucket mode is intentionally NOT used here
8
+ // because it is O(buckets × segments) and a whole run can hold millions of
9
+ // tasks (see core-time-series.js PERF note).
10
+
11
+ import { FIELDS } from './stage-quantiles.js';
12
+ import { computeCoreTimeSeries } from './core-time-series.js';
13
+
14
+ export function computeRunAggregates(taskStore )
15
+
16
+
17
+
18
+
19
+ {
20
+ const intervals = [];
21
+ const perStage = {};
22
+ for (const [stageId, arr] of taskStore) {
23
+ const taskCount = arr.length / FIELDS.STRIDE;
24
+ let totalTaskDurationSum = 0;
25
+ for (let i = 0; i < taskCount; i++) {
26
+ const base = i * FIELDS.STRIDE;
27
+ const launch = arr[base + FIELDS.LAUNCH_TIME];
28
+ const finish = arr[base + FIELDS.FINISH_TIME];
29
+ totalTaskDurationSum += arr[base + FIELDS.DURATION];
30
+ if (finish > launch) intervals.push({ launch, finish });
31
+ }
32
+ perStage[stageId] = { totalTaskDurationSum, taskCount };
33
+ }
34
+
35
+ // bucketBy: 'coreCount' always yields the coreCount branch of the union;
36
+ // narrow explicitly since the callee's return type doesn't correlate to
37
+ // the literal `bucketBy` argument.
38
+ const { histogram } = computeCoreTimeSeries(intervals, { bucketBy: 'coreCount' }) ;
39
+ let busyCoreMs = 0;
40
+ for (let k = 0; k < histogram.length; k++) busyCoreMs += k * histogram[k];
41
+ const peakConcurrentCores = histogram.length > 0 ? histogram.length - 1 : 0;
42
+
43
+ return { coreHistogram: histogram, busyCoreMs, peakConcurrentCores, perStage };
44
+ }