@bespokeagentics/microdots-host 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/evals/topology-regressions.p5.v1.json +105 -0
- package/package.json +6 -4
- package/src/composeFromSelection.test.ts +277 -0
- package/src/composeFromSelection.ts +376 -0
- package/src/compositionSpec.test.ts +311 -0
- package/src/compositionSpec.ts +802 -0
- package/src/compositionSpec.v2.test.ts +216 -0
- package/src/fillComposition.test.ts +240 -0
- package/src/fillComposition.ts +159 -0
- package/src/index.ts +140 -2
- package/src/mountedRegistry.test.ts +196 -0
- package/src/mounting.test.ts +8 -8
- package/src/mounting.ts +116 -0
- package/src/placementChecks.test.ts +12 -12
- package/src/rules.test.ts +4 -4
- package/src/rules.ts +1 -1
- package/src/slotDom.test.ts +151 -0
- package/src/slotDom.ts +111 -0
- package/src/slotResize.test.ts +392 -0
- package/src/slotResize.ts +367 -0
- package/src/slots.test.ts +53 -0
- package/src/slots.ts +30 -3
- package/src/topologyRegression.test.ts +37 -0
- package/src/topologyRegression.ts +143 -0
- package/src/topologySource.test.ts +137 -0
- package/src/topologySource.ts +97 -0
- package/src/tracePublisher.test.ts +95 -0
- package/src/tracePublisher.ts +117 -0
- package/src/wire.test.ts +13 -11
- package/src/wireEngine.test.ts +60 -36
- package/src/wireEngine.ts +13 -3
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
import { Array, Option, Schema as S } from 'effect'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AvailableMicroDotSlug,
|
|
5
|
+
type CatalogLockV1,
|
|
6
|
+
CatalogLockV1 as CatalogLockV1Schema,
|
|
7
|
+
type MigrationResult,
|
|
8
|
+
ResolvedMicroDotSpecV2 as ResolvedMicroDotSpecV2Schema,
|
|
9
|
+
decodeAuthoringDiagnosticSync,
|
|
10
|
+
migrated,
|
|
11
|
+
migrationNeedsInput,
|
|
12
|
+
} from '@bespokeagentics/microdots-authoring'
|
|
13
|
+
import type { ManifestTag } from '@bespokeagentics/microdots-element'
|
|
14
|
+
|
|
15
|
+
import { HostTopology, deriveWireState, topologyRouteTable } from './wire.ts'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The composition IR a spec-filler emits and the compiler consumes.
|
|
19
|
+
*
|
|
20
|
+
* `topology` is the already-shipping host record. `generate` names new
|
|
21
|
+
* MicroDots the compiler should scaffold; tags that appear in the topology
|
|
22
|
+
* but not here must already exist in the surface catalog. The model never
|
|
23
|
+
* writes source files — see
|
|
24
|
+
* `wiki/plans/active/microdots-specialist-spec-filler.md`.
|
|
25
|
+
*/
|
|
26
|
+
export const GenerateDotSpecV1 = S.Struct({
|
|
27
|
+
slug: AvailableMicroDotSlug,
|
|
28
|
+
tag: S.NonEmptyString.check(S.isPattern(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/)),
|
|
29
|
+
port: S.Int.check(S.isGreaterThan(0)),
|
|
30
|
+
})
|
|
31
|
+
export type GenerateDotSpecV1 = typeof GenerateDotSpecV1.Type
|
|
32
|
+
|
|
33
|
+
/** @deprecated v1 compatibility name; new authoring uses ResolvedMicroDotSpecV2. */
|
|
34
|
+
export const GenerateDotSpec = GenerateDotSpecV1
|
|
35
|
+
/** @deprecated v1 compatibility name; new authoring uses ResolvedMicroDotSpecV2. */
|
|
36
|
+
export type GenerateDotSpec = GenerateDotSpecV1
|
|
37
|
+
|
|
38
|
+
export const CompositionSpecV1 = S.Struct({
|
|
39
|
+
schemaVersion: S.Literal(1),
|
|
40
|
+
brief: S.optionalKey(S.String),
|
|
41
|
+
generate: S.Array(GenerateDotSpecV1),
|
|
42
|
+
topology: HostTopology,
|
|
43
|
+
})
|
|
44
|
+
export type CompositionSpecV1 = typeof CompositionSpecV1.Type
|
|
45
|
+
|
|
46
|
+
/** Legacy read/caller contract; new Workbench traffic uses explicit v2. */
|
|
47
|
+
export const CompositionSpec = CompositionSpecV1
|
|
48
|
+
/** Legacy read/caller contract; new Workbench traffic uses explicit v2. */
|
|
49
|
+
export type CompositionSpec = CompositionSpecV1
|
|
50
|
+
|
|
51
|
+
const CompositionSpecV2Base = S.Struct({
|
|
52
|
+
schemaVersion: S.Literal(2),
|
|
53
|
+
brief: S.optionalKey(S.String),
|
|
54
|
+
generate: S.Array(ResolvedMicroDotSpecV2Schema),
|
|
55
|
+
topology: HostTopology,
|
|
56
|
+
catalogLock: CatalogLockV1Schema,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const sameDigestRecord = (
|
|
60
|
+
left: Readonly<Record<string, string>>,
|
|
61
|
+
right: Readonly<Record<string, string>>,
|
|
62
|
+
): boolean => {
|
|
63
|
+
const leftKeys = Object.keys(left)
|
|
64
|
+
return (
|
|
65
|
+
leftKeys.length === Object.keys(right).length &&
|
|
66
|
+
leftKeys.every(key => left[key] === right[key])
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const catalogLocksEqual = (
|
|
71
|
+
left: CatalogLockV1,
|
|
72
|
+
right: CatalogLockV1,
|
|
73
|
+
): boolean =>
|
|
74
|
+
left.schemaVersion === right.schemaVersion &&
|
|
75
|
+
left.snapshotSha256 === right.snapshotSha256 &&
|
|
76
|
+
left.compilerVersion === right.compilerVersion &&
|
|
77
|
+
sameDigestRecord(left.shapeDigests, right.shapeDigests) &&
|
|
78
|
+
sameDigestRecord(left.capabilityDigests, right.capabilityDigests) &&
|
|
79
|
+
left.backendCatalogDigest === right.backendCatalogDigest &&
|
|
80
|
+
left.targetMatrixDigest === right.targetMatrixDigest &&
|
|
81
|
+
left.allocationBaselineDigest === right.allocationBaselineDigest
|
|
82
|
+
|
|
83
|
+
export const CompositionSpecV2 = CompositionSpecV2Base.check(
|
|
84
|
+
S.makeFilter(
|
|
85
|
+
spec =>
|
|
86
|
+
spec.generate.every(generated =>
|
|
87
|
+
catalogLocksEqual(generated.catalogLock, spec.catalogLock),
|
|
88
|
+
),
|
|
89
|
+
{ expected: 'every generated MicroDot uses the composition catalog lock' },
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
export type CompositionSpecV2 = typeof CompositionSpecV2.Type
|
|
93
|
+
|
|
94
|
+
export const CompositionSpecVersioned = S.Union([
|
|
95
|
+
CompositionSpecV1,
|
|
96
|
+
CompositionSpecV2,
|
|
97
|
+
])
|
|
98
|
+
export type CompositionSpecVersioned = typeof CompositionSpecVersioned.Type
|
|
99
|
+
|
|
100
|
+
export type CompositionIssue = {
|
|
101
|
+
readonly path: string
|
|
102
|
+
readonly message: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const issue = (path: string, message: string): CompositionIssue => ({
|
|
106
|
+
path,
|
|
107
|
+
message,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const tagOf = (surface: ManifestTag): string => surface.tag
|
|
111
|
+
|
|
112
|
+
const knownTags = (
|
|
113
|
+
spec: CompositionSpec,
|
|
114
|
+
surfaces: ReadonlyArray<ManifestTag>,
|
|
115
|
+
): ReadonlySet<string> => {
|
|
116
|
+
const tags = new Set<string>(surfaces.map(tagOf))
|
|
117
|
+
for (const generated of spec.generate) {
|
|
118
|
+
tags.add(generated.tag)
|
|
119
|
+
}
|
|
120
|
+
return tags
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const duplicatePaths = (
|
|
124
|
+
values: ReadonlyArray<string>,
|
|
125
|
+
pathOf: (index: number) => string,
|
|
126
|
+
label: string,
|
|
127
|
+
): ReadonlyArray<CompositionIssue> => {
|
|
128
|
+
const seen = new Map<string, number>()
|
|
129
|
+
const issues: Array<CompositionIssue> = []
|
|
130
|
+
for (const [index, value] of values.entries()) {
|
|
131
|
+
const previous = seen.get(value)
|
|
132
|
+
if (previous === undefined) {
|
|
133
|
+
seen.set(value, index)
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
issues.push(
|
|
137
|
+
issue(
|
|
138
|
+
pathOf(index),
|
|
139
|
+
`${label} "${value}" duplicates generate[${previous.toString()}]`,
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
return issues
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const placedTags = (spec: CompositionSpec): ReadonlySet<string> => {
|
|
147
|
+
const tags = new Set<string>()
|
|
148
|
+
for (const route of spec.topology.routes) {
|
|
149
|
+
for (const mount of route.mounts) {
|
|
150
|
+
tags.add(mount.tag)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const rule of spec.topology.rules ?? []) {
|
|
154
|
+
for (const placement of rule.placements) {
|
|
155
|
+
tags.add(placement.tag)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return tags
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const slotIds = (spec: CompositionSpec): ReadonlySet<string> | undefined => {
|
|
162
|
+
const manifest = spec.topology.slotManifest
|
|
163
|
+
if (manifest === undefined) {
|
|
164
|
+
return undefined
|
|
165
|
+
}
|
|
166
|
+
return new Set(manifest.slots.map(slot => slot.id))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const checkMounts = (
|
|
170
|
+
spec: CompositionSpec,
|
|
171
|
+
tags: ReadonlySet<string>,
|
|
172
|
+
): ReadonlyArray<CompositionIssue> => {
|
|
173
|
+
const slots = slotIds(spec)
|
|
174
|
+
const issues: Array<CompositionIssue> = []
|
|
175
|
+
for (const [routeIndex, route] of spec.topology.routes.entries()) {
|
|
176
|
+
for (const [mountIndex, mount] of route.mounts.entries()) {
|
|
177
|
+
const path = `topology.routes[${routeIndex.toString()}].mounts[${mountIndex.toString()}]`
|
|
178
|
+
if (!tags.has(mount.tag)) {
|
|
179
|
+
issues.push(
|
|
180
|
+
issue(
|
|
181
|
+
`${path}.tag`,
|
|
182
|
+
`tag "${mount.tag}" is neither a catalogued surface nor in generate[]`,
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
if (slots !== undefined && !slots.has(mount.slotId)) {
|
|
187
|
+
issues.push(
|
|
188
|
+
issue(
|
|
189
|
+
`${path}.slotId`,
|
|
190
|
+
`slot "${mount.slotId}" is not in topology.slotManifest`,
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return issues
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const checkWires = (
|
|
200
|
+
spec: CompositionSpec,
|
|
201
|
+
surfaces: ReadonlyArray<ManifestTag>,
|
|
202
|
+
tags: ReadonlySet<string>,
|
|
203
|
+
): ReadonlyArray<CompositionIssue> => {
|
|
204
|
+
const generatedTags = new Set(spec.generate.map(generated => generated.tag))
|
|
205
|
+
const table = topologyRouteTable(spec.topology)
|
|
206
|
+
const issues: Array<CompositionIssue> = []
|
|
207
|
+
for (const [index, wire] of spec.topology.wires.entries()) {
|
|
208
|
+
const path = `topology.wires[${index.toString()}]`
|
|
209
|
+
if (!tags.has(wire.from)) {
|
|
210
|
+
issues.push(
|
|
211
|
+
issue(
|
|
212
|
+
`${path}.from`,
|
|
213
|
+
`tag "${wire.from}" is neither a catalogued surface nor in generate[]`,
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
if (!tags.has(wire.to)) {
|
|
218
|
+
issues.push(
|
|
219
|
+
issue(
|
|
220
|
+
`${path}.to`,
|
|
221
|
+
`tag "${wire.to}" is neither a catalogued surface nor in generate[]`,
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
if (generatedTags.has(wire.from) || generatedTags.has(wire.to)) {
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
if (!tags.has(wire.from) || !tags.has(wire.to)) {
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
const state = deriveWireState(wire, { manifests: surfaces, table })
|
|
232
|
+
if (state === 'draft') {
|
|
233
|
+
issues.push(
|
|
234
|
+
issue(
|
|
235
|
+
path,
|
|
236
|
+
`wire "${wire.id}" derives draft — a tag, event, field, input or type pairing is missing from the surface catalog`,
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return issues
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Fail-closed composition lint. Schema-decode is the first gate; this is
|
|
246
|
+
* the second: identities, slot membership, and `deriveWireState !== 'draft'`
|
|
247
|
+
* for wires whose both ends are catalogued surfaces. Wires that touch a
|
|
248
|
+
* `generate[]` tag skip the manifest check — the scaffold surface is not
|
|
249
|
+
* the domain surface yet.
|
|
250
|
+
*/
|
|
251
|
+
export const validateComposition = (
|
|
252
|
+
spec: CompositionSpec,
|
|
253
|
+
surfaces: ReadonlyArray<ManifestTag>,
|
|
254
|
+
): ReadonlyArray<CompositionIssue> => {
|
|
255
|
+
const tags = knownTags(spec, surfaces)
|
|
256
|
+
return [
|
|
257
|
+
...duplicatePaths(
|
|
258
|
+
spec.generate.map(generated => generated.slug),
|
|
259
|
+
index => `generate[${index.toString()}].slug`,
|
|
260
|
+
'slug',
|
|
261
|
+
),
|
|
262
|
+
...duplicatePaths(
|
|
263
|
+
spec.generate.map(generated => generated.tag),
|
|
264
|
+
index => `generate[${index.toString()}].tag`,
|
|
265
|
+
'tag',
|
|
266
|
+
),
|
|
267
|
+
...duplicatePaths(
|
|
268
|
+
spec.generate.map(generated => String(generated.port)),
|
|
269
|
+
index => `generate[${index.toString()}].port`,
|
|
270
|
+
'port',
|
|
271
|
+
),
|
|
272
|
+
...checkMounts(spec, tags),
|
|
273
|
+
...checkWires(spec, surfaces, tags),
|
|
274
|
+
...Array.getSomes(
|
|
275
|
+
spec.generate.map((generated, index) =>
|
|
276
|
+
placedTags(spec).has(generated.tag)
|
|
277
|
+
? Option.none()
|
|
278
|
+
: Option.some(
|
|
279
|
+
issue(
|
|
280
|
+
`generate[${index.toString()}].tag`,
|
|
281
|
+
`generated tag "${generated.tag}" is never placed in the topology`,
|
|
282
|
+
),
|
|
283
|
+
),
|
|
284
|
+
),
|
|
285
|
+
),
|
|
286
|
+
]
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The current existing-surface lane writes v2, but it never generates a new
|
|
291
|
+
* MicroDot. Keep that boundary explicit so a resolved application spec cannot
|
|
292
|
+
* accidentally fall through to the legacy scaffold compiler.
|
|
293
|
+
*
|
|
294
|
+
* Once `generate[]` is known empty, the topology validation is exactly the v1
|
|
295
|
+
* compatibility rule: every mounted/wired tag must already exist in the
|
|
296
|
+
* emitted surface catalog. The temporary v1 value is not encoded or persisted;
|
|
297
|
+
* it is the retained read-side validator during the Phase 6 migration.
|
|
298
|
+
*/
|
|
299
|
+
export const validateExistingSurfaceComposition = (
|
|
300
|
+
spec: CompositionSpecV2,
|
|
301
|
+
surfaces: ReadonlyArray<ManifestTag>,
|
|
302
|
+
): ReadonlyArray<CompositionIssue> => {
|
|
303
|
+
if (spec.generate.length > 0) {
|
|
304
|
+
return [
|
|
305
|
+
issue(
|
|
306
|
+
'generate',
|
|
307
|
+
'existing-surface composition requires generate[] to be empty; use the application-authoring compiler for new MicroDots',
|
|
308
|
+
),
|
|
309
|
+
]
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const compatibilitySpec: CompositionSpecV1 = {
|
|
313
|
+
schemaVersion: 1,
|
|
314
|
+
generate: [],
|
|
315
|
+
topology: spec.topology,
|
|
316
|
+
...(spec.brief === undefined ? {} : { brief: spec.brief }),
|
|
317
|
+
}
|
|
318
|
+
return validateComposition(compatibilitySpec, surfaces)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export const encodeTopologyJson = (topology: HostTopology): string =>
|
|
322
|
+
`${JSON.stringify(S.encodeSync(HostTopology)(topology), null, 2)}\n`
|
|
323
|
+
|
|
324
|
+
/* ============================================================
|
|
325
|
+
Surface-derived JSON Schema — the smaller prompt.
|
|
326
|
+
Illegal tags/events/attrs/transforms are unrepresentable here; the
|
|
327
|
+
Effect schema is still the decoder. Built by hand because the
|
|
328
|
+
constrained enums are a function of the live ManifestTag catalog,
|
|
329
|
+
not of HostTopology's free strings.
|
|
330
|
+
============================================================ */
|
|
331
|
+
|
|
332
|
+
export type JsonSchema = {
|
|
333
|
+
readonly $schema?: string
|
|
334
|
+
readonly $comment?: string
|
|
335
|
+
readonly $defs?: Readonly<Record<string, JsonSchema>>
|
|
336
|
+
readonly type?:
|
|
337
|
+
'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean'
|
|
338
|
+
readonly enum?: ReadonlyArray<string | number>
|
|
339
|
+
readonly const?: string | number | boolean
|
|
340
|
+
readonly properties?: Readonly<Record<string, JsonSchema>>
|
|
341
|
+
readonly required?: ReadonlyArray<string>
|
|
342
|
+
readonly additionalProperties?: boolean | JsonSchema
|
|
343
|
+
readonly items?: JsonSchema
|
|
344
|
+
readonly minItems?: number
|
|
345
|
+
readonly minLength?: number
|
|
346
|
+
readonly minimum?: number
|
|
347
|
+
readonly maximum?: number
|
|
348
|
+
readonly pattern?: string
|
|
349
|
+
readonly if?: JsonSchema
|
|
350
|
+
readonly then?: JsonSchema
|
|
351
|
+
readonly allOf?: ReadonlyArray<JsonSchema>
|
|
352
|
+
readonly oneOf?: ReadonlyArray<JsonSchema>
|
|
353
|
+
readonly anyOf?: ReadonlyArray<JsonSchema>
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const objectSchema = (
|
|
357
|
+
properties: Readonly<Record<string, JsonSchema>>,
|
|
358
|
+
required: ReadonlyArray<string>,
|
|
359
|
+
): JsonSchema => ({
|
|
360
|
+
type: 'object',
|
|
361
|
+
additionalProperties: false,
|
|
362
|
+
properties,
|
|
363
|
+
required,
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
const stringEnum = (values: ReadonlyArray<string>): JsonSchema => ({
|
|
367
|
+
type: 'string',
|
|
368
|
+
enum: values,
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
const TAG_PATTERN = '^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$'
|
|
372
|
+
|
|
373
|
+
const tagNameSchema = (
|
|
374
|
+
known: ReadonlyArray<string>,
|
|
375
|
+
allowNewTags: boolean,
|
|
376
|
+
): JsonSchema => {
|
|
377
|
+
if (known.length === 0) {
|
|
378
|
+
return { type: 'string', pattern: TAG_PATTERN }
|
|
379
|
+
}
|
|
380
|
+
if (!allowNewTags) {
|
|
381
|
+
return stringEnum(known)
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
anyOf: [stringEnum(known), { type: 'string', pattern: TAG_PATTERN }],
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const wireIfThen = (
|
|
389
|
+
surfaces: ReadonlyArray<ManifestTag>,
|
|
390
|
+
): ReadonlyArray<JsonSchema> =>
|
|
391
|
+
surfaces.flatMap(surface => {
|
|
392
|
+
const clauses: Array<JsonSchema> = []
|
|
393
|
+
const events = surface.events.map(event => event.name)
|
|
394
|
+
clauses.push({
|
|
395
|
+
if: {
|
|
396
|
+
properties: { from: { const: surface.tag } },
|
|
397
|
+
required: ['from'],
|
|
398
|
+
},
|
|
399
|
+
then: {
|
|
400
|
+
properties: { event: stringEnum(events) },
|
|
401
|
+
},
|
|
402
|
+
})
|
|
403
|
+
const inputs = surface.attributes.map(attribute => attribute.name)
|
|
404
|
+
clauses.push({
|
|
405
|
+
if: {
|
|
406
|
+
properties: { to: { const: surface.tag } },
|
|
407
|
+
required: ['to'],
|
|
408
|
+
},
|
|
409
|
+
then: {
|
|
410
|
+
properties: { input: stringEnum(inputs) },
|
|
411
|
+
},
|
|
412
|
+
})
|
|
413
|
+
return clauses
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
const VALUE_TYPES: ReadonlyArray<string> = [
|
|
417
|
+
'string',
|
|
418
|
+
'number',
|
|
419
|
+
'boolean',
|
|
420
|
+
'json',
|
|
421
|
+
'enum',
|
|
422
|
+
]
|
|
423
|
+
|
|
424
|
+
const ENV_ENUM: JsonSchema = stringEnum(['dev', 'preview', 'prod'])
|
|
425
|
+
|
|
426
|
+
const transformSchema: JsonSchema = {
|
|
427
|
+
oneOf: [
|
|
428
|
+
objectSchema({ _tag: { const: 'direct' } }, ['_tag']),
|
|
429
|
+
objectSchema(
|
|
430
|
+
{
|
|
431
|
+
_tag: { const: 'lookup' },
|
|
432
|
+
rows: {
|
|
433
|
+
type: 'object',
|
|
434
|
+
additionalProperties: { type: 'string' },
|
|
435
|
+
},
|
|
436
|
+
fallback: { type: 'string' },
|
|
437
|
+
},
|
|
438
|
+
['_tag', 'rows', 'fallback'],
|
|
439
|
+
),
|
|
440
|
+
objectSchema(
|
|
441
|
+
{
|
|
442
|
+
_tag: { const: 'condition' },
|
|
443
|
+
field: { type: 'string' },
|
|
444
|
+
op: { const: 'is' },
|
|
445
|
+
value: { type: 'string' },
|
|
446
|
+
},
|
|
447
|
+
['_tag', 'field', 'op', 'value'],
|
|
448
|
+
),
|
|
449
|
+
],
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const wireSchema = (
|
|
453
|
+
surfaces: ReadonlyArray<ManifestTag>,
|
|
454
|
+
allowNewTags: boolean,
|
|
455
|
+
): JsonSchema => {
|
|
456
|
+
const known = surfaces.map(tagOf)
|
|
457
|
+
const base = objectSchema(
|
|
458
|
+
{
|
|
459
|
+
id: { type: 'string', minLength: 1 },
|
|
460
|
+
from: tagNameSchema(known, allowNewTags),
|
|
461
|
+
event: { type: 'string', minLength: 1 },
|
|
462
|
+
field: { type: 'string', minLength: 1 },
|
|
463
|
+
fieldType: stringEnum(VALUE_TYPES),
|
|
464
|
+
to: tagNameSchema(known, allowNewTags),
|
|
465
|
+
input: { type: 'string', minLength: 1 },
|
|
466
|
+
inputType: stringEnum(VALUE_TYPES),
|
|
467
|
+
transform: transformSchema,
|
|
468
|
+
envs: { type: 'array', items: ENV_ENUM, minItems: 1 },
|
|
469
|
+
plain: { type: 'string' },
|
|
470
|
+
},
|
|
471
|
+
[
|
|
472
|
+
'id',
|
|
473
|
+
'from',
|
|
474
|
+
'event',
|
|
475
|
+
'field',
|
|
476
|
+
'fieldType',
|
|
477
|
+
'to',
|
|
478
|
+
'input',
|
|
479
|
+
'inputType',
|
|
480
|
+
'transform',
|
|
481
|
+
'envs',
|
|
482
|
+
'plain',
|
|
483
|
+
],
|
|
484
|
+
)
|
|
485
|
+
const clauses = wireIfThen(surfaces)
|
|
486
|
+
if (clauses.length === 0) {
|
|
487
|
+
return base
|
|
488
|
+
}
|
|
489
|
+
return { allOf: [base, ...clauses] }
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const placementSchema = (
|
|
493
|
+
surfaces: ReadonlyArray<ManifestTag>,
|
|
494
|
+
allowNewTags: boolean,
|
|
495
|
+
): JsonSchema =>
|
|
496
|
+
objectSchema(
|
|
497
|
+
{
|
|
498
|
+
id: { type: 'string' },
|
|
499
|
+
tag: tagNameSchema(surfaces.map(tagOf), allowNewTags),
|
|
500
|
+
slotId: { type: 'string', minLength: 1 },
|
|
501
|
+
values: {
|
|
502
|
+
type: 'object',
|
|
503
|
+
additionalProperties: { type: 'string' },
|
|
504
|
+
},
|
|
505
|
+
condition: objectSchema(
|
|
506
|
+
{
|
|
507
|
+
who: { type: 'string' },
|
|
508
|
+
device: { type: 'string' },
|
|
509
|
+
locale: { type: 'string' },
|
|
510
|
+
},
|
|
511
|
+
[],
|
|
512
|
+
),
|
|
513
|
+
envs: { type: 'array', items: ENV_ENUM },
|
|
514
|
+
span: { enum: [12, 8, 6, 4, 3] },
|
|
515
|
+
order: { type: 'number' },
|
|
516
|
+
},
|
|
517
|
+
['tag', 'slotId'],
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
const routeSchema = (
|
|
521
|
+
surfaces: ReadonlyArray<ManifestTag>,
|
|
522
|
+
allowNewTags: boolean,
|
|
523
|
+
): JsonSchema =>
|
|
524
|
+
objectSchema(
|
|
525
|
+
{
|
|
526
|
+
path: { type: 'string', minLength: 1 },
|
|
527
|
+
label: { type: 'string', minLength: 1 },
|
|
528
|
+
title: { type: 'string', minLength: 1 },
|
|
529
|
+
sectionIds: { type: 'array', items: { type: 'string' } },
|
|
530
|
+
mounts: {
|
|
531
|
+
type: 'array',
|
|
532
|
+
items: placementSchema(surfaces, allowNewTags),
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
['path', 'label', 'title', 'sectionIds', 'mounts'],
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
const slotSpecSchema = objectSchema(
|
|
539
|
+
{
|
|
540
|
+
id: { type: 'string', minLength: 1 },
|
|
541
|
+
kind: stringEnum(['bar', 'band', 'rail', 'grid']),
|
|
542
|
+
row: { type: 'number' },
|
|
543
|
+
width: { type: 'string' },
|
|
544
|
+
capacity: { type: 'number' },
|
|
545
|
+
},
|
|
546
|
+
['id', 'kind', 'row'],
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
const topologySchema = (
|
|
550
|
+
surfaces: ReadonlyArray<ManifestTag>,
|
|
551
|
+
allowNewTags: boolean,
|
|
552
|
+
): JsonSchema =>
|
|
553
|
+
objectSchema(
|
|
554
|
+
{
|
|
555
|
+
host: objectSchema(
|
|
556
|
+
{
|
|
557
|
+
id: { type: 'string', minLength: 1 },
|
|
558
|
+
label: { type: 'string', minLength: 1 },
|
|
559
|
+
ownedInputs: {
|
|
560
|
+
type: 'array',
|
|
561
|
+
items: objectSchema(
|
|
562
|
+
{ name: { type: 'string' }, type: { type: 'string' } },
|
|
563
|
+
['name', 'type'],
|
|
564
|
+
),
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
['id', 'label', 'ownedInputs'],
|
|
568
|
+
),
|
|
569
|
+
routes: {
|
|
570
|
+
type: 'array',
|
|
571
|
+
minItems: 1,
|
|
572
|
+
items: routeSchema(surfaces, allowNewTags),
|
|
573
|
+
},
|
|
574
|
+
overview: objectSchema(
|
|
575
|
+
{
|
|
576
|
+
path: { type: 'string' },
|
|
577
|
+
label: { type: 'string' },
|
|
578
|
+
title: { type: 'string' },
|
|
579
|
+
leadingSectionIds: { type: 'array', items: { type: 'string' } },
|
|
580
|
+
},
|
|
581
|
+
['path', 'label', 'title'],
|
|
582
|
+
),
|
|
583
|
+
slotManifest: objectSchema(
|
|
584
|
+
{
|
|
585
|
+
theme: objectSchema(
|
|
586
|
+
{ name: { type: 'string' }, version: { type: 'string' } },
|
|
587
|
+
['name', 'version'],
|
|
588
|
+
),
|
|
589
|
+
slots: { type: 'array', items: slotSpecSchema },
|
|
590
|
+
},
|
|
591
|
+
['theme', 'slots'],
|
|
592
|
+
),
|
|
593
|
+
rules: {
|
|
594
|
+
type: 'array',
|
|
595
|
+
items: {
|
|
596
|
+
oneOf: [
|
|
597
|
+
objectSchema(
|
|
598
|
+
{
|
|
599
|
+
id: { type: 'string' },
|
|
600
|
+
label: { type: 'string' },
|
|
601
|
+
kind: { const: 'pattern' },
|
|
602
|
+
pattern: { type: 'string' },
|
|
603
|
+
placements: {
|
|
604
|
+
type: 'array',
|
|
605
|
+
items: placementSchema(surfaces, allowNewTags),
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
['id', 'label', 'kind', 'pattern', 'placements'],
|
|
609
|
+
),
|
|
610
|
+
objectSchema(
|
|
611
|
+
{
|
|
612
|
+
id: { type: 'string' },
|
|
613
|
+
label: { type: 'string' },
|
|
614
|
+
kind: { const: 'group' },
|
|
615
|
+
paths: { type: 'array', items: { type: 'string' } },
|
|
616
|
+
placements: {
|
|
617
|
+
type: 'array',
|
|
618
|
+
items: placementSchema(surfaces, allowNewTags),
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
['id', 'label', 'kind', 'paths', 'placements'],
|
|
622
|
+
),
|
|
623
|
+
objectSchema(
|
|
624
|
+
{
|
|
625
|
+
id: { type: 'string' },
|
|
626
|
+
label: { type: 'string' },
|
|
627
|
+
kind: { const: 'dynamic' },
|
|
628
|
+
template: { type: 'string' },
|
|
629
|
+
placements: {
|
|
630
|
+
type: 'array',
|
|
631
|
+
items: placementSchema(surfaces, allowNewTags),
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
['id', 'label', 'kind', 'template', 'placements'],
|
|
635
|
+
),
|
|
636
|
+
],
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
wires: { type: 'array', items: wireSchema(surfaces, allowNewTags) },
|
|
640
|
+
watch: {
|
|
641
|
+
type: 'array',
|
|
642
|
+
items: objectSchema({ event: { type: 'string' } }, ['event']),
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
['host', 'routes', 'wires', 'watch'],
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* JSON Schema (draft 2020-12) for constrained decoding of a CompositionSpec.
|
|
650
|
+
* Event and attribute names are closed over the supplied surfaces, so a
|
|
651
|
+
* model cannot emit `from: "readout-view", event: "bid-placed"`.
|
|
652
|
+
*/
|
|
653
|
+
export type CompositionJsonSchemaOptions = {
|
|
654
|
+
/** When false, topology tags are a closed enum of the catalog. Default true. */
|
|
655
|
+
readonly allowNewTags?: boolean
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export const compositionJsonSchema = (
|
|
659
|
+
surfaces: ReadonlyArray<ManifestTag>,
|
|
660
|
+
options: CompositionJsonSchemaOptions = {},
|
|
661
|
+
): JsonSchema => {
|
|
662
|
+
const allowNewTags = options.allowNewTags ?? true
|
|
663
|
+
return {
|
|
664
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
665
|
+
$comment:
|
|
666
|
+
'Legal tags, events, attributes and transforms are derived from the ManifestTag catalog. Do not invent names.',
|
|
667
|
+
...objectSchema(
|
|
668
|
+
{
|
|
669
|
+
schemaVersion: { const: 1 },
|
|
670
|
+
brief: { type: 'string' },
|
|
671
|
+
generate: {
|
|
672
|
+
type: 'array',
|
|
673
|
+
items: objectSchema(
|
|
674
|
+
{
|
|
675
|
+
slug: {
|
|
676
|
+
type: 'string',
|
|
677
|
+
pattern: '^[a-z][a-z0-9-]*$',
|
|
678
|
+
},
|
|
679
|
+
tag: { type: 'string', pattern: TAG_PATTERN },
|
|
680
|
+
port: { type: 'integer', minimum: 1, maximum: 65535 },
|
|
681
|
+
},
|
|
682
|
+
['slug', 'tag', 'port'],
|
|
683
|
+
),
|
|
684
|
+
},
|
|
685
|
+
topology: topologySchema(surfaces, allowNewTags),
|
|
686
|
+
},
|
|
687
|
+
['schemaVersion', 'generate', 'topology'],
|
|
688
|
+
),
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Test/helper: event names the schema admits when `from` is this tag. */
|
|
693
|
+
export const schemaEventsFor = (
|
|
694
|
+
schema: JsonSchema,
|
|
695
|
+
fromTag: string,
|
|
696
|
+
): ReadonlyArray<string> => {
|
|
697
|
+
const topology = schema.properties?.['topology']
|
|
698
|
+
const wires = topology?.properties?.['wires']
|
|
699
|
+
const items = wires?.items
|
|
700
|
+
const clauses = items?.allOf ?? (items === undefined ? [] : [items])
|
|
701
|
+
for (const clause of clauses) {
|
|
702
|
+
if (clause.if?.properties?.['from']?.const === fromTag) {
|
|
703
|
+
const enumerated = clause.then?.properties?.['event']?.enum
|
|
704
|
+
if (enumerated === undefined) {
|
|
705
|
+
return []
|
|
706
|
+
}
|
|
707
|
+
return Array.getSomes(
|
|
708
|
+
enumerated.map(value =>
|
|
709
|
+
typeof value === 'string' ? Option.some(value) : Option.none(),
|
|
710
|
+
),
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return []
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** Test/helper: attribute names the schema admits when `to` is this tag. */
|
|
718
|
+
export const schemaInputsFor = (
|
|
719
|
+
schema: JsonSchema,
|
|
720
|
+
toTag: string,
|
|
721
|
+
): ReadonlyArray<string> => {
|
|
722
|
+
const topology = schema.properties?.['topology']
|
|
723
|
+
const wires = topology?.properties?.['wires']
|
|
724
|
+
const items = wires?.items
|
|
725
|
+
const clauses = items?.allOf ?? (items === undefined ? [] : [items])
|
|
726
|
+
for (const clause of clauses) {
|
|
727
|
+
if (clause.if?.properties?.['to']?.const === toTag) {
|
|
728
|
+
const enumerated = clause.then?.properties?.['input']?.enum
|
|
729
|
+
if (enumerated === undefined) {
|
|
730
|
+
return []
|
|
731
|
+
}
|
|
732
|
+
return Array.getSomes(
|
|
733
|
+
enumerated.map(value =>
|
|
734
|
+
typeof value === 'string' ? Option.some(value) : Option.none(),
|
|
735
|
+
),
|
|
736
|
+
)
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return []
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** Legacy v1 decoder, preserved through the Phase 6 saved-spec migration. */
|
|
743
|
+
export const decodeCompositionSpec = S.decodeUnknownEffect(CompositionSpecV1)
|
|
744
|
+
/** Legacy v1 decoder, preserved through the Phase 6 saved-spec migration. */
|
|
745
|
+
export const decodeCompositionSpecSync = S.decodeUnknownSync(CompositionSpecV1)
|
|
746
|
+
|
|
747
|
+
export const decodeCompositionSpecVersioned = S.decodeUnknownEffect(
|
|
748
|
+
CompositionSpecVersioned,
|
|
749
|
+
)
|
|
750
|
+
export const decodeCompositionSpecVersionedSync = S.decodeUnknownSync(
|
|
751
|
+
CompositionSpecVersioned,
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
export const encodeCompositionSpecV2Sync = S.encodeSync(CompositionSpecV2)
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* The only lossless v1 migration available before catalogs exist is a
|
|
758
|
+
* topology-only composition. Legacy generate rows lack domain, shape,
|
|
759
|
+
* capability, target and provenance semantics, so Phase 0 reports the closed
|
|
760
|
+
* input boundary instead of guessing them.
|
|
761
|
+
*/
|
|
762
|
+
export const migrateCompositionSpecV1 = (
|
|
763
|
+
spec: CompositionSpecV1,
|
|
764
|
+
catalogLock: CatalogLockV1,
|
|
765
|
+
): MigrationResult<CompositionSpecV2> => {
|
|
766
|
+
if (spec.generate.length === 0) {
|
|
767
|
+
const value: CompositionSpecV2 = {
|
|
768
|
+
schemaVersion: 2,
|
|
769
|
+
generate: [],
|
|
770
|
+
topology: spec.topology,
|
|
771
|
+
catalogLock,
|
|
772
|
+
...(spec.brief === undefined ? {} : { brief: spec.brief }),
|
|
773
|
+
}
|
|
774
|
+
return migrated(value)
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
return migrationNeedsInput(
|
|
778
|
+
decodeAuthoringDiagnosticSync({
|
|
779
|
+
schemaVersion: 1,
|
|
780
|
+
code: 'migration-input-required',
|
|
781
|
+
phase: 'migrate',
|
|
782
|
+
path: ['generate'],
|
|
783
|
+
entityIds: spec.generate.map(generated => generated.slug),
|
|
784
|
+
severity: 'error',
|
|
785
|
+
retryable: true,
|
|
786
|
+
references: [
|
|
787
|
+
'wiki/plans/active/microdots-authoring-catalog-programme.md',
|
|
788
|
+
],
|
|
789
|
+
destinationMutated: false,
|
|
790
|
+
nextLane: 'application-correction',
|
|
791
|
+
parameters: {
|
|
792
|
+
required: [
|
|
793
|
+
'fronts',
|
|
794
|
+
'domain',
|
|
795
|
+
'capabilities',
|
|
796
|
+
'target-profile',
|
|
797
|
+
'provenance',
|
|
798
|
+
],
|
|
799
|
+
},
|
|
800
|
+
}),
|
|
801
|
+
)
|
|
802
|
+
}
|