@openmixer/plugin-qualify 0.16.0 → 0.17.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 (68) hide show
  1. package/README.md +154 -7
  2. package/bin/plugin-qualify-records-package.mjs +31 -0
  3. package/bin/plugin-qualify-sync.mjs +6 -1
  4. package/bin/plugin-qualify.mjs +12 -1
  5. package/dist/distributed-records.d.ts +88 -0
  6. package/dist/distributed-records.js +210 -0
  7. package/dist/distributed-records.js.map +1 -0
  8. package/dist/host-profiles.d.ts +5 -0
  9. package/dist/host-profiles.js +13 -0
  10. package/dist/host-profiles.js.map +1 -1
  11. package/dist/hosting-suitability.js +9 -1
  12. package/dist/hosting-suitability.js.map +1 -1
  13. package/dist/index.d.ts +5 -0
  14. package/dist/index.js +5 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/measurement-grid.d.ts +49 -0
  17. package/dist/measurement-grid.js +76 -0
  18. package/dist/measurement-grid.js.map +1 -0
  19. package/dist/memory-bound.d.ts +23 -0
  20. package/dist/memory-bound.js +46 -0
  21. package/dist/memory-bound.js.map +1 -0
  22. package/dist/probe-judge.js +22 -1
  23. package/dist/probe-judge.js.map +1 -1
  24. package/dist/probe-merge.d.ts +30 -0
  25. package/dist/probe-merge.js +80 -0
  26. package/dist/probe-merge.js.map +1 -0
  27. package/dist/probe-output.d.ts +8 -0
  28. package/dist/probe-output.js +7 -0
  29. package/dist/probe-output.js.map +1 -1
  30. package/dist/probe-runner.d.ts +26 -2
  31. package/dist/probe-runner.js +38 -10
  32. package/dist/probe-runner.js.map +1 -1
  33. package/dist/qualification-record.d.ts +19 -1
  34. package/dist/qualification-record.js +30 -1
  35. package/dist/qualification-record.js.map +1 -1
  36. package/dist/qualify-cli.d.ts +9 -0
  37. package/dist/qualify-cli.js +30 -1
  38. package/dist/qualify-cli.js.map +1 -1
  39. package/dist/qualify-declarations.d.ts +15 -0
  40. package/dist/qualify-declarations.js +15 -0
  41. package/dist/qualify-declarations.js.map +1 -1
  42. package/dist/qualify-options.js +11 -1
  43. package/dist/qualify-options.js.map +1 -1
  44. package/dist/qualify-sync.d.ts +23 -3
  45. package/dist/qualify-sync.js +172 -16
  46. package/dist/qualify-sync.js.map +1 -1
  47. package/dist/records-package.d.ts +63 -0
  48. package/dist/records-package.js +300 -0
  49. package/dist/records-package.js.map +1 -0
  50. package/package.json +3 -2
  51. package/src/distributed-records.ts +251 -0
  52. package/src/host-profiles.ts +13 -0
  53. package/src/hosting-suitability.ts +11 -1
  54. package/src/index.ts +5 -0
  55. package/src/measurement-grid.ts +97 -0
  56. package/src/memory-bound.ts +55 -0
  57. package/src/probe-judge.ts +25 -1
  58. package/src/probe-merge.ts +89 -0
  59. package/src/probe-output.ts +13 -0
  60. package/src/probe-runner.ts +75 -13
  61. package/src/qualification-record.ts +44 -2
  62. package/src/qualify-cli.ts +32 -1
  63. package/src/qualify-declarations.ts +17 -0
  64. package/src/qualify-options.ts +11 -1
  65. package/src/qualify-sync.ts +175 -16
  66. package/src/records-package.ts +333 -0
  67. package/tools/{ai-trace-commit-check.sh → commit-message-check.sh} +30 -28
  68. package/tools/{ai-trace-patterns.json → commit-message-patterns.json} +1 -1
@@ -24,6 +24,7 @@ import { homedir, tmpdir } from "node:os";
24
24
  import { basename, dirname, join, resolve } from "node:path";
25
25
 
26
26
  import { clapQualifyTools } from "./clap-qualify.js";
27
+ import { DISTRIBUTED_DIR_SYSTEM, EMPTY_LAYER, applyDistributed, distributedUri, hostClassOfBoard, matchDistributed, readBoardModel, readDistributedLayer, type DistributedLayer } from "./distributed-records.js";
27
28
  import { clapSearchPath, parseClapScan } from "./clap-loader.js";
28
29
  import { HOSTING_CODE } from "./hosting-codes.js";
29
30
  import { shippedHostProfile, type HostProfile } from "./host-profiles.js";
@@ -31,8 +32,10 @@ import { lv2BundlesOnPath, manifestPlugins } from "./lv2-loader.js";
31
32
  import { judgmentPinEntries, pinsFile, type PinEntry } from "./pins-file.js";
32
33
  import type { Judgment } from "./probe-judge.js";
33
34
  import { qualifyTargets } from "./probe-runner.js";
35
+ import { GRID_HOSTS, parseGridSpec, type GridSpec } from "./measurement-grid.js";
36
+ import { memoryBoundOf, parseMemoryLimit, type MemoryBound } from "./memory-bound.js";
34
37
  import { writeQualification, type NotMeasured } from "./qualification-record.js";
35
- import { DESCRIPTOR_DIR_STATE_SUBDIR, DESCRIPTOR_DIR_SYSTEM } from "./qualify-declarations.js";
38
+ import { DESCRIPTOR_DIR_STATE_SUBDIR, DESCRIPTOR_DIR_SYSTEM, QUALIFY_QUANTA, QUALIFY_RATES } from "./qualify-declarations.js";
36
39
  import { toolsDir } from "./tool-paths.js";
37
40
  import { writeFileWhole } from "./whole-file.js";
38
41
 
@@ -64,11 +67,16 @@ export const SYNC_SETTINGS: readonly SyncSetting[] = [
64
67
  { key: "qualification_dir", meaning: "where the qualification records go", default: "/var/lib/omx/qualification (system), else $XDG_STATE_HOME/omx/qualification" },
65
68
  { key: "descriptor_dir", meaning: "where the scanned descriptors go", default: `${DESCRIPTOR_DIR_SYSTEM} (system), else $XDG_STATE_HOME/${DESCRIPTOR_DIR_STATE_SUBDIR}` },
66
69
  { key: "state_file", meaning: "what was qualified, from which bytes, and how it ended", default: "/var/lib/omx/qualify-sync.json (system), else $XDG_STATE_HOME/omx/qualify-sync.json" },
67
- { key: "budget_s", meaning: "the wall clock one plugin gets, in seconds; past it the plugin is recorded as hung", default: "60" },
70
+ { key: "budget_s", meaning: "the wall clock one run gets (one cell of an LV2 plugin, one quantum of a CLAP one), in seconds; past it the plugin is recorded as hung", default: "60" },
68
71
  { key: "host_profile", meaning: "the host the verdict is for (a shipped profile id)", default: "omx-clap-host" },
72
+ { key: "grid", meaning: "the cells measured: hosts (the host's and the console tiers' rate/quantum, then 512 at every rate), full, or <rate>/<quantum>,...", default: "hosts" },
73
+ { key: "cells_per_plugin_budget", meaning: "measure at most the first n cells of the grid a plugin; 0 is every cell", default: "0" },
74
+ { key: "memory_limit", meaning: "the memory bound of every run: bytes, or with K, M, G; off for none; past it a cell is probe.oom", default: "half of the RAM" },
69
75
  { key: "hostd_port", meaning: "plugin-hostd's command port the new pins are sent to; 0 sends none", default: "5555" },
70
76
  { key: "hostd_feedback_port", meaning: "plugin-hostd's feedback port, connected before the command port; 0 is the command port's next", default: "0" },
71
77
  { key: "settle_s", meaning: "wait until the plugin directories have been quiet this long before a run", default: "0" },
78
+ { key: "host_class", meaning: "the machine class whose distributed records are read (a directory name)", default: "the board's own from the device tree (rpi5 on a Raspberry Pi 5), else none" },
79
+ { key: "distributed_dir", meaning: "the directories of distributed records, colon-separated, each holding <host class>/<set>/", default: `${DISTRIBUTED_DIR_SYSTEM} (system), else $XDG_DATA_HOME/omx/qualification and each $XDG_DATA_DIRS/omx/qualification` },
72
80
  ];
73
81
 
74
82
  export interface SyncSettings {
@@ -80,9 +88,19 @@ export interface SyncSettings {
80
88
  readonly stateFile: string;
81
89
  readonly budgetS: number;
82
90
  readonly hostProfile: HostProfile;
91
+ /** The cells measured, and at most how many a plugin (0: every cell). */
92
+ readonly grid: GridSpec;
93
+ readonly cellsPerPluginBudget: number;
94
+ /** The memory bound of every run; `undefined` is `off`. */
95
+ readonly memory?: MemoryBound;
83
96
  readonly hostdPort: number;
84
97
  readonly hostdFeedbackPort: number;
85
98
  readonly settleS: number;
99
+ /** The machine class the distributed records are read for; none, and the layer is not read. */
100
+ readonly hostClass?: string;
101
+ readonly distributedDirs: readonly string[];
102
+ /** False under `--no-distributed`. */
103
+ readonly distributed: boolean;
86
104
  }
87
105
 
88
106
  /** A settings file read: one `key value` per line, `#` starts a comment; a key alone is the empty value. An unknown key is refused by line. */
@@ -105,6 +123,7 @@ export interface SyncArgs {
105
123
  readonly config?: string;
106
124
  readonly system: boolean;
107
125
  readonly force: boolean;
126
+ readonly noDistributed: boolean;
108
127
  readonly help: boolean;
109
128
  /** The settings given as flags, by key. */
110
129
  readonly values: ReadonlyMap<string, string>;
@@ -118,11 +137,13 @@ export function parseSyncArgs(argv: readonly string[]): { ok: true; args: SyncAr
118
137
  let config: string | undefined;
119
138
  let system = false;
120
139
  let force = false;
140
+ let noDistributed = false;
121
141
  let help = false;
122
142
  for (let i = 0; i < argv.length; i++) {
123
143
  const a = argv[i]!;
124
144
  if (a === "--system") system = true;
125
145
  else if (a === "--force") force = true;
146
+ else if (a === "--no-distributed") noDistributed = true;
126
147
  else if (a === "--help") help = true;
127
148
  else if (a === "--config" || byFlag.has(a)) {
128
149
  const v = argv[++i];
@@ -131,18 +152,19 @@ export function parseSyncArgs(argv: readonly string[]): { ok: true; args: SyncAr
131
152
  else values.set(byFlag.get(a)!, v);
132
153
  } else return { ok: false, error: `unknown argument ${a}` };
133
154
  }
134
- return { ok: true, args: { ...(config === undefined ? {} : { config }), system, force, help, values } };
155
+ return { ok: true, args: { ...(config === undefined ? {} : { config }), system, force, noDistributed, help, values } };
135
156
  }
136
157
 
137
158
  export function syncHelpText(): string {
138
- const rows = SYNC_SETTINGS.map((s) => ` ${`${flagOf(s.key)} <value>`.padEnd(28)}${s.meaning}${s.default === "" ? "" : ` (default: ${s.default})`}`);
159
+ const rows = SYNC_SETTINGS.map((s) => ` ${`${flagOf(s.key)} <value>`.padEnd(36)}${s.meaning}${s.default === "" ? "" : ` (default: ${s.default})`}`);
139
160
  return [
140
161
  "plugin-qualify-sync: qualify every plugin on the search paths once, and again when its files change",
141
162
  "",
142
- ` --config <file> the settings file (default: ${SYNC_CONF}; a missing file is the defaults)`,
143
- " --system a system service's directories, under /var/lib/omx",
144
- " --force qualify every plugin again, recorded or not",
145
- " --help this text",
163
+ ` ${"--config <file>".padEnd(36)}the settings file (default: ${SYNC_CONF}; a missing file is the defaults)`,
164
+ " --system a system service's directories, under /var/lib/omx",
165
+ " --force qualify every plugin again, recorded or not; no distributed record is read",
166
+ " --no-distributed read no distributed record: every plugin not recorded is measured",
167
+ " --help this text",
146
168
  ...rows,
147
169
  "",
148
170
  "every setting is also a line of the settings file, its key as the first word",
@@ -159,6 +181,7 @@ export function resolveSyncSettings(
159
181
  args: SyncArgs,
160
182
  env: Readonly<Record<string, string | undefined>>,
161
183
  home: string = homedir(),
184
+ boardModel: () => string | undefined = readBoardModel,
162
185
  ): { ok: true; settings: SyncSettings } | { ok: false; error: string } {
163
186
  const get = (key: string): string | undefined => args.values.get(key) ?? file.get(key);
164
187
  const state = env["XDG_STATE_HOME"] !== undefined && env["XDG_STATE_HOME"] !== "" ? env["XDG_STATE_HOME"] : join(home, ".local", "state");
@@ -182,10 +205,31 @@ export function resolveSyncSettings(
182
205
  if (hostdPort === undefined || !Number.isInteger(hostdPort) || hostdPort > 65535) return { ok: false, error: "hostd_port must be a port number, or 0" };
183
206
  if (feedbackPort === undefined || !Number.isInteger(feedbackPort) || feedbackPort > 65535) return { ok: false, error: "hostd_feedback_port must be a port number, or 0" };
184
207
  if (settleS === undefined) return { ok: false, error: "settle_s must be a number of seconds" };
208
+ const gridText = get("grid");
209
+ const grid = gridText === undefined || gridText === "" ? { ok: true as const, spec: GRID_HOSTS } : parseGridSpec(gridText);
210
+ if (!grid.ok) return { ok: false, error: `grid: ${grid.error}` };
211
+ const cellsPerPluginBudget = number("cells_per_plugin_budget", 0);
212
+ if (cellsPerPluginBudget === undefined || !Number.isInteger(cellsPerPluginBudget)) return { ok: false, error: "cells_per_plugin_budget must be a whole number, 0 or more" };
213
+ const memoryText = get("memory_limit");
214
+ const memoryLimit = memoryText === undefined || memoryText === "" ? undefined : parseMemoryLimit(memoryText);
215
+ if (memoryLimit !== undefined && !memoryLimit.ok) return { ok: false, error: `memory_limit: ${memoryLimit.error}` };
216
+ const memory = memoryBoundOf(memoryLimit?.bytes);
185
217
  const profileId = get("host_profile") || "omx-clap-host";
186
218
  const hostProfile = shippedHostProfile(profileId);
187
219
  if (hostProfile === undefined) return { ok: false, error: `host_profile ${profileId} is not a shipped host profile` };
188
220
  const once = (dirs: readonly string[]): string[] => [...new Set(dirs.map((d) => resolve(d)))];
221
+ const dataHome = env["XDG_DATA_HOME"] !== undefined && env["XDG_DATA_HOME"] !== "" ? env["XDG_DATA_HOME"] : join(home, ".local", "share");
222
+ const dataDirs = dirList(env["XDG_DATA_DIRS"] ?? "").length > 0 ? dirList(env["XDG_DATA_DIRS"]!) : ["/usr/local/share", "/usr/share"];
223
+ const distributedNamed = get("distributed_dir");
224
+ const distributedDirs =
225
+ distributedNamed !== undefined && distributedNamed !== ""
226
+ ? dirList(distributedNamed)
227
+ : args.system
228
+ ? [DISTRIBUTED_DIR_SYSTEM]
229
+ : [dataHome, ...dataDirs].map((d) => join(d, "omx", "qualification"));
230
+ const classNamed = get("host_class");
231
+ const hostClass = classNamed !== undefined && classNamed !== "" ? classNamed : hostClassOfBoard(boardModel());
232
+ if (hostClass !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(hostClass)) return { ok: false, error: `host_class ${hostClass} is not a directory name` };
189
233
  return {
190
234
  ok: true,
191
235
  settings: {
@@ -197,9 +241,15 @@ export function resolveSyncSettings(
197
241
  stateFile: place("state_file", "/var/lib/omx/qualify-sync.json", join("omx", "qualify-sync.json")),
198
242
  budgetS,
199
243
  hostProfile,
244
+ grid: grid.spec,
245
+ cellsPerPluginBudget,
246
+ ...(memory === undefined ? {} : { memory }),
200
247
  hostdPort,
201
248
  hostdFeedbackPort: feedbackPort === 0 && hostdPort !== 0 ? hostdPort + 1 : feedbackPort,
202
249
  settleS,
250
+ ...(hostClass === undefined ? {} : { hostClass }),
251
+ distributedDirs: once(distributedDirs),
252
+ distributed: !args.noDistributed,
203
253
  },
204
254
  };
205
255
  }
@@ -390,6 +440,8 @@ export interface SyncEntry {
390
440
  readonly plugins: readonly SyncPlugin[];
391
441
  readonly qualifiedAt: string;
392
442
  readonly seconds: number;
443
+ /** The distributed set the plugins were taken from, when they were not measured here. */
444
+ readonly distributed?: string;
393
445
  }
394
446
 
395
447
  export interface SyncState {
@@ -484,7 +536,17 @@ function judgedPlugins(ctx: SyncContext, runs: readonly { bytes: Buffer; judgmen
484
536
  }
485
537
 
486
538
  function runPass(ctx: SyncContext, target: Parameters<typeof qualifyTargets>[0][number], env: Readonly<Record<string, string | undefined>>) {
487
- return qualifyTargets([target], { rates: [ctx.settings.hostProfile.cost.rate], profiles: [ctx.settings.hostProfile], wallS: ctx.settings.budgetS, env });
539
+ const s = ctx.settings;
540
+ return qualifyTargets([target], {
541
+ rates: QUALIFY_RATES,
542
+ quanta: QUALIFY_QUANTA,
543
+ grid: s.grid,
544
+ cellsPerPluginBudget: s.cellsPerPluginBudget,
545
+ ...(s.memory === undefined ? {} : { memory: s.memory }),
546
+ profiles: [s.hostProfile],
547
+ wallS: s.budgetS,
548
+ env,
549
+ });
488
550
  }
489
551
 
490
552
  /** The descriptors of a scanned `.clap`, through the one descriptor writer; a failure is named and the run goes on. */
@@ -566,6 +628,66 @@ export function qualifyItem(ctx: SyncContext, item: SyncItem, files: readonly Fi
566
628
  return { format: item.format, root: item.root, files: all, plugins, qualifiedAt: new Date(started).toISOString(), seconds: (Date.now() - started) / 1000 };
567
629
  }
568
630
 
631
+ // --- the distributed records ----------------------------------------------------------------------------------------
632
+
633
+ /** The layer a run reads: none under `--force` or `--no-distributed`, or without a host class. */
634
+ function distributedLayerOf(settings: SyncSettings, force: boolean, log: (line: string) => void): DistributedLayer {
635
+ if (force || !settings.distributed) return EMPTY_LAYER;
636
+ if (settings.hostClass === undefined) {
637
+ log("distributed: no host class (host_class unset, and the board names none); no distributed record is read");
638
+ return EMPTY_LAYER;
639
+ }
640
+ const layer = readDistributedLayer(settings.distributedDirs, settings.hostClass, log);
641
+ if (layer.records > 0) log(`distributed: ${String(layer.records)} records of ${layer.sets.join(", ")} for ${settings.hostClass}`);
642
+ return layer;
643
+ }
644
+
645
+ /**
646
+ * The item's entry taken from the distributed records, or `undefined` when it is to be measured: no record finds it,
647
+ * or the ones that do were not taken, each reason said. Its descriptors are written as a measured item's are: a
648
+ * `.clap` is scanned for them (the scan lists the plugins and measures nothing), an LV2 plugin joins the run's
649
+ * descriptor pass.
650
+ */
651
+ function takeDistributed(ctx: SyncContext, layer: DistributedLayer, item: SyncItem, files: readonly FileDigest[]): SyncEntry | undefined {
652
+ if (layer.records === 0) return undefined;
653
+ const started = Date.now();
654
+ const host = ctx.settings.hostProfile.id;
655
+ const match = matchDistributed(layer, item, host, (paths) => digestFiles(item.root, paths, files));
656
+ if (match.status === "none") return undefined;
657
+ if (match.status === "ignored") {
658
+ for (const line of match.lines) ctx.log(line);
659
+ return undefined;
660
+ }
661
+ let plugins: SyncPlugin[];
662
+ try {
663
+ plugins = applyDistributed(ctx.settings.qualificationDir, item, match, host);
664
+ } catch (e) {
665
+ ctx.log(`distributed: ${distributedUri(item, match.records[0]!)}: ${(e as Error).message}; measured here`);
666
+ return undefined;
667
+ }
668
+ if (item.format === "clap") scanClapDescriptors(ctx, item);
669
+ const all = digestFiles(item.root, [...item.files, ...match.files], files) ?? [...files];
670
+ return { format: item.format, root: item.root, files: all, plugins, qualifiedAt: new Date(started).toISOString(), seconds: (Date.now() - started) / 1000, distributed: match.set };
671
+ }
672
+
673
+ /** The descriptors of a `.clap` taken from the layer: the scanner lists its plugins, the descriptor writer writes them. */
674
+ function scanClapDescriptors(ctx: SyncContext, item: SyncItem): void {
675
+ const file = join(item.root, item.files[0]!);
676
+ let tool: string;
677
+ try {
678
+ tool = clapQualifyTools(ctx.env, existsSync).tool;
679
+ } catch (e) {
680
+ ctx.log(`${file}: descriptors not written: ${(e as Error).message}`);
681
+ return;
682
+ }
683
+ const r = spawnSync(tool, ["--json", file], { encoding: "utf8", timeout: Math.max(ctx.settings.budgetS, 35) * 1000, killSignal: "SIGKILL", maxBuffer: 64 << 20 });
684
+ if (r.error !== undefined || r.status !== 0) {
685
+ ctx.log(`${file}: descriptors not written: the scan ended ${r.error?.message ?? `with exit ${String(r.status)}`}`);
686
+ return;
687
+ }
688
+ writeClapDescriptors(ctx, r.stdout);
689
+ }
690
+
569
691
  // --- the outputs ------------------------------------------------------------------------------------------------------
570
692
 
571
693
  /** The pin file's text: every whole pin of the state, the worse verdict kept for a uri pinned twice. */
@@ -598,17 +720,32 @@ export function recordStem(recordPath: string): string | undefined {
598
720
  return /^(.*-[0-9a-f]{12})\.(?:[^.]+\.(?:qualification|probe)|unmeasured)\.json$/.exec(basename(recordPath))?.[1];
599
721
  }
600
722
 
723
+ /**
724
+ * A stem's id and binary hash, its version segment dropped: what a record and its descriptor share even when their
725
+ * version segments differ (a CLAP record is keyed `@unversioned` while its descriptor carries the plugin's declared
726
+ * version). Undefined for a name that is not of the `<id>@<version>-<hash>` shape.
727
+ */
728
+ function idAndHash(stem: string): string | undefined {
729
+ const m = /^(.+)@.+-([0-9a-f]{12})$/.exec(stem);
730
+ return m === null ? undefined : `${m[1]}\0${m[2]}`;
731
+ }
732
+
601
733
  /**
602
734
  * What a plugin the sync no longer finds, or finds as other bytes, leaves behind: its qualification record, the probe
603
735
  * output copied beside it and its descriptor. A stem the state before held and the state after does not hold is
604
- * stale; every file of that stem in the two directories is removed. A record another plugin of the state still names
605
- * (two copies of one binary) is kept. Returns the files removed.
736
+ * stale; every record file of that stem is removed, and so is a descriptor of its id and binary hash whichever
737
+ * version suffix it carries — unless that id and hash is still kept under another stem (two copies of one binary, or
738
+ * a record and a descriptor the sync wrote at different version suffixes for the same plugin). A record another
739
+ * plugin of the state still names (two copies of one binary) is kept, as is a descriptor the sync did not write.
740
+ * Returns the files removed.
606
741
  */
607
742
  export function pruneStale(settings: Pick<SyncSettings, "qualificationDir" | "descriptorDir">, before: SyncState, after: SyncState): string[] {
608
743
  const stems = (state: SyncState): Set<string> =>
609
744
  new Set(Object.values(state.entries).flatMap((e) => e.plugins.flatMap((p) => (p.record === undefined ? [] : [recordStem(p.record) ?? ""]))).filter((x) => x !== ""));
610
745
  const kept = stems(after);
611
746
  const stale = [...stems(before)].filter((stem) => !kept.has(stem));
747
+ const keptIdHash = new Set([...kept].map(idAndHash).filter((x): x is string => x !== undefined));
748
+ const staleIdHash = new Set(stale.map(idAndHash).filter((x): x is string => x !== undefined));
612
749
  const removed: string[] = [];
613
750
  const drop = (dir: string, file: string): void => {
614
751
  try {
@@ -624,9 +761,17 @@ export function pruneStale(settings: Pick<SyncSettings, "qualificationDir" | "de
624
761
  } catch {
625
762
  // no directory, no record
626
763
  }
627
- for (const stem of stale) {
628
- for (const f of records) if (f.startsWith(`${stem}.`) && recordStem(f) === stem) drop(settings.qualificationDir, f);
629
- drop(settings.descriptorDir, `${stem}.json`);
764
+ for (const stem of stale) for (const f of records) if (f.startsWith(`${stem}.`) && recordStem(f) === stem) drop(settings.qualificationDir, f);
765
+ let descriptors: string[] = [];
766
+ try {
767
+ descriptors = readdirSync(settings.descriptorDir);
768
+ } catch {
769
+ // no directory, no descriptor
770
+ }
771
+ for (const f of descriptors) {
772
+ if (!f.endsWith(".json")) continue;
773
+ const key = idAndHash(f.slice(0, -".json".length));
774
+ if (key !== undefined && staleIdHash.has(key) && !keptIdHash.has(key)) drop(settings.descriptorDir, f);
630
775
  }
631
776
  return removed;
632
777
  }
@@ -731,13 +876,15 @@ export interface SyncSummary {
731
876
  readonly crashed: number;
732
877
  readonly hung: number;
733
878
  readonly unmeasured: number;
879
+ /** The plugins taken from the distributed records, not measured. */
880
+ readonly distributed: number;
734
881
  readonly seconds: number;
735
882
  /** The pins a running daemon took, when one answered. */
736
883
  readonly sent?: number;
737
884
  }
738
885
 
739
886
  export function summaryLine(s: SyncSummary): string {
740
- return `qualified ${String(s.qualified)}, skipped ${String(s.skipped)}, refused ${String(s.refused)}, crashed ${String(s.crashed)}, hung ${String(s.hung)}, unmeasured ${String(s.unmeasured)}, ${s.seconds.toFixed(1)} s`;
887
+ return `qualified ${String(s.qualified)}, skipped ${String(s.skipped)}, refused ${String(s.refused)}, crashed ${String(s.crashed)}, hung ${String(s.hung)}, unmeasured ${String(s.unmeasured)}, distributed ${String(s.distributed)}, ${s.seconds.toFixed(1)} s`;
741
888
  }
742
889
 
743
890
  /** The lock of one state file: a second sync on it while one runs returns at once. A lock whose process is gone is taken over. */
@@ -791,7 +938,8 @@ export async function runSync(settings: SyncSettings, options: SyncRunOptions =
791
938
  const dirs = [...settings.clapDirs, ...settings.lv2Dirs];
792
939
  await waitQuiet(dirs, settings.settleS, log);
793
940
  const ctx: SyncContext = { settings, env, log };
794
- const count = { qualified: 0, skipped: 0, refused: 0, crashed: 0, hung: 0, unmeasured: 0 };
941
+ const count = { qualified: 0, skipped: 0, refused: 0, crashed: 0, hung: 0, unmeasured: 0, distributed: 0 };
942
+ const layer = distributedLayerOf(settings, options.force === true, log);
795
943
  let before: string;
796
944
  try {
797
945
  before = readFileSync(settings.pinFile, "utf8");
@@ -823,6 +971,17 @@ export async function runSync(settings: SyncSettings, options: SyncRunOptions =
823
971
  log(`${item.key}: a file of it cannot be read; skipped this run`);
824
972
  continue;
825
973
  }
974
+ const taken = takeDistributed(ctx, layer, item, files);
975
+ if (taken !== undefined) {
976
+ entries[item.key] = taken;
977
+ if (item.format === "lv2") lv2Pinned.push(...taken.plugins.filter((p) => p.pin !== undefined).map((p) => p.uri));
978
+ count.distributed += taken.plugins.length;
979
+ for (const p of taken.plugins) log(`distributed: ${p.uri}: applied (${taken.distributed!})`);
980
+ state = { schema: SYNC_STATE_SCHEMA, entries };
981
+ writeFileWhole(settings.stateFile, `${JSON.stringify(state, null, 1)}\n`);
982
+ writeFileWhole(settings.pinFile, syncPinsText(state));
983
+ continue;
984
+ }
826
985
  const entry = qualifyItem(ctx, item, files);
827
986
  entries[item.key] = entry;
828
987
  if (item.format === "lv2") lv2Pinned.push(...entry.plugins.filter((p) => p.pin !== undefined).map((p) => p.uri));