-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstatus.js
More file actions
3459 lines (3329 loc) · 170 KB
/
Copy pathstatus.js
File metadata and controls
3459 lines (3329 loc) · 170 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// @ts-check
import fsp from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { configRecordsPickAnswer, defaultConfigPath, loadConfigFile } from '../config/schema.js'
import { readConfigControlStatus, resolveCentralLayerPath } from '../config/apply.js'
import { readClientActionStatus } from '../config/action_reconciler.js'
import { CLAUDE_SETTINGS_MARKER_SCHEMA } from '../config/client_detach_disk.js'
import { endpointFromListen } from '../config/gateway_endpoint.js'
import { readAttachPolicy } from '../config/attach_policy.js'
import { readBackfillPolicy } from '../config/backfill_policy.js'
import {
isOtlpHeadersOverride,
otlpOverrideSignal,
perSignalOtlpOverrides,
} from '../config/otlp_precedence.js'
import { DEFAULT_RETENTION_DAYS } from '../cache/retention.js'
import { discoverSpoolTables, QUERY_FLUSH_FAILURE_COOLDOWN_MS, readFlushFailure } from '../cache/spool.js'
import { resolveLayeredConfig } from '../config/merge.js'
import { devTelemetryDir, readObservabilityEnv } from '../observability/env.js'
import { collectConfigErrors, diagnoseV1Config, validateConfig } from '../config/validate.js'
import { discoverInstalledPlugins } from '../runtime/installed.js'
import { discoverBundledPlugins } from '../runtime/bundled.js'
import { detectShadowedPlugins } from '../runtime/boot.js'
import { buildPluginCatalog } from '../plugin_catalog.js'
import { compareStrings } from '../util/compare_strings.js'
import { classifyClientProvenance } from '../cli/wizard/provenance.js'
import { describeSelfUpdate } from '../update/self_update.js'
import { atomicWriteJsonSync, readFileIfExistsSync } from '../util/fs_atomic.js'
import { getAtDottedPath, isPlainObject, sanitizeLabel } from '../util/json_util.js'
import {
ClientSyncListUnreadableError,
localOnlyListPath,
LocalOnlyListUnreadableError,
optedOutClientSourceIds,
readClientSyncEntries,
readFolderAskModeSafe,
readLocalOnlyDirs,
} from '../usage-policy/index.js'
import { readFirstSyncDeadline } from '../usage-policy/first_sync_hold.js'
import { displayableCaHosts, readLocalCaInfo } from '../tls/ca.js'
import { isCaTrusted as probeCaTrusted } from '../tls/darwin_trust.js'
import { warningsRecordBootFailure } from './boot_failure.js'
import { isLaunchdEnvSet as probeLaunchdEnvSet } from './launchd_env.js'
import { daemonLogDir } from './logs.js'
import { resolveClientSettingsPath } from './client_settings_path.js'
import {
isLaunchAgentInstalled,
launchAgentStatus,
} from './macos.js'
import {
isSystemdUnitInstalled,
systemdUnitStatus,
} from './linux.js'
import {
daemonRunDir,
processIsAlive,
readPidFile,
} from './pid.js'
/**
* @import { HypAwareV2Config, PluginConfigInstance } from '../../../hypaware-plugin-kernel-types.js'
* @import { ClientActionStatus, ConfigControlStatus, ConfigValidationError } from '../../../src/core/config/types.js'
* @import { CacheFlushFailureReport, CaptureHealthReport, ClientActionReport, ClientActionsReport, ClientAttachReport, CollectStatusOptions, DaemonStatus, DroppedUpstreamAttribution, HypAwareStatusReport, MaintenanceSkippedPartition, MaintenanceSkipReason, MaintenanceSkipSnapshot, ProxyTrustReport, RecentEntrypoint, ServiceState, SinkSnapshot, SourceSnapshot, StatusDiagnostic } from '../../../src/core/daemon/types.js'
* @import { MaintenancePartitionReport, MaintenanceReport } from '../../../src/core/cache/types.js'
* @import { Dirent } from 'node:fs'
* @import { FileHandle } from 'node:fs/promises'
* @import { ClientDescriptor, LoadedManifest, PluginCatalog } from '../../../src/core/types.js'
* @import { FolderAskMode } from '../../../src/core/usage-policy/types.js'
* @import { LocalCaInfo } from '../../../src/core/tls/types.js'
*/
/**
* The plugin the enrollment seed names. `hyp join` and the enrolling
* `hyp remote login` write `plugins: [{ name: '@hypaware/central' }]` plus the
* central sink so the machine can reach its server; it records no capture
* choice, so a central layer naming only it has answered nothing. Only the
* catalog-less fallback in `collectHypAwareStatus` reads it: with a catalog the
* test is the positive one (does the layer name a capture plugin?), which
* excludes this and every other non-capture plugin.
*/
const CENTRAL_ENROLLMENT_PLUGIN = '@hypaware/central'
/**
* Path to the daemon status file. Written by the daemon at each
* lifecycle transition so a parallel `hyp daemon status --json` call
* sees a consistent snapshot without having to walk the kernel.
*
* @param {string} stateRoot
*/
export function statusFilePath(stateRoot) {
return path.join(daemonRunDir(stateRoot), 'status.json')
}
/**
* Write a status file atomically (write to `.tmp`, then rename). The
* smoke harness asserts against this file directly so it must always
* be either absent or fully formed. Partial writes would race the
* SIGTERM assertion.
*
* @param {string} stateRoot
* @param {DaemonStatus} status
*/
export function writeStatusFile(stateRoot, status) {
atomicWriteJsonSync(statusFilePath(stateRoot), status)
}
/**
* Read the status file. Returns `null` when no daemon has run for
* this `HYP_HOME` yet. `hyp daemon status` surfaces that as
* "daemon: not started" rather than an error.
*
* @param {string} stateRoot
* @returns {DaemonStatus | null}
*/
export function readStatusFile(stateRoot) {
const raw = readFileIfExistsSync(statusFilePath(stateRoot))
if (raw === null) return null
/** @type {unknown} */
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object') {
throw new Error(`readStatusFile: malformed entry at ${statusFilePath(stateRoot)}`)
}
return /** @type {DaemonStatus} */ (parsed)
}
/** The AI gateway plugin name: the source whose bound port drives attach. */
const GATEWAY_PLUGIN_NAME = '@hypaware/ai-gateway'
/**
* Pull the AI gateway source's bound `{ host, port }` out of a status-file
* source-snapshot list. The daemon captures the gateway source's `status()`
* `details: { host, port, ... }` into each `SourceSnapshot.details`
* (`startConfiguredSources`), so the port a rebinding daemon actually chose is
* always readable here, no in-process gateway needed. Returns `undefined`
* when the gateway source is absent or recorded no usable host/port (e.g. it
* failed to bind).
*
* @param {SourceSnapshot[] | undefined} sources
* @returns {{ host: string, port: number, listenFallback: boolean, listenFallbackFrom?: string } | undefined}
* @ref LLP 0086#endpoint-discovery [implements]: the daemon's live bound port is read from status.json sources[].details, not guessed
*/
export function gatewaySourceDetails(sources) {
const details = gatewaySourceRawDetails(sources)
if (!details) return undefined
const port = details.port
if (typeof port !== 'number' || !Number.isInteger(port) || port <= 0) return undefined
const host = typeof details.host === 'string' && details.host.length > 0 ? details.host : '127.0.0.1'
// @ref LLP 0114#fallback-is-visible [implements]: the gateway records whether this bind came through the default-port fallback
const listenFallback = details.listen_fallback === true
// Display-only, and read out of a file: `listen_fallback_from` is the
// configured listen address the gateway could not take, and it is printed
// verbatim into `gateway_port_fallback`'s message and its repair line. That
// makes it the same kind of value as an upstream `name` or an `entrypoint`,
// so it is cleaned at the same last point before render. `host` above is
// deliberately left alone: it is not display-only (it composes the endpoint
// attach writes into client settings), so bounding it is a separate change.
// @ref LLP 0164#status-reads-it-from-the-status-file [constrained-by]: a string read back out of status.json is cleaned before it is printed, whichever detail it came from
const listenFallbackFrom = sanitizeLabel(details.listen_fallback_from)
return { host, port, listenFallback, ...(listenFallbackFrom ? { listenFallbackFrom } : {}) }
}
/**
* The gateway source's `status()` details as the daemon captured them, before
* any "is it bound?" filtering. `gatewaySourceDetails` above answers "where do
* I send traffic?" and so returns nothing for a gateway that never bound; the
* dropped-upstream check below needs the details of exactly that case.
*
* @param {SourceSnapshot[] | undefined} sources
* @returns {Record<string, unknown> | undefined}
*/
function gatewaySourceRawDetails(sources) {
const list = Array.isArray(sources) ? sources : []
const source =
list.find((s) => s && s.plugin === GATEWAY_PLUGIN_NAME) ??
list.find((s) => s && s.name === 'ai-gateway')
const rawDetails = source && typeof source.details === 'object' ? source.details : undefined
if (!rawDetails) return undefined
return /** @type {Record<string, unknown>} */ (rawDetails)
}
/**
* How many upstream names a single warning line will spell out before it stops
* naming them and counts the remainder.
*
* `sanitizeLabel` bounds each name; nothing bounds how many of them the file
* holds. These names are read inside one sentence rather than down a block of
* lines, so the cap sits well under `recent clients`' 32: the count leads that
* sentence and is the number that actually matters, which leaves the list free
* to be a sample.
*/
const MAX_PRINTED_UPSTREAM_NAMES = 8
/**
* A list of upstream names out of the status file, rendered for the one
* warning line that prints it: each name cleaned through `sanitizeLabel`, the
* list capped, and everything the two filters held back counted at the end, so
* a truncated list never reads as a complete one. `''` for an empty list,
* which every caller already renders as "no names to show".
*
* These names arrive in the same file as `recent_entrypoints`, so the reason
* that list is sanitized on read applies here unchanged: `status.json` is a
* *file*, and core cannot assume the daemon that wrote it was this version,
* this build, or well behaved, while everything read here is about to be
* printed to a terminal. An upstream `name` is config-authored rather than
* client-authored, which lowers the odds but not the reachability, and two
* paths reading one file should not disagree about whether it is trusted.
*
* Rendering is where the cleaning happens, not `gatewayDroppedUpstreams`,
* because the names are not display-only there: `attributeDroppedUpstreams`
* intersects them with `registered_presets` to decide each dropped name's
* fate, and a cleaned or capped list would silently change that answer.
* Cleaning bounds what is *printed*, and must revise neither the counts the
* message leads with nor the sets it splits them into.
*
* @param {string[]} list
* @returns {string}
* @ref LLP 0164#status-reads-it-from-the-status-file [constrained-by]: the sanitize-and-cap on read is a property of reading status.json, not of the entrypoint list that first needed it
*/
function printableUpstreamNames(list) {
/** @type {string[]} */
const printed = []
for (const name of list) {
if (printed.length === MAX_PRINTED_UPSTREAM_NAMES) break
const label = sanitizeLabel(name)
// A name that cleans away to nothing is withheld rather than printed
// empty, and counted with the ones the cap dropped: from the reader's side
// both are names the file holds and the line does not show.
if (label !== undefined) printed.push(label)
}
const hidden = list.length - printed.length
// Nothing survived cleaning: say how many names are being withheld rather
// than render an empty list, which would read as "no names in the file".
if (printed.length === 0) return hidden > 0 ? `${hidden} unprintable` : ''
return hidden > 0 ? `${printed.join(', ')}, +${hidden} more` : printed.join(', ')
}
/**
* The upstreams the gateway's config asked for and did not get, or `undefined`
* when it got everything it asked for (which includes asking for nothing).
*
* `compileUpstreams` drops an upstream entry missing either `name` or
* `base_url`, per entry and without complaint. Nothing downstream can see
* that from the routing table alone, so the gateway source reports the raw
* configured count next to the number that fell out, and this reads the
* difference. One comparison covers both shapes of the fault:
*
* - **Every entry dropped.** The routing table is empty, so the source binds
* no listener at all (`listening: false`). An upstream-less gateway is a
* legitimate config (LLP 0120: hermes composes the plugin for its
* materializer alone and contributes no upstream), so this cannot be a start
* failure any more; without a diagnostic it reports `started` and `healthy`
* while every client gets ECONNREFUSED.
* - **Some entries dropped.** The table is non-empty, the proxy binds, and
* `listening` is never set. Nothing about that install looks wrong, and the
* traffic for the typo'd provider is simply never proxied or captured.
*
* `idle` distinguishes them so the caller can say which one happened; the two
* are mutually exclusive by construction, so they never double-report.
*
* Neither shape is caught anywhere else: `@hypaware/ai-gateway` registers no
* config section, so nothing validates upstream shape, and `diagnoseV1Config`'s
* `gateway_missing_*_upstream` check matches an upstream by its `provider`
* field, which a nameless entry still has.
*
* Counts lead, names follow, because `name` is one of the two keys whose
* absence drops an entry: `provider = "anthropic", base_url = "..."` yields no
* name at all yet is exactly the config that needs the warning.
*
* A status file written before `upstreams_dropped` existed answers only the
* all-dropped question, from `listening: false` plus whatever it did record of
* the configured entries. A partial loss recorded by such a build stays
* invisible rather than being guessed at.
*
* `attribution` answers the question the bound-gateway message would otherwise
* have to hedge on: see `attributeDroppedUpstreams` below.
*
* Every name here is the file's own, uncleaned: the counts and the attribution
* split are decided off them, and `printableUpstreamNames` cleans and caps at
* each point one is rendered instead. Nothing in this function may be revised
* by what a warning line is willing to print.
*
* @param {SourceSnapshot[] | undefined} sources
* @returns {{ idle: boolean, configured: number, dropped: number, names: string[], attribution: DroppedUpstreamAttribution | undefined } | undefined}
*/
function gatewayDroppedUpstreams(sources) {
const details = gatewaySourceRawDetails(sources)
if (!details) return undefined
const idle = details.listening === false
const names = stringList(details.upstreams)
const configured = nonNegativeInt(details.upstreams_configured) ?? names.length
const rawDropped = nonNegativeInt(details.upstreams_dropped)
// Older status file: an idle gateway lost every entry it had by definition,
// which is what that build's own check inferred. A bound one tells us
// nothing, so claim nothing.
const dropped = rawDropped ?? (idle ? configured : 0)
if (dropped <= 0) return undefined
const droppedNames = stringList(details.upstreams_dropped_names)
// On the older status file the dropped names are exactly the configured
// ones, since none survived.
const reportedNames = droppedNames.length > 0 ? droppedNames : idle ? names : []
return {
idle,
configured,
dropped,
names: reportedNames,
// Only the bound gateway has a routing table for a preset to have filled,
// so only it has this question. An idle one bound nothing, which already
// proves no preset covered anything.
attribution: idle ? undefined : attributeDroppedUpstreams(details, dropped, reportedNames),
}
}
/**
* Split the dropped upstream names into the ones an adapter preset is still
* proxying and the ones nothing is, or `undefined` when the status file does
* not support the split.
*
* A dropped entry is absent from the compiled config table by definition, and
* `mergeUpstreams` (the gateway source) backfills a registered preset into
* exactly the names that table is missing. So a dropped name that is also a
* registered preset name is still routed, by the preset's own entry rather
* than the one the operator wrote; a dropped name that is not has no route of
* its own at all. The daemon publishes both halves already
* (`registered_presets`, `upstreams_dropped_names`), so the message can say
* which one happened rather than hedging over both.
*
* Two shapes withhold the answer rather than inventing one, because this reads
* a *file* that some other build may have written:
*
* - **No `registered_presets` key.** Absent is not empty. Reading a missing
* field as "no presets are registered" would turn every dropped name into a
* confident claim of silence on a build that never recorded the list.
* - **A drop with no name.** `name` is one of the two keys whose absence drops
* an entry, so an unnamed drop has nothing to intersect with, and its
* destination is unknowable from status. Requiring one name per dropped
* entry also covers the deduped case (two same-named entries both dropping
* yield one name for two drops), where the shortfall is real but which entry
* the preset covers is not decidable.
*
* @param {Record<string, unknown>} details
* @param {number} dropped
* @param {string[]} names
* @returns {DroppedUpstreamAttribution | undefined}
*/
function attributeDroppedUpstreams(details, dropped, names) {
if (!Array.isArray(details.registered_presets)) return undefined
if (names.length !== dropped) return undefined
const presets = new Set(stringList(details.registered_presets))
return {
covered: names.filter((n) => presets.has(n)),
silent: names.filter((n) => !presets.has(n)),
}
}
/**
* What a bound gateway's dropped upstreams mean for the traffic aimed at them.
*
* Two fates, and they are not close: a name no preset covers has no entry in
* the routing table at all, while a name a preset covers has one, backfilled
* from the preset rather than from what the operator wrote. Both are worth
* the warning and they call for different fixes, so when `attribution` can
* tell them apart the message names each set rather than hedging across both.
*
* Every clause is a claim about the *routing table*, never about the traffic,
* because the table is all a name-set intersection can reach. Routing is by
* `path_prefix` and `match()` and then by rank (`matchUpstream` and
* `compileUpstreams` in the gateway's `proxy.js` sort on `priority`, then
* prefix length, then merge order), and none of that is published:
*
* - **A dropped name is not a dead path.** A surviving upstream written with
* no `path_prefix` compiles to `/`, which `pathMatchesPrefix` matches every
* path against, so a request aimed at the dropped name can still be proxied
* and recorded - under the *other* upstream's name. The gateway source says
* the same where it logs this fault ("falls through to whatever the
* remaining routes match (or nothing)"). Hence "under the name X", with the
* fall-through spelled out, rather than a flat claim that nothing happens.
* - **A covered name is a table entry, not a guarantee of traffic.** The
* backfilled preset can be shadowed outright: `mergeUpstreams` appends
* presets after the config entries, and `compileUpstreams` breaks a rank
* tie on that order, so a surviving config upstream at an equal
* `path_prefix` (or at a higher `priority`) wins every path the preset
* would have taken. Hence "in the routing table only as the preset", plus
* the outranking note, rather than "is still proxied".
* - **A covered name loses more than its `base_url`, and not only its
* `path_prefix`.** `mergeUpstreams` backfills the preset's whole entry, so
* its `provider` and `priority` come too, and a preset carrying a `match()`
* (which every bundled adapter preset does) routes by that function while
* `path_prefix` degrades to a sort key `matchUpstream` never consults. The
* claude preset's `match()` takes `/v1/complete` and any anthropic-headered
* path, so naming `path_prefix` as "what is in force" understates its
* reach as badly as it overstates the operator's. Hence "routing rules".
*
* The hedge survives for the case that still deserves it, where the status
* file does not say which of the two happened. It hedges only that question,
* though. The catch-all above is a fact about the *routing table*, not about
* the preset list, so it holds on the hedged branch identically and the
* hedged sentence is bounded to the name in the same way. Hedging the preset
* question is not licence to assert the traffic one: the shape that most
* often reaches this branch is an entry that lost its `name` (nothing to
* intersect), which is an ordinary current-build config, not only an old
* status file.
*
* @ref LLP 0195#visible-when-unintended [constrained-by]: the kind still fires on the configured-vs-compiled comparison alone; this only reports which fate each dropped name met
*
* @param {number} dropped
* @param {string[]} names
* @param {DroppedUpstreamAttribution | undefined} attribution
* @returns {string}
*/
function droppedUpstreamConsequence(dropped, names, attribution) {
if (!attribution) {
// Labelled, because unlike the idle branch these are not the configured
// set: an unlabelled `(openai)` next to "2 configured upstreams" invites
// exactly the wrong reading.
const printed = printableUpstreamNames(names)
const named = printed.length > 0 ? ` (dropped: ${printed})` : ''
const oneEntry = dropped === 1
// The entry nouns count entries and the name nouns count names, because
// the two differ: the dedupe in `readConfiguredUpstreams` prints one name
// for two same-named dropped entries, which is one of the shapes that
// lands here. With no names to print at all there is nothing to
// disagree with, so the entry count stands in.
const oneName = (names.length > 0 ? names.length : dropped) === 1
return `${oneEntry ? 'that entry is' : 'those entries are'} not in the routing table${named}, so unless an adapter preset already covers the same ${oneName ? 'name' : 'names'}, nothing is proxied or captured under ${oneName ? 'that name' : 'those names'}, and ${oneName ? 'a request' : 'requests'} aimed at ${oneName ? 'it' : 'them'} ${oneName ? 'gets' : 'get'} a 404 or ${oneName ? 'falls' : 'fall'} through to whatever surviving route ${oneName ? 'its path matches' : 'their paths match'}`
}
/** @type {string[]} */
const parts = []
const { silent, covered } = attribution
// Each set's grammar counts the names the *file* holds, not the ones this
// line prints, so cleaning and capping cannot make a plural set read as a
// singular one.
// Silence leads: it is the more damaging of the two, and the reason the
// operator is reading this line at all.
if (silent.length > 0) {
const one = silent.length === 1
parts.push(
`nothing is proxied or captured under the ${one ? 'name' : 'names'} ${printableUpstreamNames(silent)} (no adapter preset covers ${one ? 'that name' : 'those names'}), so ${one ? 'a request' : 'requests'} aimed at ${one ? 'it' : 'them'} ${one ? 'gets' : 'get'} a 404 or ${one ? 'falls' : 'fall'} through to whatever surviving route ${one ? 'its path matches' : 'their paths match'}`,
)
}
if (covered.length > 0) {
const one = covered.length === 1
parts.push(
`${printableUpstreamNames(covered)} ${one ? 'is' : 'are'} in the routing table only as the adapter ${one ? 'preset' : 'presets'} registered under the same ${one ? 'name' : 'names'}, so ${one ? "that preset's" : "each preset's"} own base_url and routing rules are in force, nothing this config set for ${one ? 'it' : 'them'} took effect, and a surviving upstream can still outrank ${one ? 'the preset' : 'a preset'} on any path`,
)
}
return parts.join('; ')
}
/** @param {unknown} v @returns {string[]} */
function stringList(v) {
if (!Array.isArray(v)) return []
return /** @type {string[]} */ (v.filter((s) => typeof s === 'string' && s.length > 0))
}
/** @param {unknown} v @returns {number | undefined} */
function nonNegativeInt(v) {
return typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : undefined
}
/**
* How many recent client surfaces `hyp status` will report. The gateway keeps
* its own, deliberately equal, cap on the writing side; this one exists
* because core reads a *file* and a file can have been written by an older
* build, a different build, or something that is not this daemon at all. It
* bounds the terminal output, not the tracker.
*/
const MAX_RECENT_ENTRYPOINTS = 32
/**
* Lift the gateway source's `recent_entrypoints` detail out of a status-file
* source-snapshot list, most recently seen first.
*
* This is the whole of core's knowledge about client surfaces: it validates
* shape and orders by time, and never interprets an `entrypoint` string. Which
* value means "Codex Desktop" stays Codex's business, exactly as
* [LLP 0130]'s "rendering needs no plugin code" and LLP 0003's core/plugin
* split require. Malformed or partial entries are dropped rather than repaired:
* a name in `hyp status` that no query could reproduce would be worse than a
* short list.
*
* Labels are sanitized here as well as at the gateway that wrote them, and the
* list is capped here as well as there. This is not belt-and-braces:
* `status.json` is a file, and core must not assume the daemon that wrote it
* was this version, was this build, or was well behaved. Everything read here
* is about to be printed to a terminal, so all three ways a label can be
* hostile are answered at the last point before render - control and invisible
* bytes (`sanitizeLabel`), unbounded length (`sanitizeLabel`), and unbounded
* *count*, which the writer's cap does not cover for a file this build did not
* write.
*
* @param {SourceSnapshot[] | undefined} sources
* @returns {RecentEntrypoint[]}
* @ref LLP 0164#status-reads-it-from-the-status-file [implements]: hyp status answers from status.json, with no dataset registry and no cache read
*/
export function recentEntrypointsFromSources(sources) {
const list = Array.isArray(sources) ? sources : []
const source =
list.find((s) => s && s.plugin === GATEWAY_PLUGIN_NAME) ??
list.find((s) => s && s.name === 'ai-gateway')
const rawDetails = source && typeof source.details === 'object' ? source.details : undefined
if (!rawDetails) return []
const raw = /** @type {Record<string, unknown>} */ (rawDetails).recent_entrypoints
if (!Array.isArray(raw)) return []
/** @type {RecentEntrypoint[]} */
const out = []
for (const item of raw) {
if (!isPlainObject(item)) continue
const entrypoint = sanitizeLabel(item.entrypoint)
const lastSeen = item.last_seen
if (entrypoint === undefined) continue
if (typeof lastSeen !== 'string' || Number.isNaN(Date.parse(lastSeen))) continue
out.push({
entrypoint,
clientName: sanitizeLabel(item.client_name) ?? null,
lastSeen,
rows: typeof item.rows === 'number' && Number.isFinite(item.rows) ? item.rows : 0,
})
}
out.sort((a, b) => compareStrings(b.lastSeen, a.lastSeen))
// Sorted before the cap so the entries kept are the most recently seen ones,
// which is the same entry a "recent clients" readout would keep anyway.
return out.slice(0, MAX_RECENT_ENTRYPOINTS)
}
/* ---------- maintenance skips (LLP 0228) ---------- */
/**
* How many skipped partitions the standing surface names. The counts beside
* the list are exact, so this bounds the terminal block and the status file
* without hiding the size of the problem; `hyp query maintain` is where an
* operator enumerates every one. Eight is a screenful, and a cache with more
* than eight frozen partitions has a story the count already tells.
*/
export const MAX_SKIPPED_PARTITIONS_REPORTED = 8
/** Every reason id, in the order the render lists them. */
const MAINTENANCE_SKIP_REASONS = Object.freeze(
/** @type {MaintenanceSkipReason[]} */ (['compaction_ineffective', 'compaction_attempt_failed'])
)
/**
* The reason breakdown as one phrase, e.g. `2 compaction_ineffective, 1
* compaction_attempt_failed`. Reasons no partition was skipped for are left
* out rather than printed as zeros, and the ids are printed verbatim: they
* are the span attribute names, so this phrase is also the trace query
* (LLP 0228#reason-ids-are-span-attribute-names).
*
* Both call sites interpolate this unconditionally into a sentence that
* already committed to a parenthetical, so an empty phrase would render as a
* bare `()`. That is unreachable from a snapshot this build wrote (every
* skip has one of the two known reasons by construction), but not from a
* `status.json` a later build wrote: LLP 0228#consequences names a third
* reason id as exactly the kind of extension this shape absorbs, and a
* snapshot whose only nonzero reasons are ones this build does not
* recognize is precisely `skippedTotal > 0` with every known count at zero.
* The fallback names that case instead of leaving the parenthetical empty.
*
* @param {Record<MaintenanceSkipReason, number>} reasons
* @returns {string}
*/
export function describeMaintenanceSkipReasons(reasons) {
const phrase = MAINTENANCE_SKIP_REASONS
.filter((reason) => (reasons[reason] ?? 0) > 0)
.map((reason) => `${reasons[reason]} ${reason}`)
.join(', ')
return phrase === '' ? 'reasons this build does not recognize' : phrase
}
/**
* The partition tuple as one label: exactly the shape `hyp query maintain`
* prints after the dataset name, so the same partition reads identically on
* both surfaces.
*
* @param {Record<string, string> | undefined} partition
* @returns {string}
*/
function partitionLabel(partition) {
if (!isPlainObject(partition)) return 'all'
const parts = Object.entries(partition)
.filter(([, v]) => typeof v === 'string')
.map(([k, v]) => `${k}=${v}`)
return parts.length > 0 ? parts.join('/') : 'all'
}
/**
* Why this tick left the partition fragmented, or undefined when it did not.
*
* A partition the tick *rewrote* is not on this surface even when the rewrite
* achieved nothing: that is a run that did work, and the verdict it recorded
* puts the partition on the next tick's snapshot as a skip. What this names is
* the standing state, the partition the kernel has stopped rewriting.
*
* @param {MaintenancePartitionReport} p
* @returns {MaintenanceSkipReason | undefined}
* @ref LLP 0218#verdict-outranks-error [constrained-by]: maintenance already makes the two mutually exclusive, so this order only has to agree about which one a reader is owed if that ever stops holding
*/
function skipReasonOf(p) {
if (p.compacted || p.rebaselined) return undefined
if (p.compactionIneffective) return 'compaction_ineffective'
if (p.compactionAttemptFailed) return 'compaction_attempt_failed'
return undefined
}
/**
* Summarize a maintenance tick's report into the snapshot the daemon persists
* (`DaemonStatus.maintenance`). Pure, and deliberately cheap: it reads the
* report the walk already produced and stats nothing, because proving a
* skipped partition is also still fragmented is the per-tick cost the LLP 0199
* baseline gate exists to avoid.
*
* A tick that skipped nothing still produces a snapshot (all-zero counts, an
* empty list). The snapshot is the *current* answer, so a partition that
* thawed has to be able to leave it.
*
* @param {MaintenanceReport} report
* @param {{ at?: string }} [opts]
* @returns {MaintenanceSkipSnapshot}
* @ref LLP 0228#last-tick-only [implements]: one bounded snapshot per tick, named partitions capped and taken in the walk's own neediest-first order
*/
export function summarizeMaintenanceSkips(report, opts = {}) {
const visited = Array.isArray(report?.partitions) ? report.partitions : []
/** @type {Record<MaintenanceSkipReason, number>} */
const reasons = { compaction_ineffective: 0, compaction_attempt_failed: 0 }
/** @type {MaintenanceSkippedPartition[]} */
const partitions = []
let skippedTotal = 0
for (const p of visited) {
const reason = skipReasonOf(p)
if (reason === undefined) continue
reasons[reason] += 1
skippedTotal += 1
// No sort: the report is already in walk order, which is descending live
// data-file count (LLP 0199#neediest-first), so the first entries past the
// cap are the most fragmented ones by construction.
if (partitions.length >= MAX_SKIPPED_PARTITIONS_REPORTED) continue
partitions.push({
// Sanitized here too, not only on read: LLP 0228#last-tick-only says the
// cap and the sanitizing are both re-applied on read, which only holds
// if the write side already produced a clean label. `dataset` and
// `partition` are kernel-side identifiers in the ordinary case, but
// `partition`'s values come off a captured row's `client_name` by way
// of `resolveSourceSegments` -> `sanitizePathSegment`, which strips only
// path-hostile bytes and applies no length clamp or bidi/zero-width
// filtering. Unsanitized here, the daemon log line at
// `runtime.js`'s `worst` field (which reads `partitions[0]` straight)
// would be the one surface on this path with nothing downstream to
// clean it.
// @ref LLP 0228#last-tick-only [implements]: the write side sanitizes and clamps, not only the read side
dataset: sanitizeLabel(p.dataset) ?? 'unknown',
partition: sanitizeLabel(partitionLabel(p.partition)) ?? 'all',
reason,
// The count the recorded rewrite ran over, not the live one: the same
// distinction `hyp query maintain` draws, for the same reason.
...(reason === 'compaction_ineffective' && typeof p.compactionIneffectiveFiles === 'number'
? { dataFiles: p.compactionIneffectiveFiles }
: {}),
...(reason === 'compaction_attempt_failed' && typeof p.compactionAttemptFailedAt === 'string'
? { failedAt: p.compactionAttemptFailedAt }
: {}),
})
}
return {
tickAt: opts.at ?? new Date().toISOString(),
partitionsVisited: visited.length,
skippedTotal,
reasons,
partitions,
}
}
/**
* Lift the maintenance snapshot out of a status file, or null when no daemon
* has reported a tick for this state root.
*
* Validated, sanitized and re-capped on read as well as on write, for the
* reason `recentEntrypointsFromSources` states: `status.json` is a file, this
* build did not necessarily write it, and everything here is about to be
* printed to a terminal. Dataset and partition labels are the only free-form
* strings on the path and both are kernel-side identifiers, but they are
* cleaned anyway rather than trusted.
*
* Not liveness-gated, for LLP 0164's reason: "these partitions were frozen as
* of the tick at T" stays true after the daemon exits, and the rendered age
* carries the staleness.
*
* @param {DaemonStatus | null} status
* @returns {MaintenanceSkipSnapshot | null}
* @ref LLP 0228#status-file-is-the-surface [implements]: hyp status answers from status.json rather than running a second maintenance walk
*/
export function maintenanceSkipsFromStatus(status) {
const raw = status?.maintenance
if (!isPlainObject(raw)) return null
const tickAt = raw.tickAt
// No timestamp, no snapshot: every render of this block is relative to when
// the tick ran, and "frozen, at some unknown time" is not worth printing.
if (typeof tickAt !== 'string' || Number.isNaN(Date.parse(tickAt))) return null
const rawReasons = isPlainObject(raw.reasons) ? raw.reasons : {}
/** @type {Record<MaintenanceSkipReason, number>} */
const reasons = { compaction_ineffective: 0, compaction_attempt_failed: 0 }
for (const reason of MAINTENANCE_SKIP_REASONS) {
reasons[reason] = nonNegativeInt(rawReasons[reason]) ?? 0
}
/** @type {MaintenanceSkippedPartition[]} */
const partitions = []
const rawPartitions = Array.isArray(raw.partitions) ? raw.partitions : []
for (const item of rawPartitions) {
if (partitions.length >= MAX_SKIPPED_PARTITIONS_REPORTED) break
if (!isPlainObject(item)) continue
const reason = item.reason
// An unknown reason id is dropped rather than printed: a name this build
// cannot explain is worse than a shorter list, and the counts above still
// account for it.
if (typeof reason !== 'string' || !MAINTENANCE_SKIP_REASONS.includes(/** @type {MaintenanceSkipReason} */ (reason))) continue
const dataset = sanitizeLabel(item.dataset)
const partition = sanitizeLabel(item.partition)
if (dataset === undefined || partition === undefined) continue
const dataFiles = nonNegativeInt(item.dataFiles)
const failedAt = sanitizeLabel(item.failedAt)
partitions.push({
dataset,
partition,
reason: /** @type {MaintenanceSkipReason} */ (reason),
...(dataFiles !== undefined ? { dataFiles } : {}),
...(failedAt !== undefined ? { failedAt } : {}),
})
}
const recordedTotal = nonNegativeInt(raw.skippedTotal)
?? MAINTENANCE_SKIP_REASONS.reduce((sum, reason) => sum + reasons[reason], 0)
return {
tickAt,
// Floored at the skipped total (and the named list, which the total
// itself is already floored at below): "visited" can never be smaller
// than "skipped", or the render says "5 of 0 partitions" for a snapshot
// no tick could have produced. A file this build did not write can claim
// whatever it wants here, so the floor is enforced rather than trusted.
partitionsVisited: Math.max(nonNegativeInt(raw.partitionsVisited) ?? 0, recordedTotal, partitions.length),
// The list is capped, so the count leads; but a count smaller than the
// list would render "2 partitions" above three lines of them.
skippedTotal: Math.max(recordedTotal, partitions.length),
reasons,
partitions,
}
}
/**
* Resolve the AI gateway's live bound base URL from the on-disk daemon status
* snapshot, **guarded by a daemon-liveness check** so a stale snapshot from a
* dead daemon is never handed back. Returns `undefined` when no daemon is
* running for this state root, no status file exists, or the gateway source
* recorded no bound port.
*
* This is the discovery mechanism manual `hyp client attach` uses on a default
* install: only the running daemon knows which port it actually bound (the
* well-known default, its ephemeral fallback when that port was taken - LLP
* 0114 - or a pre-0114 ephemeral bind), and the daemon persists it here
* (issue #277 / LLP 0086). It never fabricates a port for a daemon that is
* not running.
*
* @param {{ stateRoot: string }} args
* @returns {string | undefined}
* @ref LLP 0086#manual-attach-reads-the-live-port [implements]: resolve the live gateway URL from status.json, gated on a live pid
*/
export function resolveLiveGatewayEndpointFromStatus({ stateRoot }) {
// Liveness gate first: a status.json outlives its daemon, so a bound port in
// it proves nothing without a living process behind the pid file.
let pidEntry
try {
pidEntry = readPidFile(stateRoot)
} catch {
return undefined
}
if (!pidEntry || !processIsAlive(pidEntry.pid)) return undefined
/** @type {DaemonStatus | null} */
let status
try {
status = readStatusFile(stateRoot)
} catch {
return undefined
}
const details = status ? gatewaySourceDetails(status.sources) : undefined
if (!details) return undefined
return endpointFromListen(`${details.host}:${details.port}`)
}
/**
* Resolve a named listener source's live bound `listen_port` from the on-disk
* daemon status snapshot, behind the same daemon-liveness gate as
* {@link resolveLiveGatewayEndpointFromStatus}: a stale snapshot from a dead
* daemon is never handed back, and no port is ever fabricated.
*
* The generic sibling of the gateway resolver above, for sources that publish
* `details.listen_port` (the OTLP receiver, the Claude telemetry listener).
* The first consumer is `hyp client attach claude` in `otel` mode: only the running
* daemon knows which port the listener actually bound (its configured default,
* or the ephemeral fallback when that port was taken), so the endpoint attach
* writes must come from here whenever a daemon is up.
*
* @param {{ stateRoot: string, sourceName: string }} args
* @returns {number | undefined}
*/
export function resolveLiveSourceListenPortFromStatus({ stateRoot, sourceName }) {
const list = liveStatusSources(stateRoot)
if (!list) return undefined
const source = list.find((s) => s && s.name === sourceName)
const details = sourceDetails(source)
const port = details?.listen_port
if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) {
return undefined
}
return port
}
/**
* Every live source advertising a named `/_hypaware/` control route in its
* status details (`control_routes`), resolved to the base URL of its bound
* listener.
*
* This is how `hyp session ignore` / `unignore` finds the recorders beyond
* the gateway: a recorder that hosts the route says so in its own status
* details, so the verb stays client-agnostic and a listener that is not
* running (absent from a live snapshot, or no live daemon at all) is simply
* not addressed - it is recording nothing, so there is nothing to notify.
* The gateway itself is NOT discovered here; its endpoint has its own,
* richer resolution (`status.json` plus the pinned `listen` fallback).
*
* @ref LLP 0256#cli-posts-to-both [implements]: the CLI addresses every
* listener that offers the route; offering is advertised, never guessed
* @param {{ stateRoot: string, route: string }} args
* @returns {Array<{ source: string, endpoint: string }>}
*/
export function resolveLiveControlRouteEndpointsFromStatus({ stateRoot, route }) {
const list = liveStatusSources(stateRoot)
if (!list) return []
/** @type {Array<{ source: string, endpoint: string }>} */
const out = []
for (const source of list) {
if (!source || typeof source.name !== 'string') continue
const details = sourceDetails(source)
const routes = details?.control_routes
if (!Array.isArray(routes) || !routes.includes(route)) continue
const port = details?.listen_port
if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) continue
const host = typeof details?.listen_host === 'string' && details.listen_host.length > 0
? details.listen_host
: '127.0.0.1'
const endpoint = endpointFromListen(`${host}:${port}`)
if (endpoint) out.push({ source: source.name, endpoint })
}
return out
}
/**
* The liveness-gated snapshot read shared by the resolvers above: a live pid
* and a readable status file, or nothing. A `status.json` outlives its
* daemon, so a bound port in it proves nothing without a living process
* behind the pid file.
*
* @param {string} stateRoot
* @returns {SourceSnapshot[] | undefined}
*/
function liveStatusSources(stateRoot) {
let pidEntry
try {
pidEntry = readPidFile(stateRoot)
} catch {
return undefined
}
if (!pidEntry || !processIsAlive(pidEntry.pid)) return undefined
/** @type {DaemonStatus | null} */
let status
try {
status = readStatusFile(stateRoot)
} catch {
return undefined
}
return Array.isArray(status?.sources) ? status.sources : []
}
/**
* @param {SourceSnapshot | undefined} source
* @returns {Record<string, unknown> | undefined}
*/
function sourceDetails(source) {
return source && typeof source.details === 'object' && source.details !== null
? /** @type {Record<string, unknown>} */ (source.details)
: undefined
}
/**
* How long the daemon's own status snapshot may go unwritten before
* `hyp status` stops believing the `state` recorded in it.
*
* The daemon persists that snapshot at the end of every tick, and the tick
* interval is fixed at 60s outside the test harnesses, so this is five
* consecutive missed ticks. It is deliberately several ticks wide: one slow
* tick is ordinary (the sink export runs inside it), and a status command
* that flickered to `degraded` whenever an export ran long would be worse
* than the bug it is here to catch. It is also the reason the window is not
* tighter: the daemon's timers do not fire while the machine is asleep, so a
* host that just woke reads as stale until its next tick lands.
*
* @ref LLP 0348#the-window [implements]: several missed ticks, not one
*/
export const DAEMON_HEARTBEAT_STALE_MS = 5 * 60_000
/**
* How long ago the daemon last wrote its status snapshot, derived from the
* two fields the snapshot already carries: `persist()` recomputes `uptimeMs`
* as `now - healthyAt` immediately before every write, so `healthyAt +
* uptimeMs` *is* the moment of that write. Nothing new has to be recorded
* for the heartbeat to be readable.
*
* Returns `null` when the snapshot dates no write (a daemon that has not
* reached the first `persist()` of its run has not started serving, so it has
* no tick to be late for: the `starting` snapshot goes down before
* `bootKernel`, and the pair reaches disk on the first post-boot write, not on
* the assignment) or when either field is missing or unusable, which is also
* how a status file written by an older build reads. Reaching `healthy` is
* not the condition: a daemon whose boot aggregate landed `degraded` is
* serving, and dates its writes like any other (LLP 0386#serving-dates-the-write).
*
* @param {DaemonStatus | null | undefined} status
* @param {number} nowMs
* @returns {number | null}
* @ref LLP 0348#heartbeat-is-derived [implements]: healthyAt + uptimeMs is the last persist, so no new status field is minted
*/
export function daemonHeartbeatAgeMs(status, nowMs) {
const healthyAtMs = parseIsoMs(status?.healthyAt)
if (healthyAtMs === undefined) return null
const uptimeMs = status?.uptimeMs
if (typeof uptimeMs !== 'number' || !Number.isFinite(uptimeMs) || uptimeMs < 0) return null
return nowMs - (healthyAtMs + uptimeMs)
}
/* ---------- Phase 8: top-level status collector ---------- */
/**
* Collect everything `hyp status` shows. Reads config from disk,
* probes daemon install + runtime state, walks the kernel runtime
* for source/sink contributions when available, and probes client
* settings files for the HypAware attach markers. All probes are
* best-effort: a single probe failing surfaces as a warning, not an
* exception, so the operator always gets a complete report.
*
* @param {CollectStatusOptions} [opts]
* @returns {Promise<HypAwareStatusReport>}
*/
export async function collectHypAwareStatus(opts = {}) {
const env = opts.env ?? process.env
const obsEnv = readObservabilityEnv(env)
const hypHome = obsEnv.hypHome
const stateRoot = obsEnv.stateDir
const platform = opts.platform ?? process.platform
const homeDir = opts.homeDir ?? env.HOME ?? process.env.HOME ?? os.homedir()
// ----- config (LLP 0031: central ⊕ local) -----
// The user-facing config path is the local layer; the central layer is
// resolved read-only from config-control/ (active slot or join seed).
// Reading it never fires a config poll. What's "running" is the merge.
// @ref LLP 0031#status-provenance [implements]: Restore inspectability: provenance tags + dropped-local section over the merged config
const configPath = env.HYP_CONFIG
? path.resolve(env.HYP_CONFIG)
: defaultConfigPath(hypHome)
const localLoaded = await loadConfigFile(configPath)
const localConfig = localLoaded.ok ? localLoaded.config : null
const centralConfigPath = resolveCentralLayerPath({ stateRoot })
const centralLoaded = centralConfigPath ? await loadConfigFile(centralConfigPath) : null
const centralConfig = centralLoaded?.ok ? centralLoaded.config : null
const hasCentral = centralConfig !== null
// Build the plugin catalog before the merge so the layer resolution
// validates local additions against the same plugin set the daemon
// runs. A local plugin that invalidates the merge (capability tie,
// unknown plugin) is dropped here, not surfaced as a config error.
const manifests = await discoverStatusManifests({ stateDir: stateRoot })
const catalog = catalogFromManifests(manifests)
// Installed plugins a bundled name shadows: boot activates the bundled
// copy and skips these, so they are idle code in the lock. Read off the
// same discovery pass, by the same rule boot applies.
const shadowedInstalled = detectShadowedPlugins({
discovered: manifests.bundled,
installed: manifests.installed,
})
// @ref LLP 0031#central-layer-is-sacrosanct [implements]: Same merge + validation pruning as boot, so status shows exactly what runs
const merged = resolveLayeredConfig({
central: centralConfig,
local: localConfig,
validate: (cfg) => collectConfigErrors(cfg, {
...(catalog ? { knownPlugins: catalog.pluginMetadata, knownDatasets: catalog.knownDatasets } : {}),