-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvalidate.js
More file actions
950 lines (892 loc) · 33.6 KB
/
Copy pathvalidate.js
File metadata and controls
950 lines (892 loc) · 33.6 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
// @ts-check
import { Attr, getLogger, withSpan } from '../observability/index.js'
/**
* @import { BlobSinkConfigInstance, CapabilityName, ConfigRegistry, HypAwareV2Config, JsonObject, PluginManifest, PluginName, RequestSinkConfigInstance } from '../../../hypaware-plugin-kernel-types.js'
* @import { ClientDescriptor, LoadedManifest } from '../../../src/core/types.js'
*/
/**
* @import {
* ConfigValidationError,
* V1Diagnostic,
* PluginMetadata,
* ValidateContext,
* ValidateResult,
* } from '../../../src/core/config/types.js'
*/
/**
* Capability that a writer plugin must provide. Encoders register
* themselves under this name (e.g. `@hypaware/format-parquet` and
* `@hypaware/format-jsonl`).
*/
export const CAP_ENCODER = 'hypaware.encoder'
/**
* Capability that a destination plugin must provide for a blob sink.
* Local-fs, S3, and any future object store satisfy this. The
* capability VALUE is a `BlobStore` object that consumers (table-format
* plugins and the blob sink contribution) call into directly.
*/
export const CAP_BLOB_STORE = 'hypaware.blob-store'
/**
* Capability a table-format writer provides. Table-format writers
* (`@hypaware/format-iceberg`) layer directory layout + manifests on
* top of an inner encoder and a blob store. Their capability VALUE is
* a `TableFormatProvider`.
*/
export const CAP_TABLE_FORMAT = 'hypaware.table-format'
/**
* Capability that a request destination provides. Request sinks
* (`@hypaware/central`, `@hypaware/webhook`) advertise this; the
* validator uses it to surface a useful error message when the user
* accidentally pairs a writer with a request destination.
*/
export const CAP_HTTP_ENDPOINT = 'hypaware.http-endpoint'
/**
* First-party plugin metadata baked into the kernel.
*
* @deprecated Prefer `buildPluginCatalog()` from `../plugin_catalog.js`
* which derives the same data from bundled manifest files on disk.
* This helper is retained only for tests and public-API consumers
* that have not migrated to the catalog path yet.
*
* @returns {Map<PluginName, PluginMetadata>}
*/
export function firstPartyPluginMetadata() {
return new Map(/** @type {[PluginName, PluginMetadata][]} */ ([
['@hypaware/ai-gateway', {
provides: { 'hypaware.ai-gateway': '2.0.0' },
}],
['@hypaware/claude', {
requires: { 'hypaware.ai-gateway': '^2.0.0' },
}],
['@hypaware/codex', {
requires: { 'hypaware.ai-gateway': '^2.0.0' },
}],
['@hypaware/gascity', {}],
['@hypaware/otel', {}],
['@hypaware/local-fs', {
provides: { 'hypaware.blob-store': '1.0.0' },
}],
['@hypaware/s3', {
provides: { 'hypaware.blob-store': '1.0.0' },
}],
['@hypaware/format-parquet', {
requires: { 'hypaware.blob-store': '^1.0.0' },
provides: { 'hypaware.encoder': '1.0.0' },
}],
['@hypaware/format-jsonl', {
requires: { 'hypaware.blob-store': '^1.0.0' },
provides: { 'hypaware.encoder': '1.0.0' },
}],
['@hypaware/format-iceberg', {
requires: { 'hypaware.blob-store': '^1.0.0', 'hypaware.encoder': '^1.0.0' },
provides: { 'hypaware.table-format': '1.0.0' },
}],
['@hypaware/central', {
provides: { 'hypaware.http-endpoint': '1.0.0' },
}],
['@hypaware/webhook', {
provides: { 'hypaware.http-endpoint': '1.0.0' },
}],
]))
}
/**
* Build a known-plugins map for `validateConfig` that includes both
* first-party metadata and any installed third-party manifests
* (`plugin-lock.json` entries). Each installed plugin contributes its
* manifest `provides.capabilities` and `requires.capabilities` to the
* cross-plugin validator so sink-pair and capability-ambiguity checks
* work the same way they do for bundled plugins.
*
* First-party metadata always wins on collision: the boot path already
* activates the bundled copy over an installed plugin that shadows a
* first-party name (LLP 0380), and this helper agrees with it when called
* outside the boot path (e.g. `hyp config validate` from a host that has
* not booted).
*
* @param {LoadedManifest[]} installedManifests
* @param {Map<PluginName, PluginMetadata>} [base]
* @returns {Map<PluginName, PluginMetadata>}
*/
export function mergeInstalledManifestsIntoKnown(installedManifests, base) {
const out = new Map(base ?? firstPartyPluginMetadata())
for (const entry of installedManifests) {
const name = /** @type {PluginName} */ (entry.manifest.name)
if (out.has(name)) continue
out.set(name, pluginMetadataFromManifest(entry.manifest))
}
return out
}
/**
* Derive a `PluginMetadata` snapshot from a plugin manifest. Picks up
* capability `provides` / `requires` only, schema-validated upstream
* by `validateManifest` so the casts here are safe.
*
* @param {PluginManifest} manifest
* @returns {PluginMetadata}
*/
function pluginMetadataFromManifest(manifest) {
/** @type {PluginMetadata} */
const meta = {}
const provides = manifest.provides?.capabilities
if (provides && Object.keys(provides).length > 0) {
meta.provides = /** @type {Partial<Record<CapabilityName, string>>} */ ({ ...provides })
}
const requires = manifest.requires?.capabilities
if (requires && Object.keys(requires).length > 0) {
meta.requires = /** @type {Partial<Record<CapabilityName, string>>} */ ({ ...requires })
}
return meta
}
/**
* Run the cross-plugin checks and return the raw error list, without the
* `config.validate` span or per-error logging. The synchronous, quiet
* core that `validateConfig` wraps, used by the boot-time layer merge,
* which re-validates candidate merges many times and must stay silent and
* cheap (no telemetry spam, no async).
*
* @param {HypAwareV2Config} config
* @param {ValidateContext} [ctx]
* @returns {ConfigValidationError[]}
* @ref LLP 0010#validation [implements]: the same cross-plugin checks, sans span/logging
*/
export function collectConfigErrors(config, ctx = {}) {
const knownPlugins = ctx.knownPlugins ?? firstPartyPluginMetadata()
const knownDatasets = ctx.knownDatasets ?? new Set()
const configRegistry = ctx.configRegistry
/** @type {ConfigValidationError[]} */
const errors = []
checkDuplicatePlugins(config, errors)
checkPluginsKnown(config, knownPlugins, errors)
checkSinks(config, knownPlugins, errors)
checkRetention(config, knownDatasets, errors)
checkCapabilityAmbiguity(config, knownPlugins, errors)
runPerPluginSectionValidators(config, configRegistry, errors)
return errors
}
/**
* Run kernel-level cross-plugin validation over a parsed v2 config.
*
* Rules:
*
* 1. Blob sink `writer` must require `hypaware.blob-store` and provide
* either `hypaware.encoder` (encoder writer) or
* `hypaware.table-format` (table-format writer). A writer that
* provides neither surfaces as
* `error_kind=sink_writer_invalid`. The `destination` must
* provide `hypaware.blob-store`; missing destination caps surface
* as `error_kind=sink_destination_invalid` for table-format writers
* (the new flow) and `error_kind=sink_pair_incompatible` for
* encoder writers (the legacy flow that existing configs depend on).
* For table-format writers, the optional inner encoder pin
* (`config.encoder`) must itself be a known plugin that provides
* `hypaware.encoder`; mismatches surface as
* `error_kind=sink_encoder_invalid`.
* 2. Request sinks (`plugin` shape) cannot carry `writer` or
* `destination` keys. Caught by the schema parser but re-emitted
* here as `error_kind=request_sink_invalid_keys` for consistency.
* 3. Every sink instance's `config.schedule` must be a standard
* 5-field cron expression. Reject any DSL (e.g. `@hourly`).
* 4. Every dataset named under `query.cache.retention.datasets` must be
* in the known dataset set.
* 5. Capability ambiguity: when two known plugins provide the same
* capability at a compatible version, the user must pin the
* provider via `disambiguate`.
*
* Emits one `config.validate.error` log row per error, carrying
* `error_kind`, `pointer`, and `message`.
*
* @param {HypAwareV2Config} config
* @param {ValidateContext} [ctx]
* @returns {Promise<ValidateResult>}
* @ref LLP 0010#validation [implements]: core's cross-plugin checks run after all manifests are loaded
*/
export async function validateConfig(config, ctx = {}) {
const knownPlugins = ctx.knownPlugins ?? firstPartyPluginMetadata()
const knownDatasets = ctx.knownDatasets ?? new Set()
const configRegistry = ctx.configRegistry
const log = getLogger('config')
const pluginCount = config.plugins?.length ?? 0
const sinkCount = Object.keys(config.sinks ?? {}).length
return withSpan(
'config.validate',
{
[Attr.COMPONENT]: 'config',
[Attr.OPERATION]: 'config.validate',
plugin_count: pluginCount,
sink_count: sinkCount,
},
async (span) => {
const errors = collectConfigErrors(config, { knownPlugins, knownDatasets, configRegistry })
for (const e of errors) {
log.error('config.validate.error', {
[Attr.ERROR_KIND]: e.errorKind,
pointer: e.pointer,
message: e.message,
})
}
if (errors.length > 0) {
span.setAttribute('error_kind', errors[0].errorKind)
span.setAttribute('status', 'failed')
} else {
span.setAttribute('status', 'ok')
}
return { ok: errors.length === 0, errors, pluginCount, sinkCount }
},
{ component: 'config' }
)
}
/**
* @param {HypAwareV2Config} config
* @param {ConfigValidationError[]} errors
*/
function checkDuplicatePlugins(config, errors) {
if (!config.plugins) return
/** @type {Map<PluginName, number>} */
const seen = new Map()
for (let i = 0; i < config.plugins.length; i += 1) {
const entry = config.plugins[i]
const prior = seen.get(entry.name)
if (prior !== undefined) {
errors.push({
pointer: `/plugins/${i}`,
errorKind: 'duplicate_plugin',
message: `plugin '${entry.name}' is listed twice (also at /plugins/${prior})`,
})
continue
}
seen.set(entry.name, i)
}
}
/**
* @param {HypAwareV2Config} config
* @param {Map<PluginName, PluginMetadata>} knownPlugins
* @param {ConfigValidationError[]} errors
*/
function checkPluginsKnown(config, knownPlugins, errors) {
if (!config.plugins) return
for (let i = 0; i < config.plugins.length; i += 1) {
const entry = config.plugins[i]
if (!knownPlugins.has(entry.name)) {
// For first-party only knowledge this catches typos against
// declared plugins. Third-party plugins land in Phase 7; this
// check should not fail then because the merged registry will
// include the manifest entries the user actually installed.
errors.push({
pointer: `/plugins/${i}/name`,
errorKind: 'plugin_unknown',
message: `plugin '${entry.name}' is not a known first-party plugin and is not installed`,
})
}
}
}
/**
* @param {HypAwareV2Config} config
* @param {Map<PluginName, PluginMetadata>} knownPlugins
* @param {ConfigValidationError[]} errors
*/
function checkSinks(config, knownPlugins, errors) {
if (!config.sinks) return
for (const [name, raw] of Object.entries(config.sinks)) {
const pointer = `/sinks/${name}`
// Defensive: re-derive shape rather than trust schema layer alone.
if ('writer' in raw || 'destination' in raw) {
if ('plugin' in raw) {
errors.push({
pointer,
errorKind: 'request_sink_invalid_keys',
message: `sink '${name}' mixes writer/destination with plugin; pick one shape`,
})
continue
}
checkBlobSink(name, raw, knownPlugins, errors)
} else if ('plugin' in raw) {
checkRequestSink(name, raw, knownPlugins, errors)
} else {
errors.push({
pointer,
errorKind: 'sink_pair_incompatible',
message: `sink '${name}' has neither writer/destination nor plugin`,
})
continue
}
checkSchedule(name, raw.config?.schedule, pointer, errors)
}
}
/**
* @param {string} name
* @param {BlobSinkConfigInstance} sink
* @param {Map<PluginName, PluginMetadata>} knownPlugins
* @param {ConfigValidationError[]} errors
* @ref LLP 0014#bytes-flow-down-semantics-flow-up [implements]: writer requires blob-store; rejects writer paired with an http-endpoint
*/
function checkBlobSink(name, sink, knownPlugins, errors) {
const pointer = `/sinks/${name}`
const writerMeta = knownPlugins.get(sink.writer)
const destMeta = knownPlugins.get(sink.destination)
if (!writerMeta) {
errors.push({
pointer: `${pointer}/writer`,
errorKind: 'sink_plugin_unknown',
message: `sink '${name}': writer plugin '${sink.writer}' is unknown`,
})
}
if (!destMeta) {
errors.push({
pointer: `${pointer}/destination`,
errorKind: 'sink_plugin_unknown',
message: `sink '${name}': destination plugin '${sink.destination}' is unknown`,
})
}
if (!writerMeta || !destMeta) return
const writerRequiresBlob = !!writerMeta.requires?.[CAP_BLOB_STORE]
const writerProvidesEncoder = !!writerMeta.provides?.[CAP_ENCODER]
const writerProvidesTableFormat = !!writerMeta.provides?.[CAP_TABLE_FORMAT]
// Determine writer shape. A writer providing neither encoder nor
// table-format is unusable as a blob-sink writer and earns the
// dedicated `sink_writer_invalid` kind so callers can branch on it
// separately from the generic encoder-shape mismatch covered by
// `sink_pair_incompatible`.
if (!writerProvidesEncoder && !writerProvidesTableFormat) {
errors.push({
pointer: `${pointer}/writer`,
errorKind: 'sink_writer_invalid',
message:
`sink '${name}': writer '${sink.writer}' provides neither ${CAP_ENCODER} nor ${CAP_TABLE_FORMAT}` +
` - blob sinks need an encoder or table-format writer`,
})
return
}
if (writerProvidesEncoder) {
// Legacy encoder-writer flow. Keep the existing error_kind so
// callers that already grep for `sink_pair_incompatible` on bad
// encoder pairings keep working.
if (!writerRequiresBlob) {
errors.push({
pointer: `${pointer}/writer`,
errorKind: 'sink_pair_incompatible',
message:
`sink '${name}': writer '${sink.writer}' must require ${CAP_BLOB_STORE} and provide ${CAP_ENCODER}` +
` (missing requires ${CAP_BLOB_STORE})`,
})
}
const destProvidesBlob = !!destMeta.provides?.[CAP_BLOB_STORE]
if (!destProvidesBlob) {
const destProvidesHttp = !!destMeta.provides?.[CAP_HTTP_ENDPOINT]
const hint = destProvidesHttp
? ` (provides ${CAP_HTTP_ENDPOINT} instead - only request sinks accept it)`
: ''
errors.push({
pointer: `${pointer}/destination`,
errorKind: 'sink_pair_incompatible',
message: `sink '${name}': destination '${sink.destination}' does not provide ${CAP_BLOB_STORE}${hint}`,
})
}
return
}
// Table-format writer flow. The destination still has to provide
// `hypaware.blob-store` so the table-format sink can write bytes;
// missing it earns `sink_destination_invalid` to distinguish the
// table-format setup error from the legacy encoder-shape error.
if (!writerRequiresBlob) {
errors.push({
pointer: `${pointer}/writer`,
errorKind: 'sink_writer_invalid',
message:
`sink '${name}': writer '${sink.writer}' provides ${CAP_TABLE_FORMAT} but does not require ${CAP_BLOB_STORE}` +
` (table-format writers must declare a blob-store dependency)`,
})
}
const destProvidesBlob = !!destMeta.provides?.[CAP_BLOB_STORE]
if (!destProvidesBlob) {
const destProvidesHttp = !!destMeta.provides?.[CAP_HTTP_ENDPOINT]
const hint = destProvidesHttp
? ` (provides ${CAP_HTTP_ENDPOINT} instead - only request sinks accept it)`
: ''
errors.push({
pointer: `${pointer}/destination`,
errorKind: 'sink_destination_invalid',
message:
`sink '${name}': destination '${sink.destination}' does not provide ${CAP_BLOB_STORE}${hint}` +
` (table-format writer '${sink.writer}' needs a blob-store destination)`,
})
}
// Optional inner-encoder pin. When set, the named plugin must be
// known and itself provide `hypaware.encoder`. Unknown plugins land
// in `sink_plugin_unknown` for consistency with /writer and
// /destination; missing-encoder lands in the dedicated kind.
const encoderPin = typeof sink.config?.encoder === 'string' ? sink.config.encoder : undefined
if (encoderPin) {
const encoderMeta = knownPlugins.get(/** @type {PluginName} */ (encoderPin))
if (!encoderMeta) {
errors.push({
pointer: `${pointer}/config/encoder`,
errorKind: 'sink_plugin_unknown',
message: `sink '${name}': encoder plugin '${encoderPin}' is unknown`,
})
} else if (!encoderMeta.provides?.[CAP_ENCODER]) {
errors.push({
pointer: `${pointer}/config/encoder`,
errorKind: 'sink_encoder_invalid',
message: `sink '${name}': encoder plugin '${encoderPin}' does not provide ${CAP_ENCODER}`,
})
}
}
}
/**
* @param {string} name
* @param {RequestSinkConfigInstance} sink
* @param {Map<PluginName, PluginMetadata>} knownPlugins
* @param {ConfigValidationError[]} errors
*/
function checkRequestSink(name, sink, knownPlugins, errors) {
const pointer = `/sinks/${name}`
const meta = knownPlugins.get(sink.plugin)
if (!meta) {
errors.push({
pointer: `${pointer}/plugin`,
errorKind: 'sink_plugin_unknown',
message: `sink '${name}': plugin '${sink.plugin}' is unknown`,
})
return
}
if (!meta.provides?.[CAP_HTTP_ENDPOINT]) {
errors.push({
pointer: `${pointer}/plugin`,
errorKind: 'sink_pair_incompatible',
message: `sink '${name}': request plugin '${sink.plugin}' must provide ${CAP_HTTP_ENDPOINT}`,
})
}
}
/**
* Validate a 5-field cron expression. The grammar is intentionally
* narrow: standard minute hour day-of-month month day-of-week with
* `star`, step (e.g. `star slash N`), range (`N-M`), or
* comma-separated lists. No `@hourly`, no seconds field, no
* timezones.
*
* @param {string} name
* @param {unknown} schedule
* @param {string} pointer
* @param {ConfigValidationError[]} errors
* @ref LLP 0014#config-two-shapes [implements]: schedule is a 5-field cron; friendly DSLs (@hourly) are rejected
*/
function checkSchedule(name, schedule, pointer, errors) {
if (schedule === undefined) return
if (typeof schedule !== 'string' || schedule.length === 0) {
errors.push({
pointer: `${pointer}/config/schedule`,
errorKind: 'sink_schedule_invalid',
message: `sink '${name}': schedule must be a 5-field cron string`,
})
return
}
if (!isCronExpression(schedule)) {
errors.push({
pointer: `${pointer}/config/schedule`,
errorKind: 'sink_schedule_invalid',
message:
`sink '${name}': schedule '${schedule}' is not a valid 5-field cron expression. ` +
`Use standard cron grammar (e.g. '0 * * * *'); DSL aliases like @hourly are rejected.`,
})
}
}
/**
* @param {HypAwareV2Config} config
* @param {Set<string>} knownDatasets
* @param {ConfigValidationError[]} errors
*/
function checkRetention(config, knownDatasets, errors) {
const datasets = config.query?.cache?.retention?.datasets
if (!datasets) return
for (const ds of Object.keys(datasets)) {
if (knownDatasets.size === 0) {
// No dataset registry yet (Phase 6 default). The retention entry is
// not actionable until Phase 7 wires dataset discovery; emit the
// warning so users see it during validate runs but the error is
// still real (an unknown dataset reference).
}
if (!knownDatasets.has(ds)) {
errors.push({
pointer: `/query/cache/retention/datasets/${ds}`,
errorKind: 'dataset_unknown',
message: `dataset '${ds}' is not registered; cannot apply retention`,
})
}
}
}
/**
* @param {HypAwareV2Config} config
* @param {Map<PluginName, PluginMetadata>} knownPlugins
* @param {ConfigValidationError[]} errors
*/
function checkCapabilityAmbiguity(config, knownPlugins, errors) {
if (!config.plugins || config.plugins.length === 0) return
// Only inspect plugins the user actually enabled in `plugins[]`.
const enabled = new Set(
config.plugins
.filter((p) => p.enabled !== false)
.map((p) => p.name)
)
/** @type {Map<CapabilityName, PluginName[]>} */
const providersByCap = new Map()
for (const pluginName of enabled) {
const meta = knownPlugins.get(pluginName)
if (!meta?.provides) continue
for (const capName of Object.keys(meta.provides)) {
let arr = providersByCap.get(capName)
if (!arr) {
arr = []
providersByCap.set(capName, arr)
}
arr.push(pluginName)
}
}
const pins = config.disambiguate ?? {}
for (const [capName, providers] of providersByCap.entries()) {
if (providers.length < 2) continue
const pinned = pins[capName]
if (typeof pinned === 'string' && providers.includes(pinned)) continue
errors.push({
pointer: `/disambiguate/${capName}`,
errorKind: 'capability_ambiguous',
message:
`capability '${capName}' is provided by ${providers.sort().join(', ')}; ` +
`add disambiguate.${capName} = <one of the providers>`,
})
}
}
/**
* Dispatch to per-plugin section validators. The config registry
* already emits one log per `ValidationError`, so this routine only
* needs to roll them up into the cross-plugin error list using a
* fixed `config_section_invalid` error_kind so smoke harnesses can
* grep by it.
*
* @param {HypAwareV2Config} config
* @param {ConfigRegistry | undefined} registry
* @param {ConfigValidationError[]} errors
*/
function runPerPluginSectionValidators(config, registry, errors) {
if (!registry || !config.plugins) return
for (let i = 0; i < config.plugins.length; i += 1) {
const entry = config.plugins[i]
const result = registry.validatePluginConfig(entry.name, entry.config ?? {})
if (!result.ok) {
for (const err of result.errors) {
errors.push({
pointer: err.pointer || `/plugins/${i}/config`,
errorKind: 'config_section_invalid',
message: err.message,
})
}
}
}
}
/* ---------- Phase 8 V1 diagnostics ---------- */
const AI_GATEWAY_PLUGIN = /** @type {PluginName} */ ('@hypaware/ai-gateway')
/**
* The capability the gateway provides. A client adapter that reaches its
* client registry through the gateway declares this in its manifest
* `requires`, which is the fact {@link clientRegistersViaGateway} reads.
*/
const AI_GATEWAY_CAPABILITY = /** @type {CapabilityName} */ ('hypaware.ai-gateway')
/**
* Fallback client descriptors for first-party clients. `hyp status`
* uses catalog-derived descriptors when manifest discovery succeeds,
* but diagnostics should still catch common Claude/Codex wiring gaps
* when bundled manifest discovery is unavailable.
*
* @returns {Map<string, ClientDescriptor>}
*/
function firstPartyClientDescriptors() {
return new Map(/** @type {[string, ClientDescriptor][]} */ ([
['claude', {
plugin: /** @type {PluginName} */ ('@hypaware/claude'),
name: 'claude',
skillDir: '.claude/skills',
agentDir: '.claude/agents',
attachProbe: {
format: 'json',
settings_file: '.claude/settings.json',
marker_key: '_hypaware',
},
}],
['codex', {
plugin: /** @type {PluginName} */ ('@hypaware/codex'),
name: 'codex',
skillDir: '.codex/skills',
attachProbe: {
format: 'toml',
settings_file: '.codex/config.toml',
marker_header: '[model_providers.hypaware]',
},
requiredUpstreams: ['openai', 'chatgpt'],
}],
]))
}
/**
* Would enabling this client with no gateway in the config actually break
* its attach? Only for an adapter that registers itself with the
* *gateway's* client registry (`@hypaware/claude`, `@hypaware/codex`,
* `@hypaware/openclaw`). An endpoint-free adapter registers with the
* kernel's own `ctx.clients` and attaches with no gateway present at all,
* which is what `src/core/commands/clients.js` means by "endpoint-free
* clients do not need the gateway capability".
*
* Which kind a plugin is, is already stated in its manifest: a
* gateway-backed one declares `requires.capabilities['hypaware.ai-gateway']`.
* Reading that keeps this from becoming a second list to maintain
* alongside the manifests.
*
* Without the test, `hyp setup --source opencode` composed exactly the one
* plugin it was asked for and then reported itself degraded, warning that
* attach would fail after attach had already succeeded, and pointing the
* operator at a gateway OpenCode does not use.
*
* A plugin the catalog does not know yields no warning, which is the safe
* direction: an unrecognized name is not evidence of a broken wiring.
*
* @param {Map<PluginName, PluginMetadata>} knownPlugins
* @param {PluginName} pluginName
* @returns {boolean}
*/
function clientRegistersViaGateway(knownPlugins, pluginName) {
return knownPlugins.get(pluginName)?.requires?.[AI_GATEWAY_CAPABILITY] !== undefined
}
/**
* @param {Map<string, ClientDescriptor> | undefined} clientDescriptors
* @returns {Map<string, ClientDescriptor>}
*/
function clientDescriptorsWithFallback(clientDescriptors) {
const fallback = firstPartyClientDescriptors()
if (!clientDescriptors || clientDescriptors.size === 0) return fallback
const out = new Map(clientDescriptors)
for (const [name, descriptor] of fallback) {
if (!out.has(name)) out.set(name, descriptor)
}
return out
}
/**
* Walk a v2 config and report Phase 8 V1 diagnostic findings. The
* checks are advisory: they do not fail `hyp config validate` (so a
* partially configured walkthrough output still passes), but they
* give `hyp status` a concrete list of "what's wrong and how to fix
* it" lines.
*
* Client and upstream checks use the plugin catalog's client
* descriptors rather than hardcoded plugin names, any plugin that
* declares a `contributes.client` with `required_upstreams` gets the
* same diagnostic coverage.
*
* Encoder / blob-store checks use the capability metadata from the
* catalog so new encoder or destination plugins are automatically
* included.
*
* @param {HypAwareV2Config | null | undefined} config
* @param {{ clientDescriptors?: Map<string, ClientDescriptor>, knownPlugins?: Map<PluginName, PluginMetadata> }} [ctx]
* @returns {V1Diagnostic[]}
*/
export function diagnoseV1Config(config, ctx = {}) {
/** @type {V1Diagnostic[]} */
const out = []
if (!config) return out
const enabledByName = enabledPluginIndex(config)
const gatewayConfig = enabledByName.get(AI_GATEWAY_PLUGIN)
const clientDescriptors = clientDescriptorsWithFallback(ctx.clientDescriptors)
const knownPlugins = ctx.knownPlugins ?? firstPartyPluginMetadata()
for (const [clientName, descriptor] of clientDescriptors) {
const pluginName = descriptor.plugin
if (!enabledByName.has(pluginName)) continue
if (gatewayConfig === undefined && clientRegistersViaGateway(knownPlugins, pluginName)) {
out.push({
kind: 'client_without_gateway',
pointer: pluginPointer(config, pluginName),
message:
`client plugin '${pluginName}' is enabled but '${AI_GATEWAY_PLUGIN}' is not - ` +
`attach commands will fail until the gateway is enabled.`,
repair: [
`hyp setup --from-file <config.json> # re-run picker to add the gateway`,
`hyp client attach ${clientName}`,
],
})
}
if (gatewayConfig !== undefined && descriptor.requiredUpstreams?.length) {
const primaryUpstream = descriptor.requiredUpstreams[0]
if (typeof primaryUpstream !== 'string' || primaryUpstream.length === 0) continue
const hasAny = descriptor.requiredUpstreams.some((u) =>
gatewayHasUpstreamProvider(gatewayConfig, u)
)
if (!hasAny) {
const upstreamList = descriptor.requiredUpstreams.join(' or ')
out.push({
kind: `gateway_missing_${primaryUpstream}_upstream`,
pointer: pluginPointer(config, AI_GATEWAY_PLUGIN),
message:
`'${pluginName}' is enabled but the gateway has no ${upstreamList} upstream - ` +
`${clientName} requests will have nowhere to forward.`,
repair: [
`hyp setup --from-file <config.json> # re-run picker to add the upstream`,
`hyp client attach ${clientName}`,
],
})
}
}
}
const encoderPlugins = derivePluginsByCapability(knownPlugins, 'hypaware.encoder', 'provides')
const blobStorePlugins = derivePluginsByCapability(knownPlugins, 'hypaware.blob-store', 'provides')
if (config.sinks) {
for (const [name, raw] of Object.entries(config.sinks)) {
if (!('writer' in raw) && !('destination' in raw)) continue
const writer = 'writer' in raw && typeof raw.writer === 'string' ? raw.writer : null
const destination = 'destination' in raw && typeof raw.destination === 'string' ? raw.destination : null
if (!blobStorePlugins.has(/** @type {PluginName} */ (destination))) continue
if (writer !== null && enabledByName.has(writer) && encoderPlugins.has(writer)) continue
out.push({
kind: 'sink_missing_encoder',
pointer: `/sinks/${name}`,
message:
`sink '${name}' targets '${destination}' but no encoder plugin ` +
`(${[...encoderPlugins].join(' or ')}) is enabled - local export will produce no files.`,
repair: [
`hyp setup --from-file <config.json> # re-run picker and pick "local Parquet export"`,
],
})
}
}
return out
}
/**
* Derive the set of plugins providing (or requiring) a given capability
* from the known-plugins metadata map.
*
* @param {Map<PluginName, PluginMetadata>} known
* @param {string} capability
* @param {'provides' | 'requires'} direction
* @returns {Set<PluginName>}
*/
function derivePluginsByCapability(known, capability, direction) {
/** @type {Set<PluginName>} */
const out = new Set()
for (const [name, meta] of known) {
if (meta[direction]?.[capability]) out.add(name)
}
return out
}
/**
* @param {HypAwareV2Config} config
* @returns {Map<PluginName, JsonObject>}
*/
function enabledPluginIndex(config) {
/** @type {Map<PluginName, JsonObject>} */
const out = new Map()
for (const entry of config.plugins ?? []) {
if (entry.enabled === false) continue
out.set(/** @type {PluginName} */ (entry.name), entry.config ?? {})
}
return out
}
/**
* @param {HypAwareV2Config} config
* @param {PluginName} name
*/
function pluginPointer(config, name) {
const idx = (config.plugins ?? []).findIndex((p) => p.name === name)
if (idx < 0) return '/plugins'
return `/plugins/${idx}`
}
/**
* Inspect an ai-gateway config block for an upstream that targets a
* given provider. Matches by explicit `provider` field, by the
* `name` (`anthropic` / `openai` / `chatgpt`), or by `base_url` host. The gateway
* config shape is intentionally loose, so check all three.
*
* @param {JsonObject} gatewayConfig
* @param {string} provider
*/
function gatewayHasUpstreamProvider(gatewayConfig, provider) {
const upstreams = gatewayConfig?.upstreams
if (!Array.isArray(upstreams)) return false
/** @type {Record<string, string>} */
const HOST_HINTS = {
anthropic: 'anthropic.com',
chatgpt: 'chatgpt.com',
openai: 'openai.com',
}
const hostHint = HOST_HINTS[provider]
for (const raw of upstreams) {
if (!raw || typeof raw !== 'object') continue
const u = /** @type {Record<string, unknown>} */ (raw)
if (typeof u.provider === 'string' && u.provider === provider) return true
if (typeof u.name === 'string' && u.name === provider) return true
if (hostHint && typeof u.base_url === 'string' && u.base_url.includes(hostHint)) return true
}
return false
}
/* ---------- cron grammar ---------- */
const CRON_FIELDS = /** @type {const} */ ([
{ name: 'minute', min: 0, max: 59 },
{ name: 'hour', min: 0, max: 23 },
{ name: 'day_of_month', min: 1, max: 31 },
{ name: 'month', min: 1, max: 12 },
{ name: 'day_of_week', min: 0, max: 7 },
])
/**
* @param {string} expression
* @returns {boolean}
*/
export function isCronExpression(expression) {
if (typeof expression !== 'string') return false
// Whitelist visible ASCII to keep the rejector tight against
// unicode lookalikes; cron has no concept of whitespace beyond the
// ASCII space character.
if (!/^[ -~]+$/.test(expression)) return false
if (expression.startsWith('@')) return false
const fields = expression.trim().split(/\s+/)
if (fields.length !== CRON_FIELDS.length) return false
for (let i = 0; i < CRON_FIELDS.length; i += 1) {
if (!isCronField(fields[i], CRON_FIELDS[i].min, CRON_FIELDS[i].max)) return false
}
return true
}
/**
* @param {string} field
* @param {number} min
* @param {number} max
*/
function isCronField(field, min, max) {
if (field.length === 0) return false
for (const part of field.split(',')) {
if (!isCronPart(part, min, max)) return false
}
return true
}
/**
* @param {string} part
* @param {number} min
* @param {number} max
*/
function isCronPart(part, min, max) {
if (part === '*') return true
// Step form: `*/N` or `N-M/N`
const stepMatch = part.match(/^([^/]+)\/(\d+)$/)
if (stepMatch) {
const stepValue = Number(stepMatch[2])
if (!Number.isInteger(stepValue) || stepValue <= 0) return false
return isCronPart(stepMatch[1], min, max)
}
// Range form: `N-M`
const rangeMatch = part.match(/^(\d+)-(\d+)$/)
if (rangeMatch) {
const lo = Number(rangeMatch[1])
const hi = Number(rangeMatch[2])
if (!isFinite(lo) || !isFinite(hi)) return false
if (lo < min || hi > max || lo > hi) return false
return true
}
// Single value
if (/^\d+$/.test(part)) {
const n = Number(part)
return n >= min && n <= max
}
return false
}