@openmixer/plugin-qualify 0.19.1 → 0.20.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.
- package/bin/plugin-qualify-records-package.mjs +9 -7
- package/bin/plugin-qualify.mjs +36 -1
- package/dist/distributed-records.d.ts +16 -3
- package/dist/distributed-records.js +38 -4
- package/dist/distributed-records.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/judged-layer.d.ts +91 -0
- package/dist/judged-layer.js +144 -0
- package/dist/judged-layer.js.map +1 -0
- package/dist/judgement-line.d.ts +6 -0
- package/dist/judgement-line.js +20 -0
- package/dist/judgement-line.js.map +1 -0
- package/dist/measurement/cli.d.ts +2 -0
- package/dist/measurement/cli.js +25 -2
- package/dist/measurement/cli.js.map +1 -1
- package/dist/measurement/format.d.ts +2 -0
- package/dist/measurement/format.js +5 -0
- package/dist/measurement/format.js.map +1 -1
- package/dist/probe-merge.js +6 -0
- package/dist/probe-merge.js.map +1 -1
- package/dist/probe-runner.d.ts +4 -0
- package/dist/probe-runner.js +12 -3
- package/dist/probe-runner.js.map +1 -1
- package/dist/qualification-record.d.ts +15 -1
- package/dist/qualification-record.js +27 -5
- package/dist/qualification-record.js.map +1 -1
- package/dist/qualify-isolated.d.ts +5 -3
- package/dist/qualify-isolated.js +6 -4
- package/dist/qualify-isolated.js.map +1 -1
- package/dist/qualify-sync.d.ts +8 -3
- package/dist/qualify-sync.js +25 -15
- package/dist/qualify-sync.js.map +1 -1
- package/dist/records-package.d.ts +31 -4
- package/dist/records-package.js +149 -24
- package/dist/records-package.js.map +1 -1
- package/fixtures/records-rpi5-0.19.1/http___drumkv1.sourceforge.net_lv2@0.2-7d179bdcf9e5.default.probe.json +58 -0
- package/fixtures/records-rpi5-0.19.1/http___drumkv1.sourceforge.net_lv2@0.2-7d179bdcf9e5.default.qualification.json +305 -0
- package/fixtures/records-rpi5-0.19.1/http___samplv1.sourceforge.net_lv2@0.2-c9cd8fb3102f.default.probe.json +58 -0
- package/fixtures/records-rpi5-0.19.1/http___samplv1.sourceforge.net_lv2@0.2-c9cd8fb3102f.default.qualification.json +301 -0
- package/package.json +2 -2
- package/src/distributed-records.ts +41 -4
- package/src/index.ts +2 -0
- package/src/judged-layer.ts +197 -0
- package/src/judgement-line.ts +22 -0
- package/src/measurement/cli.ts +26 -2
- package/src/measurement/format.ts +8 -0
- package/src/probe-merge.ts +4 -0
- package/src/probe-runner.ts +16 -6
- package/src/qualification-record.ts +40 -6
- package/src/qualify-isolated.ts +6 -4
- package/src/qualify-sync.ts +27 -15
- package/src/records-package.ts +156 -25
- package/tools/lv2-measure.mjs +21 -2
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
* treats the plugin as one it measured.
|
|
13
13
|
*/
|
|
14
14
|
import { readFileSync, readdirSync } from "node:fs";
|
|
15
|
+
import { machine } from "node:os";
|
|
15
16
|
import { join, resolve } from "node:path";
|
|
16
17
|
|
|
18
|
+
import { cellKey, type GridCell } from "./measurement-grid.js";
|
|
17
19
|
import { clapPinUri, pinVerdictOf, type PinEntry } from "./pins-file.js";
|
|
18
20
|
import { integrityOfProbeOutput } from "./probe-judge.js";
|
|
19
21
|
import { readProbeOutput, sha256Hex } from "./probe-output.js";
|
|
@@ -60,6 +62,20 @@ export function hostClassOfBoard(model: string | undefined): string | undefined
|
|
|
60
62
|
return m === null ? undefined : `rpi${m[1]!}`;
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
/** The architecture `uname -m` names, or `undefined` when it cannot be read. */
|
|
66
|
+
export function readMachine(): string | undefined {
|
|
67
|
+
try {
|
|
68
|
+
return machine();
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The host class of a machine with no board name, by `uname -m`: `x86-64` for an x86_64 one; none for an architecture without a class. */
|
|
75
|
+
export function hostClassOfMachine(arch: string | undefined): string | undefined {
|
|
76
|
+
return arch?.trim() === "x86_64" ? "x86-64" : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
/** One distributed record, read: where it is, what it says, and the set it came with. */
|
|
64
80
|
export interface DistributedRecord {
|
|
65
81
|
readonly path: string;
|
|
@@ -156,26 +172,38 @@ export function outcomeOfVerdict(v: Pick<RecordVerdict, "measured" | "rating" |
|
|
|
156
172
|
return v.rating === "unsuitable" ? "refused" : "unmeasured";
|
|
157
173
|
}
|
|
158
174
|
|
|
175
|
+
/** The cells a record's grid holds, by rate then quantum: where the set it came in was measured. */
|
|
176
|
+
export function recordCells(record: Pick<QualificationRecord, "measurements">): GridCell[] {
|
|
177
|
+
const seen = new Map<string, GridCell>();
|
|
178
|
+
for (const p of record.measurements?.grid ?? []) seen.set(cellKey(p), { rate: p.rate, quantum: p.quantum });
|
|
179
|
+
return [...seen.values()].sort((a, b) => a.rate - b.rate || a.quantum - b.quantum);
|
|
180
|
+
}
|
|
181
|
+
|
|
159
182
|
/** What the layer makes of one item. */
|
|
160
183
|
export type DistributedMatch =
|
|
161
184
|
/** No distributed record names the plugin: it is measured, and nothing is said. */
|
|
162
185
|
| { readonly status: "none" }
|
|
163
186
|
/** Every reason a record found was not taken, one line each; the plugin is measured. */
|
|
164
187
|
| { readonly status: "ignored"; readonly lines: readonly string[] }
|
|
165
|
-
|
|
188
|
+
/** A set applies; `lines` names every set tried before it and why it was passed over. */
|
|
189
|
+
| { readonly status: "applies"; readonly set: string; readonly records: readonly DistributedRecord[]; readonly files: readonly string[]; readonly lines: readonly string[] };
|
|
166
190
|
|
|
167
191
|
/**
|
|
168
192
|
* Whether the item is taken from the layer. The records that find it are tried one set at a time, in the order the
|
|
169
193
|
* layer read them. Of a set, only the records whose every file is installed with the SHA-256 it recorded (computed
|
|
170
194
|
* here by `digest`) count: a record of other bytes is another build of the plugin and is passed over. The set applies
|
|
171
195
|
* when at least one record of these bytes holds a verdict for `host`, and none of those verdicts is one the source
|
|
172
|
-
* could not measure; a set with no record of these bytes is a binary that differs.
|
|
196
|
+
* could not measure; a set with no record of these bytes is a binary that differs. A set is per machine class, rate
|
|
197
|
+
* and quantum: of these records, only the ones whose grid holds every cell in `cells` (the cells the sync runs at)
|
|
198
|
+
* count, so a set measured at another cell is passed over, naming the cells it was measured at, and never answers a
|
|
199
|
+
* sync at this one by the order its name sorts in (#317). No cells given asks nothing of the grid.
|
|
173
200
|
*/
|
|
174
201
|
export function matchDistributed(
|
|
175
202
|
layer: DistributedLayer,
|
|
176
203
|
item: SyncItem,
|
|
177
204
|
host: string,
|
|
178
205
|
digest: (files: readonly string[]) => readonly FileDigest[] | undefined,
|
|
206
|
+
cells: readonly GridCell[] = [],
|
|
179
207
|
): DistributedMatch {
|
|
180
208
|
const found = item.format === "clap" ? layer.clap.get(resolve(join(item.root, item.files[0]!))) : layer.lv2.get(item.uri ?? "");
|
|
181
209
|
if (found === undefined || found.length === 0) return { status: "none" };
|
|
@@ -195,7 +223,16 @@ export function matchDistributed(
|
|
|
195
223
|
say(`distributed: ${distributedUri(item, found.find((r) => r.set === set)!)}: binary differs`);
|
|
196
224
|
continue;
|
|
197
225
|
}
|
|
198
|
-
const
|
|
226
|
+
const atCell = records.filter((r) => {
|
|
227
|
+
const held = new Set(recordCells(r.record).map(cellKey));
|
|
228
|
+
return cells.every((c) => held.has(cellKey(c)));
|
|
229
|
+
});
|
|
230
|
+
if (atCell.length === 0) {
|
|
231
|
+
const measuredAt = [...new Set(records.flatMap((r) => recordCells(r.record).map(cellKey)))];
|
|
232
|
+
say(`distributed: ${distributedUri(item, records[0]!)}: set ${set} measured at ${measuredAt.length === 0 ? "no cell" : measuredAt.join(", ")}`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const judged = atCell.filter((r) => r.record.verdicts.some((v) => v.host === host));
|
|
199
236
|
if (judged.length === 0) {
|
|
200
237
|
say(`distributed: ${distributedUri(item, records[0]!)}: no verdict for ${host} in ${set}`);
|
|
201
238
|
continue;
|
|
@@ -205,7 +242,7 @@ export function matchDistributed(
|
|
|
205
242
|
say(`distributed: ${distributedUri(item, unmeasured)}: unmeasured in ${set}, measured here`);
|
|
206
243
|
continue;
|
|
207
244
|
}
|
|
208
|
-
return { status: "applies", set, records, files: [...new Set(
|
|
245
|
+
return { status: "applies", set, records: atCell, files: [...new Set(atCell.flatMap((r) => r.record.binary.files.map((f) => f.path)))], lines };
|
|
209
246
|
}
|
|
210
247
|
return { status: "ignored", lines };
|
|
211
248
|
}
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,8 @@ export * from "./cost-fields.js";
|
|
|
24
24
|
export * from "./probe-output.js";
|
|
25
25
|
export * from "./probe-judge.js";
|
|
26
26
|
export * from "./qualification-record.js";
|
|
27
|
+
export * from "./judgement-line.js";
|
|
28
|
+
export * from "./judged-layer.js";
|
|
27
29
|
export * from "./probe-runner.js";
|
|
28
30
|
export * from "./measurement-grid.js";
|
|
29
31
|
export * from "./memory-bound.js";
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* The judged layer of a plugin-db entry, from a qualification record: plugin-qualify is that layer's one writer
|
|
5
|
+
* (plugin-db's records design note, FreeMixer/plugin-db#10). The reader never parses a record; it reads the layer this writes, so every field
|
|
6
|
+
* the state function reads is set here, from the record and from what plugin-qualify declares once:
|
|
7
|
+
*
|
|
8
|
+
* - the latency line (`LATENCY_LINE_MS`), written beside each cell's figure (`latencyMs`), so a surface shows the
|
|
9
|
+
* figure within or over the line and keeps no tier word (openmixer plugin-system design 7.15);
|
|
10
|
+
* - the rules the record was judged by (`judgementRules`), the minor of the plugin-qualify that judged it: a rule
|
|
11
|
+
* changes only in a minor, so a patch release leaves every record current;
|
|
12
|
+
* - the hosting epoch of the host the verdict is for (`HOSTING_EPOCH`).
|
|
13
|
+
*
|
|
14
|
+
* One layer is one verdict under one host: the host plugin-hostd runs a plugin with. Its verdict is the one the pins
|
|
15
|
+
* file gives the same bytes (`pinVerdictOf` of the isolated tier's host, omx-clap-host, which is plugin-hostd's
|
|
16
|
+
* worker). A record whose verdict for that host was not measured has no judged layer: the reader then answers
|
|
17
|
+
* unqualified, with its reason.
|
|
18
|
+
*/
|
|
19
|
+
import { judgementRules, latencyMsOf, LATENCY_LINE_MS } from "./judgement-line.js";
|
|
20
|
+
import { pinVerdictOf, type PinVerdict } from "./pins-file.js";
|
|
21
|
+
import { QUALIFICATION_RECORD_SCHEMA, ISOLATED_TIER_HOST, type QualificationRecord } from "./qualification-record.js";
|
|
22
|
+
import type { SuitabilityRating } from "./rating.js";
|
|
23
|
+
|
|
24
|
+
/** The host a judged layer is written for: the one that runs plugins, in workers of its own (PLUGIN_DB_ISOLATION_HOSTS). */
|
|
25
|
+
export const JUDGED_HOST = "plugin-hostd";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The hosting epoch of each host a layer is written for. The epoch is the host owner's to move, when a release of it
|
|
29
|
+
* changes how it hosts; plugin-hostd has declared no hosting change since records were written, so its epoch is 0.
|
|
30
|
+
*/
|
|
31
|
+
export const HOSTING_EPOCH = { [JUDGED_HOST]: 0 } as const satisfies Readonly<Record<string, number>>;
|
|
32
|
+
|
|
33
|
+
/** The plugin-db envelope version this writes (plugin-db data/store.schema.json). */
|
|
34
|
+
export const PLUGIN_DB_ENVELOPE = 1;
|
|
35
|
+
|
|
36
|
+
/** The pin a layer is written for, as plugin-db's pinned layer holds it. */
|
|
37
|
+
export interface LayerPin {
|
|
38
|
+
/** The `omx-files/1` SHA-256 of the plugin's files. */
|
|
39
|
+
readonly files: string;
|
|
40
|
+
/** `<scheme>:<sha256>` of the parameter layout, or null when the pin holds none. */
|
|
41
|
+
readonly layout: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface JudgedCell {
|
|
45
|
+
readonly rate: number;
|
|
46
|
+
readonly quantum: number;
|
|
47
|
+
readonly latencyMs?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface JudgedBody {
|
|
51
|
+
readonly verdict: PinVerdict;
|
|
52
|
+
readonly machineClass: string;
|
|
53
|
+
readonly host: { readonly name: string; readonly version: string; readonly hostingEpoch: number };
|
|
54
|
+
readonly rules: string;
|
|
55
|
+
readonly recordSchema: string;
|
|
56
|
+
readonly cells: readonly JudgedCell[];
|
|
57
|
+
readonly conditions: Readonly<Record<string, string>>;
|
|
58
|
+
readonly latencyLineMs: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface JudgedLayer {
|
|
62
|
+
readonly envelope: typeof PLUGIN_DB_ENVELOPE;
|
|
63
|
+
readonly layer: "judged";
|
|
64
|
+
readonly address: string;
|
|
65
|
+
readonly pin: LayerPin;
|
|
66
|
+
readonly writer: "plugin-qualify";
|
|
67
|
+
readonly body: JudgedBody;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What the converter is given beside the record: the class word, the pin, and the rules when the record states none. */
|
|
71
|
+
export interface JudgedLayerInput {
|
|
72
|
+
/** The machine class word the record was measured on (`rpi5`, `x86-64`): the set's host class. */
|
|
73
|
+
readonly machineClass: string;
|
|
74
|
+
readonly pin: LayerPin;
|
|
75
|
+
/** plugin-db's address; by default `<format>:<id>`. */
|
|
76
|
+
readonly address?: string;
|
|
77
|
+
/** The rules of a record that states none: the plugin-qualify version that judged its set (the set's provenance). */
|
|
78
|
+
readonly judgedBy?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Why a record has no judged layer, when it has none. */
|
|
82
|
+
export type NoLayer = { readonly layer: null; readonly reason: string };
|
|
83
|
+
|
|
84
|
+
/** The version of a tool named `<name> <version>` in the record's `tool`. */
|
|
85
|
+
function versionOf(record: QualificationRecord, name: string): string | undefined {
|
|
86
|
+
for (const v of Object.values(record.tool)) {
|
|
87
|
+
const m = new RegExp(`^${name.replace(/[-.]/g, "\\$&")} (\\S+)$`).exec(v);
|
|
88
|
+
if (m) return m[1];
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The cells of the record: each measured rate and quantum, with its latency in milliseconds when one was measured. */
|
|
94
|
+
export function judgedCells(record: QualificationRecord): JudgedCell[] {
|
|
95
|
+
return record.measurements.grid
|
|
96
|
+
.filter((p) => p.outcome === "probe.ok")
|
|
97
|
+
.map((p) => {
|
|
98
|
+
const frames = (p.latency as { measuredFrames?: number }).measuredFrames;
|
|
99
|
+
return { rate: p.rate, quantum: p.quantum, ...(frames === undefined ? {} : { latencyMs: latencyMsOf(frames, p.rate) }) };
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The judged layer of a record, or why there is none. The verdict is the isolated tier host's, as the pins file gives
|
|
105
|
+
* it; the host is plugin-hostd at the probe runner's version, which is plugin-hostd's.
|
|
106
|
+
*/
|
|
107
|
+
export function judgedLayerOf(record: QualificationRecord, input: JudgedLayerInput): JudgedLayer | NoLayer {
|
|
108
|
+
if (record.schema !== QUALIFICATION_RECORD_SCHEMA) return { layer: null, reason: `record schema ${JSON.stringify(record.schema)} is not ${QUALIFICATION_RECORD_SCHEMA}` };
|
|
109
|
+
const tierHost = ISOLATED_TIER_HOST[record.plugin.format];
|
|
110
|
+
const v = record.verdicts.find((r) => r.host === tierHost);
|
|
111
|
+
if (v === undefined) return { layer: null, reason: `no verdict under ${tierHost ?? `a host for ${record.plugin.format}`}` };
|
|
112
|
+
const verdict = v.measured ? pinVerdictOf(v.rating as SuitabilityRating) : undefined;
|
|
113
|
+
if (verdict === undefined) return { layer: null, reason: `${tierHost}: not measured (${v.deciding ?? v.rating})` };
|
|
114
|
+
const hostVersion = versionOf(record, "plugin-hostd-probe");
|
|
115
|
+
if (hostVersion === undefined) return { layer: null, reason: "the record names no plugin-hostd-probe version" };
|
|
116
|
+
const rules = record.rules ?? (input.judgedBy === undefined ? undefined : judgementRules(input.judgedBy));
|
|
117
|
+
if (rules === undefined) return { layer: null, reason: "the record states no rules, and no judgedBy was given" };
|
|
118
|
+
return {
|
|
119
|
+
envelope: PLUGIN_DB_ENVELOPE,
|
|
120
|
+
layer: "judged",
|
|
121
|
+
address: input.address ?? `${record.plugin.format}:${record.plugin.id}`,
|
|
122
|
+
pin: input.pin,
|
|
123
|
+
writer: "plugin-qualify",
|
|
124
|
+
body: {
|
|
125
|
+
verdict,
|
|
126
|
+
machineClass: input.machineClass,
|
|
127
|
+
host: { name: JUDGED_HOST, version: hostVersion, hostingEpoch: HOSTING_EPOCH[JUDGED_HOST] },
|
|
128
|
+
rules,
|
|
129
|
+
recordSchema: record.schema,
|
|
130
|
+
cells: judgedCells(record),
|
|
131
|
+
conditions: {},
|
|
132
|
+
latencyLineMs: record.latencyLineMs ?? LATENCY_LINE_MS,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface JudgedLayerArgs {
|
|
138
|
+
readonly record: string;
|
|
139
|
+
readonly machineClass: string;
|
|
140
|
+
readonly files: string;
|
|
141
|
+
readonly layout: string | null;
|
|
142
|
+
readonly judgedBy?: string;
|
|
143
|
+
readonly address?: string;
|
|
144
|
+
/** Print the body only, as `plugin-db put <address> judged --writer plugin-qualify` reads it from stdin. */
|
|
145
|
+
readonly body: boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function judgedLayerHelpText(): string {
|
|
149
|
+
return [
|
|
150
|
+
"usage: plugin-qualify judged-layer <record.qualification.json> --machine-class <word> --files <sha256>",
|
|
151
|
+
" [--layout <scheme>:<sha256>] [--judged-by <version>] [--address <address>] [--body]",
|
|
152
|
+
"",
|
|
153
|
+
"Print the plugin-db judged layer of a qualification record, for the pin plugin-db holds (its files digest and",
|
|
154
|
+
"layout). --judged-by names the plugin-qualify that judged a record stating no rules (the set's provenance). --body",
|
|
155
|
+
"prints the body only, for `plugin-db put <address> judged --writer plugin-qualify`. A record whose verdict under",
|
|
156
|
+
"plugin-hostd's worker was not measured has no layer: the reason is printed and the exit status is 1.",
|
|
157
|
+
"",
|
|
158
|
+
].join("\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type ParsedJudgedLayerArgs = { ok: true; args: JudgedLayerArgs } | { ok: true; help: true } | { ok: false; error: string };
|
|
162
|
+
|
|
163
|
+
export function parseJudgedLayerArgs(argv: readonly string[]): ParsedJudgedLayerArgs {
|
|
164
|
+
const opts: Record<string, string> = {};
|
|
165
|
+
const files: string[] = [];
|
|
166
|
+
let body = false;
|
|
167
|
+
for (let i = 0; i < argv.length; i++) {
|
|
168
|
+
const a = argv[i]!;
|
|
169
|
+
if (a === "--help" || a === "-h") return { ok: true, help: true };
|
|
170
|
+
if (a === "--body") body = true;
|
|
171
|
+
else if (["--machine-class", "--files", "--layout", "--judged-by", "--address"].includes(a)) {
|
|
172
|
+
const v = argv[++i];
|
|
173
|
+
if (v === undefined || v.startsWith("--")) return { ok: false, error: `${a} needs a value` };
|
|
174
|
+
opts[a] = v;
|
|
175
|
+
} else if (a.startsWith("--")) return { ok: false, error: `unknown option ${a}` };
|
|
176
|
+
else files.push(a);
|
|
177
|
+
}
|
|
178
|
+
if (files.length !== 1) return { ok: false, error: "name one record" };
|
|
179
|
+
const machineClass = opts["--machine-class"];
|
|
180
|
+
const pinFiles = opts["--files"];
|
|
181
|
+
if (machineClass === undefined) return { ok: false, error: "--machine-class is required" };
|
|
182
|
+
if (pinFiles === undefined || !/^[0-9a-f]{64}$/.test(pinFiles)) return { ok: false, error: "--files must be the 64 hex digits of the files pin" };
|
|
183
|
+
const layout = opts["--layout"] ?? null;
|
|
184
|
+
if (layout !== null && !/^[^:]+:[0-9a-f]{64}$/.test(layout)) return { ok: false, error: "--layout must be <scheme>:<sha256>" };
|
|
185
|
+
return {
|
|
186
|
+
ok: true,
|
|
187
|
+
args: {
|
|
188
|
+
record: files[0]!,
|
|
189
|
+
machineClass,
|
|
190
|
+
files: pinFiles,
|
|
191
|
+
layout,
|
|
192
|
+
body,
|
|
193
|
+
...(opts["--judged-by"] === undefined ? {} : { judgedBy: opts["--judged-by"] }),
|
|
194
|
+
...(opts["--address"] === undefined ? {} : { address: opts["--address"] }),
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* What plugin-qualify's judgement declares once and writes into every record (and so into every judged layer): the
|
|
5
|
+
* latency line and the rules a record was judged by (openmixer plugin-system design 7.15, plugin-db records.md).
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
|
|
9
|
+
/** The latency line, in milliseconds: declared once, here, and written into every record and judged layer. */
|
|
10
|
+
export const LATENCY_LINE_MS = 5;
|
|
11
|
+
|
|
12
|
+
/** A measured latency in milliseconds at its rate, rounded to the microsecond. */
|
|
13
|
+
export const latencyMsOf = (frames: number, rate: number): number => Math.round((frames * 1e9) / rate) / 1e6;
|
|
14
|
+
|
|
15
|
+
const VERSION: string = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
16
|
+
|
|
17
|
+
/** The rules a plugin-qualify of this version judges by: `plugin-qualify-rules/<major>.<minor>`. */
|
|
18
|
+
export function judgementRules(version: string = VERSION): string {
|
|
19
|
+
const m = /^(\d+)\.(\d+)\.\d+/.exec(version);
|
|
20
|
+
if (m === null) throw new Error(`judgementRules: ${JSON.stringify(version)} is no version`);
|
|
21
|
+
return `plugin-qualify-rules/${m[1]}.${m[2]}`;
|
|
22
|
+
}
|
package/src/measurement/cli.ts
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
import { join } from "node:path";
|
|
36
36
|
|
|
37
37
|
import type { MeasurementDocument, MeasurementRun, MeasuredPlugin } from "./format.js";
|
|
38
|
-
import { buildMeasurementDocument } from "./format.js";
|
|
38
|
+
import { buildMeasurementDocument, readIntegrity } from "./format.js";
|
|
39
39
|
import { measuredPluginsFromProbeOutput } from "./probe-json.js";
|
|
40
40
|
import { buildRun, costMethodFromProbeProvenance, hostFromProbeProvenance } from "./provenance.js";
|
|
41
41
|
import type { MeasurementHost, LatencyMethod } from "./format.js";
|
|
@@ -237,10 +237,17 @@ export function buildPipeline(options: MeasureOptions, native?: HostingTools): P
|
|
|
237
237
|
});
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
// a run of the prober's dimensions alone takes the pin too: a plugin measured with no integrity block cannot be pinned
|
|
241
|
+
// from its figures (plugin-qualify#316), and the measurer names the files without instantiating anything
|
|
242
|
+
const pinned = !hosting && probed && native !== undefined && (options.plugins !== undefined || current !== undefined);
|
|
243
|
+
if (pinned && native !== undefined) {
|
|
244
|
+
steps.push({ id: "pin", command: native.tool, args: ["--pin"], perPlugin: true, produces: join(options.workDir, "pins.jsonl") });
|
|
245
|
+
}
|
|
246
|
+
|
|
240
247
|
return {
|
|
241
248
|
steps,
|
|
242
249
|
...(probed && current !== undefined ? { finalCatalog: current } : {}),
|
|
243
|
-
...(hosting && options.plugins === undefined && current !== undefined ? { hostingScan: current } : {}),
|
|
250
|
+
...((hosting || pinned) && options.plugins === undefined && current !== undefined ? { hostingScan: current } : {}),
|
|
244
251
|
...(costProvenance === undefined ? {} : { costProvenance }),
|
|
245
252
|
};
|
|
246
253
|
}
|
|
@@ -599,6 +606,8 @@ export interface AssemblyInput {
|
|
|
599
606
|
readonly catalog?: unknown;
|
|
600
607
|
/** The hosting pass's lines ({@link hostingLine}), one per plugin. Absent when it did not run. */
|
|
601
608
|
readonly hosting?: readonly unknown[];
|
|
609
|
+
/** The measurer's `--pin` lines ({@link hostingLine}), one per plugin, for a run with no hosting pass. Absent when the pin step did not run. */
|
|
610
|
+
readonly pins?: readonly unknown[];
|
|
602
611
|
/** The cost pass's provenance JSON, already parsed. Absent on a latency-only run. */
|
|
603
612
|
readonly costProvenance?: unknown;
|
|
604
613
|
/** Host facts, for a run whose provenance the cost pass did not write. */
|
|
@@ -636,6 +645,7 @@ export function assembleDocument(input: AssemblyInput): MeasurementDocument {
|
|
|
636
645
|
const plugins: MeasuredPlugin[] =
|
|
637
646
|
probed.length > 0 && input.catalog !== undefined ? measuredPluginsFromProbeOutput(input.catalog, probed) : [];
|
|
638
647
|
if (input.hosting !== undefined) mergeHosting(plugins, measuredPluginsFromSweep({ measurements: input.hosting }), input.options.dimensions);
|
|
648
|
+
if (input.pins !== undefined) mergePins(plugins, input.pins);
|
|
639
649
|
const costMethod = costMethodFromProbeProvenance(input.costProvenance);
|
|
640
650
|
const host = hostFromProbeProvenance(input.costProvenance) ?? input.host;
|
|
641
651
|
const run: MeasurementRun = buildRun({
|
|
@@ -655,6 +665,20 @@ export function assembleDocument(input: AssemblyInput): MeasurementDocument {
|
|
|
655
665
|
return buildMeasurementDocument(run, plugins);
|
|
656
666
|
}
|
|
657
667
|
|
|
668
|
+
/** The pin lines folded into the prober's plugins, by URI: a plugin without one keeps none, and a line for a plugin not measured adds none. */
|
|
669
|
+
function mergePins(plugins: MeasuredPlugin[], rows: readonly unknown[]): void {
|
|
670
|
+
for (const row of rows) {
|
|
671
|
+
if (typeof row !== "object" || row === null) continue;
|
|
672
|
+
const r = row as Record<string, unknown>;
|
|
673
|
+
const integrity = readIntegrity(r["integrity"]);
|
|
674
|
+
const at = plugins.findIndex((p) => p.uri === r["uri"]);
|
|
675
|
+
if (integrity === undefined || at < 0) continue;
|
|
676
|
+
const p = plugins[at]!;
|
|
677
|
+
const version = typeof r["version"] === "string" && r["version"] !== "" ? r["version"] : undefined;
|
|
678
|
+
plugins[at] = { ...p, ...(p.integrity === undefined ? { integrity } : {}), ...(p.version === undefined && version !== undefined ? { version } : {}) };
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
658
682
|
/**
|
|
659
683
|
* The hosting pass's plugins folded into the prober's, by URI: each keeps the hosting dimensions the
|
|
660
684
|
* run asked for and the pin, and a plugin only the hosting pass measured joins with its own topology.
|
|
@@ -303,6 +303,8 @@ export interface MemoryFootprint {
|
|
|
303
303
|
/** Activated, after processing: what a long-running host carries. */
|
|
304
304
|
readonly atRestKb?: number;
|
|
305
305
|
readonly notesHeldKb?: number;
|
|
306
|
+
/** The notes held (voices sounding) when `notesHeldKb` was read, the count `basis` says where it came from. */
|
|
307
|
+
readonly notesHeld?: number;
|
|
306
308
|
readonly secondInstanceKb?: number;
|
|
307
309
|
readonly basis?: VoiceBasis;
|
|
308
310
|
readonly unmeasuredReason?: string;
|
|
@@ -950,6 +952,11 @@ function readLatency(value: unknown): LatencyMeasurement | undefined {
|
|
|
950
952
|
}
|
|
951
953
|
|
|
952
954
|
/** A KB figure: finite and never negative. */
|
|
955
|
+
function notesCount(value: unknown): number | undefined {
|
|
956
|
+
const n = positiveNumber(value);
|
|
957
|
+
return n !== undefined && Number.isInteger(n) ? n : undefined;
|
|
958
|
+
}
|
|
959
|
+
|
|
953
960
|
function kilobytes(value: unknown): number | undefined {
|
|
954
961
|
const n = finiteNumber(value);
|
|
955
962
|
return n !== undefined && n >= 0 ? n : undefined;
|
|
@@ -963,6 +970,7 @@ export function readMemoryFootprint(value: unknown): MemoryFootprint | undefined
|
|
|
963
970
|
...optional("afterActivateKb", kilobytes(value.afterActivateKb)),
|
|
964
971
|
...optional("atRestKb", kilobytes(value.atRestKb)),
|
|
965
972
|
...optional("notesHeldKb", kilobytes(value.notesHeldKb)),
|
|
973
|
+
...optional("notesHeld", notesCount(value.notesHeld)),
|
|
966
974
|
...optional("secondInstanceKb", kilobytes(value.secondInstanceKb)),
|
|
967
975
|
...optional("basis", isVoiceBasis(value.basis) ? value.basis : undefined),
|
|
968
976
|
...reasonOf(value),
|
package/src/probe-merge.ts
CHANGED
|
@@ -83,6 +83,10 @@ export function mergeProbeRuns(parts: readonly ProbeRunPart[], bound: MemoryBoun
|
|
|
83
83
|
};
|
|
84
84
|
merged["steps"] = steps;
|
|
85
85
|
merged["grid"] = grid;
|
|
86
|
+
// every run's stderr is cited, not only the first's: the cell that crashed is as often a later one
|
|
87
|
+
const logs = docs.flatMap((d) => (Array.isArray(d["logs"]) ? (d["logs"] as Json[]) : []));
|
|
88
|
+
if (logs.length > 0) merged["logs"] = logs;
|
|
89
|
+
else delete merged["logs"];
|
|
86
90
|
const bytes = Buffer.from(`${JSON.stringify(merged, null, 2)}\n`);
|
|
87
91
|
const read = readProbeOutput(bytes.toString("utf8"));
|
|
88
92
|
if (!read.ok) throw new ProbeMergeError(`the merged runs of ${first.output.plugin.id} do not read as a probe output (${read.code}): ${read.problem}`);
|
package/src/probe-runner.ts
CHANGED
|
@@ -165,6 +165,9 @@ export interface ProbeLoad {
|
|
|
165
165
|
export interface ProbedTarget {
|
|
166
166
|
readonly bytes: Buffer;
|
|
167
167
|
readonly output: ProbeOutput;
|
|
168
|
+
/** What each run's child printed on stderr (the file the document's `logs` cites, read before the run's directory goes),
|
|
169
|
+
* one entry a run that printed anything: a crash's own words are kept with the record (#samplv1, 459 bytes lost on the Pi). */
|
|
170
|
+
readonly stderr: readonly Buffer[];
|
|
168
171
|
/** The most loaded the machine was when any of the target's runs started. */
|
|
169
172
|
readonly load?: ProbeLoad;
|
|
170
173
|
}
|
|
@@ -226,7 +229,14 @@ function runOne(target: PluginTarget, options: ProbeTargetOptions, invocation: R
|
|
|
226
229
|
if (read.output.plugin.id !== expectedId) {
|
|
227
230
|
throw new ProbeRunnerError(`${tool} measured ${read.output.plugin.id} when asked for ${expectedId}: not judged as the plugin asked for`);
|
|
228
231
|
}
|
|
229
|
-
|
|
232
|
+
let stderr: Buffer[] = [];
|
|
233
|
+
try {
|
|
234
|
+
const said = readFileSync(log);
|
|
235
|
+
if (said.length > 0) stderr = [said];
|
|
236
|
+
} catch {
|
|
237
|
+
// the runner wrote no log: the document cites none
|
|
238
|
+
}
|
|
239
|
+
return { bytes, output: read.output, load, stderr };
|
|
230
240
|
} finally {
|
|
231
241
|
rmSync(work, { recursive: true, force: true });
|
|
232
242
|
}
|
|
@@ -246,7 +256,7 @@ export function probeTarget(target: PluginTarget, options: ProbeTargetOptions):
|
|
|
246
256
|
const loads = parts.flatMap((p) => (p.load === undefined ? [] : [p.load]));
|
|
247
257
|
const load = loads.length === 0 ? undefined : loads.reduce((a, b) => (b.loadavg1 > a.loadavg1 ? b : a));
|
|
248
258
|
try {
|
|
249
|
-
return { ...mergeProbeRuns(parts, options.memory), ...(load === undefined ? {} : { load }) };
|
|
259
|
+
return { ...mergeProbeRuns(parts, options.memory), stderr: parts.flatMap((p) => p.stderr), ...(load === undefined ? {} : { load }) };
|
|
250
260
|
} catch (e) {
|
|
251
261
|
if (e instanceof ProbeMergeError) throw new ProbeRunnerError(e.message);
|
|
252
262
|
throw e;
|
|
@@ -256,7 +266,7 @@ export function probeTarget(target: PluginTarget, options: ProbeTargetOptions):
|
|
|
256
266
|
/** What the pass over a run's targets produced. */
|
|
257
267
|
export interface ProbePass {
|
|
258
268
|
/** Every probe output that was judged, with its bytes, for the qualification directory. */
|
|
259
|
-
readonly runs: readonly { readonly bytes: Buffer; readonly judgment: Judgment }[];
|
|
269
|
+
readonly runs: readonly { readonly bytes: Buffer; readonly judgment: Judgment; readonly stderr: readonly Buffer[] }[];
|
|
260
270
|
readonly verdicts: readonly QualifyVerdict[];
|
|
261
271
|
/** The CLAP documents of today's judge, which the pins file is taken from. */
|
|
262
272
|
readonly documents: readonly RtSafetyDocument[];
|
|
@@ -292,7 +302,7 @@ export function qualifyTargets(
|
|
|
292
302
|
const admissionRuns = options.admissions ?? SPAWNED_ADMISSIONS;
|
|
293
303
|
const runEnvironment = options.env ?? process.env;
|
|
294
304
|
const admissionEnv = options.admissionLv2Path === undefined ? runEnvironment : { ...runEnvironment, LV2_PATH: options.admissionLv2Path.join(":") };
|
|
295
|
-
const runs: { bytes: Buffer; judgment: Judgment }[] = [];
|
|
305
|
+
const runs: { bytes: Buffer; judgment: Judgment; stderr: readonly Buffer[] }[] = [];
|
|
296
306
|
const verdicts: QualifyVerdict[] = [];
|
|
297
307
|
const documents: RtSafetyDocument[] = [];
|
|
298
308
|
const failures: { uri: string; problem: string }[] = [];
|
|
@@ -330,13 +340,13 @@ export function qualifyTargets(
|
|
|
330
340
|
const judged = new Map<string, QualifyVerdict>();
|
|
331
341
|
// a target's runs and documents count only when every one of its runs gave a probe output: one that
|
|
332
342
|
// failed leaves the target not measured, and then nothing of it is recorded or pinned
|
|
333
|
-
const targetRuns: { bytes: Buffer; judgment: Judgment }[] = [];
|
|
343
|
+
const targetRuns: { bytes: Buffer; judgment: Judgment; stderr: readonly Buffer[] }[] = [];
|
|
334
344
|
try {
|
|
335
345
|
for (const { offer, profiles: forOffer } of byOffer.values()) {
|
|
336
346
|
const probed = probeTarget(target, { ...options, ...measured, offer });
|
|
337
347
|
const plain = judgeProbeOutput(probed.output, forOffer, { admissions });
|
|
338
348
|
const judgment = { ...plain, ...(adapter === undefined ? {} : { adapter }), ...(probed.load === undefined ? {} : { load: probed.load }) };
|
|
339
|
-
targetRuns.push({ bytes: probed.bytes, judgment });
|
|
349
|
+
targetRuns.push({ bytes: probed.bytes, judgment, stderr: probed.stderr });
|
|
340
350
|
for (const v of judgmentVerdicts(judgment)) judged.set(v.host, v);
|
|
341
351
|
}
|
|
342
352
|
} catch (e) {
|
|
@@ -20,11 +20,12 @@ import { join } from "node:path";
|
|
|
20
20
|
|
|
21
21
|
import type { Adjustment } from "./history-adjust.js";
|
|
22
22
|
import type { Judgment, RecordReason } from "./probe-judge.js";
|
|
23
|
-
import { sha256Hex, PROBE_OK, type ProbeGridPoint, type ProbeOutput } from "./probe-output.js";
|
|
23
|
+
import { sha256Hex, PROBE_NOT_ATTEMPTED, PROBE_OK, type ProbeGridPoint, type ProbeOutput } from "./probe-output.js";
|
|
24
24
|
import type { VoicesHeld } from "./measurement/format.js";
|
|
25
25
|
import { writeFileWhole } from "./whole-file.js";
|
|
26
26
|
import { HOSTING_CODE } from "./hosting-codes.js";
|
|
27
27
|
import { MEASUREMENT_UNMEASURED_REASONS } from "@openmixer/omx-contract";
|
|
28
|
+
import { judgementRules, latencyMsOf, LATENCY_LINE_MS } from "./judgement-line.js";
|
|
28
29
|
|
|
29
30
|
export const QUALIFICATION_RECORD_SCHEMA = "plugin-qualify.qualification-record/1";
|
|
30
31
|
|
|
@@ -201,6 +202,8 @@ export type RecordGridPoint = Omit<ProbeGridPoint, "latency" | "memory"> & {
|
|
|
201
202
|
/** The footprint as measured, with what it comes to: what a client adding the plugin reads (`memory-budget.ts`). */
|
|
202
203
|
readonly memory?: MemoryFootprint & { readonly firstInstanceKb?: number; readonly perInstanceKb?: number };
|
|
203
204
|
readonly latency: (Omit<NonNullable<ProbeGridPoint["latency"]>, "measuredFrames"> & { readonly measuredFrames: number }) | UnmeasuredLatency;
|
|
205
|
+
/** The measured latency in milliseconds at the cell's rate, beside the record's `latencyLineMs`; absent when none was measured. */
|
|
206
|
+
readonly latencyMs?: number;
|
|
204
207
|
};
|
|
205
208
|
|
|
206
209
|
const UNMEASURED_WORDS: ReadonlySet<string> = new Set(Object.values(MEASUREMENT_UNMEASURED_REASONS).map((r) => r.word));
|
|
@@ -220,12 +223,20 @@ export function memoryOfCell(p: ProbeGridPoint): Pick<RecordGridPoint, "memory">
|
|
|
220
223
|
return { memory: { ...p.memory, ...(first === undefined ? {} : { firstInstanceKb: first }), ...(per === undefined ? {} : { perInstanceKb: per }) } };
|
|
221
224
|
}
|
|
222
225
|
|
|
226
|
+
/** A grid point as the record writes it: its footprint, its latency or why there is none, and the latency in milliseconds. */
|
|
227
|
+
export function gridPointOf(output: ProbeOutput, p: ProbeGridPoint): RecordGridPoint {
|
|
228
|
+
const latency = latencyOfCell(output, p);
|
|
229
|
+
const frames = (latency as { measuredFrames?: number }).measuredFrames;
|
|
230
|
+
return { ...p, ...memoryOfCell(p), latency, ...(frames === undefined ? {} : { latencyMs: latencyMsOf(frames, p.rate) }) };
|
|
231
|
+
}
|
|
232
|
+
|
|
223
233
|
/** The unmeasured word of a cell whose run did not end well, by its outcome code. */
|
|
224
234
|
const OUTCOME_WORD: Readonly<Record<string, string>> = {
|
|
225
235
|
"probe.crashed": word("probeCrashed"),
|
|
226
236
|
"probe.timeout": word("probeTimeout"),
|
|
227
237
|
"probe.refused-instantiation": word("refusedInstantiation"),
|
|
228
238
|
"probe.non-finite-output": word("nonFiniteOutput"),
|
|
239
|
+
[PROBE_NOT_ATTEMPTED]: word("notAttempted"),
|
|
229
240
|
};
|
|
230
241
|
|
|
231
242
|
/**
|
|
@@ -361,6 +372,10 @@ export interface QualificationRecord {
|
|
|
361
372
|
/** Who measured and when. */
|
|
362
373
|
readonly tool: Readonly<Record<string, string>>;
|
|
363
374
|
readonly measuredAt: string;
|
|
375
|
+
/** The latency line the cells' `latencyMs` stand against (`LATENCY_LINE_MS`); absent on a record written before it was declared. */
|
|
376
|
+
readonly latencyLineMs?: number;
|
|
377
|
+
/** The rules the verdicts were judged by (`judgementRules`); absent on a record written before they were stated. */
|
|
378
|
+
readonly rules?: string;
|
|
364
379
|
/** The second verdict, per host class of the history that was read; absent until `adjust --history` has run. */
|
|
365
380
|
readonly history?: readonly HistoryBlock[];
|
|
366
381
|
/** Where the record came from when it was not measured on this device: the distributed set's provenance, as the set's package wrote it. */
|
|
@@ -428,6 +443,8 @@ export function qualificationBaseName(output: ProbeOutput): string {
|
|
|
428
443
|
|
|
429
444
|
export const recordFileName = (output: ProbeOutput): string => `${qualificationBaseName(output)}.qualification.json`;
|
|
430
445
|
export const probeCopyFileName = (output: ProbeOutput): string => `${qualificationBaseName(output)}.probe.json`;
|
|
446
|
+
/** A child's stderr beside the record, named by the first 12 of the SHA-256 the probe copy's `logs` cites it by. */
|
|
447
|
+
export const stderrCopyFileName = (output: ProbeOutput, sha256: string): string => `${qualificationBaseName(output)}.stderr.${sha256.slice(0, 12)}.log`;
|
|
431
448
|
|
|
432
449
|
function tierOf(judgment: Judgment, host: string | undefined): TierVerdict | undefined {
|
|
433
450
|
const v = judgment.hosts.find((h) => h.verdict.host === host)?.verdict;
|
|
@@ -481,13 +498,15 @@ export function buildQualificationRecord(
|
|
|
481
498
|
tiers: tiersOf(judgment),
|
|
482
499
|
cost: judgment.hosts.map((h) => ({ host: h.verdict.host, ...h.cost })),
|
|
483
500
|
measurements: {
|
|
484
|
-
grid: o.grid.map((p) => (
|
|
501
|
+
grid: o.grid.map((p) => gridPointOf(o, p)),
|
|
485
502
|
...(o.behaviour === undefined ? {} : { behaviour: o.behaviour }),
|
|
486
503
|
...(unmeasuredFigures(o).length === 0 ? {} : { unmeasured: unmeasuredFigures(o) }),
|
|
487
504
|
},
|
|
488
505
|
...(notMeasured.length === 0 ? {} : { notMeasured }),
|
|
489
506
|
tool: o.run.tools,
|
|
490
507
|
measuredAt: o.run.startedAt,
|
|
508
|
+
latencyLineMs: LATENCY_LINE_MS,
|
|
509
|
+
rules: judgementRules(),
|
|
491
510
|
probe: {
|
|
492
511
|
file: copy.file,
|
|
493
512
|
sha256: copy.sha256,
|
|
@@ -518,6 +537,8 @@ export interface WrittenQualification {
|
|
|
518
537
|
readonly recordPath: string;
|
|
519
538
|
readonly probePath: string;
|
|
520
539
|
readonly record: QualificationRecord;
|
|
540
|
+
/** The children's stderr written beside the record, one file each run that printed anything. */
|
|
541
|
+
readonly stderrPaths: readonly string[];
|
|
521
542
|
}
|
|
522
543
|
|
|
523
544
|
/**
|
|
@@ -530,33 +551,46 @@ export function writeQualification(
|
|
|
530
551
|
probeBytes: Uint8Array,
|
|
531
552
|
judgment: Judgment,
|
|
532
553
|
notMeasured: readonly NotMeasured[] = [],
|
|
554
|
+
stderr: readonly Uint8Array[] = [],
|
|
533
555
|
): WrittenQualification {
|
|
534
556
|
mkdirSync(dir, { recursive: true });
|
|
557
|
+
// the children's own words first, as the probe copy cites them: a crash's reason is kept, not only its size
|
|
558
|
+
const stderrPaths = stderr
|
|
559
|
+
.filter((s) => s.length > 0)
|
|
560
|
+
.map((s) => {
|
|
561
|
+
const path = join(dir, stderrCopyFileName(judgment.output, sha256Hex(s)));
|
|
562
|
+
writeFileWhole(path, s);
|
|
563
|
+
return path;
|
|
564
|
+
});
|
|
535
565
|
const probePath = join(dir, probeCopyFileName(judgment.output));
|
|
536
566
|
writeFileWhole(probePath, probeBytes);
|
|
537
567
|
const copy = readFileSync(probePath);
|
|
538
568
|
const record = buildQualificationRecord(judgment, { file: probeCopyFileName(judgment.output), sha256: sha256Hex(copy), bytes: copy.length }, notMeasured);
|
|
539
569
|
const recordPath = join(dir, recordFileName(judgment.output));
|
|
540
570
|
writeFileWhole(recordPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
541
|
-
return { recordPath, probePath, record };
|
|
571
|
+
return { recordPath, probePath, record, stderrPaths };
|
|
542
572
|
}
|
|
543
573
|
|
|
544
574
|
/**
|
|
545
575
|
* Runs that name the same binary and offer share one record file (a CLAP judged with and without the extra extensions
|
|
546
576
|
* offered): their verdicts become one record over the first run's probe output, so the second is never written over the first.
|
|
547
577
|
*/
|
|
548
|
-
export function mergeRunsByRecord<T extends { readonly bytes: Uint8Array; readonly judgment: Judgment
|
|
549
|
-
|
|
578
|
+
export function mergeRunsByRecord<T extends { readonly bytes: Uint8Array; readonly judgment: Judgment; readonly stderr?: readonly Uint8Array[] }>(
|
|
579
|
+
runs: readonly T[],
|
|
580
|
+
): { bytes: Uint8Array; judgment: Judgment; stderr: readonly Uint8Array[] }[] {
|
|
581
|
+
// the stderr kept is the first run's, as its probe output is the copy that cites it
|
|
582
|
+
const byName = new Map<string, { bytes: Uint8Array; judgment: Judgment; stderr: readonly Uint8Array[] }>();
|
|
550
583
|
for (const r of runs) {
|
|
551
584
|
const name = qualificationBaseName(r.judgment.output);
|
|
552
585
|
const first = byName.get(name);
|
|
553
586
|
byName.set(
|
|
554
587
|
name,
|
|
555
588
|
first === undefined
|
|
556
|
-
? r
|
|
589
|
+
? { bytes: r.bytes, judgment: r.judgment, stderr: r.stderr ?? [] }
|
|
557
590
|
: {
|
|
558
591
|
bytes: first.bytes,
|
|
559
592
|
judgment: { ...first.judgment, hosts: [...first.judgment.hosts, ...r.judgment.hosts], documents: [...first.judgment.documents, ...r.judgment.documents] },
|
|
593
|
+
stderr: first.stderr,
|
|
560
594
|
},
|
|
561
595
|
);
|
|
562
596
|
}
|